Repository navigation
Memory: one mutation authority for every caller, notebook-only tidy - #2966
Merged
Merged
Conversation
Brains, scope bindings and the member-brain switch live in one memory_registry record. Every memory operation resolves the current version through a facade, so registry changes reach every instance without a redeploy. MEMORY_PROVIDER_CONFIG stays a read-only overlay, and registry entries pass through its validator.
knip flagged BRAIN_PRESETS, brainEndpoint and three types as exports nobody imports.
gbrain-v1 can't read a shared scope as the scope, so scope brains, pools and claim-once source reservations had no consumer until the preset that serves them lands; they move to QI3 with it. The record drops memberBrains, owner and the per-brain enabled flag, a binding's delete is a delete, and the bindings cap goes. Each disabled binding carries a detail, the store reads its version from DurableMap instead of querying the versions table, the scope-kind list has one home, and the unused createConfiguredMemoryService path is gone, with its tests on the production facade.
An agent may register a brain for an org admin but must never carry its
client secret. POST /v1/keychain/drops now takes { memoryBrain, purpose }:
only an active org admin can mint or redeem it, the form shows the brain,
its origin and who is bound to it, and the secret is written straight into
the registry record's brain entry, never a keychain credential or grant.
The link is bound to the brain id and url, so a moved brain refuses it.
A registered brain's client secret was stored as plain text in the memory_registry row. Seal it with a key derived from CONNECTOR_SECRET_KEY, as connector client secrets and model credentials are, and open it only when the routed service is built. A secret sealed under another key disables its bindings with invalid_config and says to set it again.
- Brain drops need only secret drops and the registry, not a keychain: mint and redeem check the keychain only for keychain drops. - Redeem runs its shared steps (validate, take the link, org check) once and branches on memoryBrain after them; the brain branch writes through putBrain, so the secret gets the same validation and encryption as the admin API. - One adminStanding check in shared.ts backs authorizeAdmin and the brain link, canonicalizing the principal for both. - A brain drop takes only memoryBrain and purpose (an allowlist), stores no grant mode, destination or thread, and never counts as a pending sibling of a keychain drop.
A brain link now checks admin standing and the brain url before it uses up the one-time link, so a refused save leaves the link working. Refusals are audited as memory-registry.brain.secret.refused with the reason, and the drop form shows the server message instead of a generic error. Drop links need a keychain again: production always has one, because buildApp refuses to start without CONNECTOR_SECRET_KEY.
redactBrain dropped clientSecretEnc and passed every other auth field through, so a row written by an earlier build of this branch, which stored auth.clientSecret in plaintext, showed that secret on GET. The view now builds auth from clientId and hasClientSecret alone.
The form now shows the server message on a failed save, so an empty submit
would have shown the API text "expected { secret }". Marking the inputs
required stops an empty submit in the browser on both keychain and brain forms.
A member can register their own gbrain as their personal memory from chat (GET/PUT/DELETE /v1/memory/brain, called as the speaker) and set its client secret through the core-only brain link. The brain is owned by them, serves only personal:<their id>, needs a public https URL and is called through the address-guarded fetch. An org switch (PUT /v1/admin/memory-registry/member-brains) turns every member brain off at once. Every MCP memory provider call now cancels its request when the deadline fires, and responses are cut off at 1 MB.
…n a live turn The size limit moves into the MCP client: when a response passes maxResponseBytes, the session aborts the pending request with the size error, so a streamed SSE reply fails at the limit instead of waiting for the deadline as a timeout. Member brain routes now need a turn the member sent, matching the agent API catalog. memberBrainsOn keeps the switch's default in one place, a member's view resolves only their own brain, members no longer set timeoutMs, and the tests drop duplicate refusals and run on short deadlines.
…core
Memory rows now carry what they are instead of a display line and a score.
A notebook row keeps its fact id and raw claim; a structured MCP brain row
keeps a "<provider id>:<fact id>" ref, its claim and gbrain's own/ranked arm.
The router returns every provider's rows tagged with their source and no
longer flattens and cuts them, so a full first provider cannot starve the
next one.
Cross-scope recall and search take rows from each (scope, provider, arm)
list in turn, in the order each list arrived, until the budget or count is
full. No scores are compared across scopes or providers. A claim held by
several copies prints once, as the notebook copy when there is one, and
names the other copies ("also acme-company:4821") so each stays addressable.
The recall delta records a brain row's ref next to its text, so refs and
notebook ids never collide and older binaries read ref rows as id-less
provider lines.
A structured gbrain fact renders as "- <fact> (<date>; <kind>)", the line gbrain itself returns, so recall text for gbrain rows reads as before.
A brain row's ref is recorded with it but does not join the delta key, so records written before and after this change compare equal and an upgrade re-announces nothing.
… malformed facts loudly Review fixes: - An MCP provider takes its own arm labels in turn inside its own list, so every (scope, provider) gets one turn per round; MemoryCandidate no longer carries an arm. - The router qualifies a provider's fact id into "<provider>:<fact id>", so the provider no longer needs its id passed twice. - The recall-delta record and RecalledFact are back to their earlier shape; nothing read the stored ref. - Copies compare by the notebook's own claim normalization, the kept copy is chosen by scope and route order, and only search names the other copies, since nothing in recall can act on the address. - Structured rows render in QM's fact-line shape and honour maxChars, a refused read yields no rows in either answer shape, and a malformed facts answer fails as McpCallError code malformed_fact. - Tests share one structured-recall fake and drop the duplicated delta test.
… nits - The MCP provider keeps a structured answer in the provider's own order; reading an arm label lands with the provider version that sends one. - actor_unlinked is handled once, in the provider's read path. - A first structured fact longer than maxChars still passes the provider's local cut, so the shared recall budget decides. - Turn-taking lives in cross-scope.ts, its only caller. - Docs say search names the other provider copies.
…e notebook Every capture, edit and delete now names who is acting and in what context: a conversation, self-service, an admin, or maintenance. Only a conversation's own write reaches a provider other than the notebook. Admin writes through /v1/admin/memory, onboarding status, self-service edits, QM's note about missing channel history, writes made from another conversation, and a write with no named actor change the notebook alone. Before this, an admin's write into a member's notebook and every onboarding marker were also sent to the member's brain, attested as the admin. The capture context's separate actorId is gone; the authority carries the actor, and providers attest that id. The managed tidy job's turns are notebook-only. The scheduler recognizes the job by its claim in memory_tidy_profiles, not by title or task, and marks the turn; recall, search and writes in that turn, and the agent API token it mints, see the notebook alone.
The copy is a separately created cron, so its id never equals the tidy job id.
Main now hands memory providers session items (sessionItems, loadItems) instead of session entries; the registry memory service and its factory take the same rename.
authorizeAdmin keeps the shared adminStanding check and also records the admin actor and scope on the model request context, as main now does.
The member brain options take main's sessionItems rename beside fetchFor and memberFetch.
Main moved provider-factory to a destructured operation() and a shared 30-second write deadline; keep both and drop the routed-service import, since registry-memory routes now. Port main's gbrain contract test and the memorable e2e tests from createConfiguredMemoryService to createRegistryMemoryService with an empty registry.
Brings in main through QI1. Main removed the workspace layer from the API server options, so the brain-secret drop test stops passing one.
Brings in main through QI1 and QI2a. Main gave MCP memory writes one 30-second deadline shared by every batch and moved operation() to a destructured spec; keep both alongside the member fetch, the 1 MB response cap and the abort signal that cancels a call when its deadline passes. The provider docs now state the read and write deadlines and that a deadline cancels the connection.
The recall delta kept facts recalled from a binding that later became disabled, as if the brain had only failed for a moment, so the model never heard they were no longer authorized; a disabled binding now counts like a refusal or a deleted binding. A disabled binding also reported memory_provider_disabled for writes a working brain never receives (a system actor, or facts from another conversation), because the facade checked only the capture policy. The router's skip rule moves into capturesInto, which the router and the facade both use.
Brings in the disabled-binding fix.
Brings in the disabled-binding fix from QI1.
A served registry brain has no facts list, so in a shared room the disclosure withholds it before any call and nothing is reported. A disabled binding for the same scope still reported memory_provider_disabled there, which put a problem line in the room and turned /v1/memory/search into a 502. Recall and search now flag a disabled binding only when the disclosure would let its scope through, using the same scopeDisclosed check the disclosure applies to a provider without facts.
Main (#3197, #3204) removed the web UI's admin relay and its allow-list, so a browser now reaches an admin route only when the core route opts in. The registry's seven admin routes sit with the other admin page routes, so they are listed in test/admin-browser.test.ts's ADMIN_PAGES. The old relay allow-list entry and its relay tests go away with the files.
Main's #3199 made the keychain drop mint read the person through personalCapability, so a signed-in browser can mint a drop. The import line now names both that helper and this branch's adminStanding and orgScope.
…n link Since main's #3199, a signed-in person can mint a keychain drop from the browser, where the request carries a session instead of an agent capability. The brain link check read the admin standing from the agent capability alone, so a browser mint of a brain link failed with a 500. It now takes the person mintDrop already resolved, so an admin gets the link and a member gets a 403.
The base branch now checks a brain link's admin standing against the person mintDrop resolved, so a signed-in browser can mint one. This branch's owner-or-admin check takes that same person, and the browser mint test sits beside the member brain tests in one setup.
Since main moved browser admin access into core, test/admin-browser.test.ts names every admin route a signed-in browser may reach. This branch's PUT /v1/admin/memory-registry/member-brains sits with the other registry routes the admin page uses, so it joins that list.
Main now carries the memory registry this branch builds on, squashed. The merge replays this branch's own changes on top of it: the secret drop link for a registered brain, the admin standing helper and the docs and catalog lines.
The memory registry now refuses a request that sets a brain's client secret without naming its url. The secret link already checks that the brain's url is unchanged since the link was made, so it now sends that url with the secret. Without it every link save answered 400.
…ndpoint A memory brain secret link now carries its own link audience, so a core that predates brain links (the other colour mid-deploy, or a rollback) refuses it instead of saving the brain's client secret into the admin's keychain. The form names the brain's origin and path, so an admin on a shared MCP host sees which endpoint receives the secret, and a link whose brain is gone or has moved shows the invalid-link page instead of a form that can only fail. The link works again if the brain moves back. Two sleeps in the brain link tests waited for nothing and are removed.
Brings in main by way of the parent branch. Resolves the overlaps: an unreadable registry answers 409 on every route that reads it, including a member's own brain; schema 2 records stay readable; setting a member brain's secret restates its url, like every other secret write; and the MCP client keeps its response size limit with main's url trimming.
A secret link whose memory brain was removed or moved now answers with a page that says so, and that the link works again if the brain moves back, instead of the generic expired-link page. The refusal is audited with its reason, the same way a refused save is, so an operator can see which brain and which change stopped it.
Brings in the parent's notice to an admin when a brain changed after its secret link was made. No conflicts.
Main now withholds a recall from a brain that attests the person unless the answer goes back to that person's own scope. The member brain tests searched with no audience, so the brain was never called and the guarded fetch, deadline, token request and size limit checks never ran. The tests now search as a turn whose answer goes back to the member, and assert the same refusals as before.
Brings in the parent branch's member brain test fix. The one conflict is in test/memory-member-brain.test.ts: the search helper keeps this branch's typed rows (mapping each row to its text) and takes the parent's audienceScopeId, so the member's brain is asked under main's attested-audience rule. No product code changed.
Two member brain tests slept a fixed 50 ms before asserting on work that finishes asynchronously: the audit rows a member's brain changes write, and the server seeing an oversized stream closed. On a loaded runner either could land later and fail the test. Both now poll for the condition with a bound, the way the neighbouring session-end checks already do, and keep their assertions unchanged. Review-Finding: test/memory-brain-secret-drop.test.ts:346:timing-dependent-test reader=core Review-Finding: test/memory-member-brain.test.ts:266:timing-dependent-test reader=core
Main now carries the shared memory registry change this branch was stacked on. Its files match what this branch already had, so the conflicts resolve to this branch's side and the change against main is the same member brain work as before.
Brings in main's latest changes through the parent branch. The merge is clean, and the typed recall change against its parent is unchanged.
Brings in main's rule that an attested memory provider is called only for the member whose private conversation it is. Memory captures still take their author from the write's authority, and now also carry the conversation's audience. Tests follow main's audience fixtures with this branch's authority arguments.
Brings in main's latest changes through the parent branch. The merge is clean.
Combines the test-only timing fix with the earlier merge of main. No conflicts.
A member can save their own brain's client secret through a single-use link. That path accepted any secret up to the API's 1,000,000-byte body limit, so one member could grow the shared memory registry record that every instance reloads after each change. A member-owned brain's secret now has the same 8,000-character limit as the member's url, clientId and label, and a longer one is refused before anything is written. Admin writes to org brains stay uncapped. Review-Finding: v1/outside-voice#1 reader=outside-voice
Brings in the parent's cap on a member brain's client secret and its test timing fix. The merge is clean, and the typed recall change against its parent is unchanged.
Brings in the cap on a member brain's client secret and a test timing fix through the parent branch.
Main now carries the member brain and typed recall changes as squash commits. This branch already held both, so the merge keeps its own content, and the provider router conflict takes this branch's side.
…ook-only rule A notebook-only turn could message an existing subagent of an ordinary conversation with wake or interrupt. The run it queued there was notebook-only, but nothing marked that session, so its return woke the ordinary parent without the rule and a watch armed during the run fired later without it either. That let the nightly tidy reach a member's brain one hop away. A notebook-only turn now starts work only in a session that already follows the rule, such as one of its own subagents. Any other session gets the message as mail, and an interrupt is refused, the same way a read-only turn is held back at this boundary. Review-Finding: q1/red-team#1 reader=red-team
…s guide The guide lists the wakes that fall back to the inbox. A notebook-only sender, such as the nightly memory tidy, now falls back to the inbox when its target session isn't notebook-only, and its interrupt to such a session is refused. Add both to the guide so it matches the code.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Built on #2961 (typed recall rows), #2945, #2890 and #2878, which have all landed on main.
This is the safety step before QM can ever change a brain. Today the memory service can't tell who is changing a notebook or why: an admin's write, an onboarding marker, a member's own edit and the nightly tidy all arrive as a bare author id. So an admin adding a fact to a member's notebook also sends it to that member's gbrain, attested as the admin, and every onboarding marker lands in the member's brain too. And the nightly tidy, which runs as the person and is told to delete stale facts, recalls and searches the person's brain like any other turn. After this PR every change names its actor and context, only a conversation's own writes reach a brain, and the tidy sees the notebook alone.
What changed
Every change names an authority.
MemoryService.capture,editanddeletetakeby: { actorId, context }where the bareauthorstring used to be. The context isconversation,self,adminormaintenance, and each caller names its own:memorytool add, edit, delete;POST,PATCH,DELETE /v1/memory/factsconversation, ormaintenancein a tidy turnconversation(a tidy turn, like every scheduled turn, makes none)PATCH,DELETE /v1/memory(a member's own notebook)selfPOST,PATCH,DELETE /v1/admin/memory; onboarding status set or reset by an adminadminmaintenance, as the channel or group itselfOnly a conversation reaches a brain. The router sends a capture to a provider other than the notebook only when the authority is a conversation with a named actor and the write comes from the scope's own conversation. Any other context, a blank actor, or no authority at all changes the notebook alone. This folds in the existing rule that a write from another conversation reaches only notebooks, so it's one condition in one place. Edit and delete already reach only the notebook, because no brain implements them; they now carry the authority K4b's brain forget will check.
The actor lives in one place.
MemoryCaptureContext.actorIdis gone. Providers attestby.actorId, the same id the notebook records as the fact's author, and the member-brains rule that a system actor never reaches an attested provider readsby.actorIdtoo.The nightly tidy is notebook-only. The scheduler asks the tidy service whether a cron is the job claimed for its notebook in
memory_tidy_profiles(the same check the tidy's skip uses) and marks that firememoryNotebookOnly. The mark is never read from the job's title or task, so a tidy whose owner edited its task keeps it, existing jobs are covered, and a copy with the same title and task isn't. The turn's memory view keeps only notebook routes, so its turn-start recall and itsmemorytool search and read see the notebook alone. Its writes carry themaintenanceauthority, so they change the notebook alone too. Its agent API token carriesmemory.notebookOnly, so/v1/memory/searchand/v1/memory/factscalled from its computer see the notebook alone too. A brain registered for that notebook isn't reported as disabled in a tidy turn, because the turn would never call it.What the tidy starts or wakes stays notebook-only. The mark travels the way
readOnlydoes: in the stored context of a subagent a notebook-only turn opens (sessions.spawn_meta), in the run request of each turn woken into that subagent, in the session messages it sends and in a swarm's template and every worker's run request. A session message from a notebook-only sender starts work only in a session whose stored context already carries the mark, such as a subagent it opened. Any other session gets the message as mail without starting a turn, and an interrupt to it is refused. The orchestrator also floors the turn itself, the way it floors read-only for a subagent: a turn into a subagent whose stored context carries the mark is notebook-only even when a person sends it, and every turn in the tidy's own conversation is notebook-only whatever wakes it, such as a background watch's monitor wake or a credential-approval continuation. A swarm's template also takes the mark from the calling turn's agent API token, which carries that decision.The tidy can't schedule work outside the rule. Jobs fire in their own conversations, so a notebook-only turn has no
cron,webhookorgoaltool, and its agent API token gets a 403 on every request other than a read to the cron, loop, loop-item, loop-ingress and webhook routes.docs/memory-providers.mdsays which writes reach a provider and how the tidy is recognized.Schema and payload changes
No table, column, index, constraint, migration or backfill.
Persisted JSON gains one optional key:
A run's stored request (
runs) and a pending approval's stored request carrymemoryNotebookOnly: trueon the tidy job's fires and on every turn a notebook-only turn starts or wakes: subagent turns, session messages and swarm workers. Every other request is unchanged. Example (invented values), before:{"surface":"cron","actor":{"externalId":"U1"},"conversation":{"kind":"dm","threadRef":"cron:ab12…"},"text":"[Cron runtime context]…","triggered":true,"idempotencyKey":"cron:ab12:1793688603610"}; after: the same plus"memoryNotebookOnly":true.sessions.spawn_metacarriesmemoryNotebookOnly: truefor a subagent a notebook-only turn opens. Example (invented values), before:{"surface":"cron","actor":{"id":"U1","type":"internal"},"conversation":{"kind":"dm","threadRef":"cron:ab12…:fire:9f3c…"}}; after: the same plus"memoryNotebookOnly":true.A swarm's stored template carries
memoryNotebookOnly: truewhen a notebook-only turn started the swarm, and the dispatch contract that binds each worker's run compares it.One stored value changes:
memory_facts.authorfor QM's missing-history note was NULL and is now the scope's own actor,system:channel:<id>orsystem:group:<id>, the id a channel's tidy already writes as. Existing rows are untouched.Not persisted:
memoryclaim,{ write?, orgWrite?, read }, gainsnotebookOnly?: trueon tidy turns.MemoryServicemutation methods takeby?: MemoryAuthorityinstead ofauthor?: string;MemoryCaptureContextdropsactorId;MemoryDisclosuregainsnotebookOnly?;SchedulerDepsgainsnotebookOnlyMemory?;TurnRequest,TriggerSpec,SpawnMeta,AgentTurnInputandAgentToolsOptionsgainmemoryNotebookOnly?;OrchestratorDepsgainsnotebookOnlyMemory?(threadRef); the tidy service gainsmanagesThread.memoryclaim carriesnotebookOnly, which only notebook-only turns get, receives403 {"error":"forbidden","message":"a notebook-only turn can't create or change crons, loops or webhooks"}on any non-GET cron, loop, loop-item, loop-ingress or webhook route.Rollout: the key is optional. An older worker ignores it and runs the tidy as today, and a newer worker given an older request does the same. Nothing can withdraw a brain fact before K4b, so a mixed fleet can't do harm in the meantime.
Configuration
No configuration change: no environment variable, setting, flag, mode or provider is added, removed or changed. One behaviour change for operators: providers routed through
MEMORY_PROVIDER_CONFIGor the memory registry no longer receive admin console writes, onboarding markers or the missing-history note. An admin who wants a fact in an org provider writes it from their own conversation, which still reaches it.Not in this PR
Brain forget and edit (K4b), K1a, K2a, rooms, any gbrain change.
Open notes
by, but nothing reads its context yet, because only the notebook can edit or delete. K4b reads it for brain forget.memoryNotebookOnlyis aTurnRequestfield, so a caller of the public turns API can set it too. It only narrows what that turn reaches.memoryNotebookOnly,notebookOnly,memory.notebookOnly,notebookOnlyMemory) and is copied besidereadOnlythrough many files, and the scheduling tool names and route groups are denylists, so a new job-creating tool or route has to join them. Reviewers rated these non-blocking.crontool. Its task never asks for one.agent-run.ts's one-line pass-through; the live QA reads the tool list from the prompt QM actually sent.MEMORY_PROVIDER_CONFIGcan set up, now gets nothing from an admin, onboarding or self-service write, and the admin console doesn't say so. That's the documented rule applied to callers that used to reach such a provider.self,adminandmaintenancebehave alike until K4b reads them; post-turn capture builds itsconversationauthority by hand instead of throughturnAuthority; a tidy fire looks up its claim twice (the skip check and the mark); "notebook route" is still the existingprovider.factstest, with no census of providers that might implement it, though no brain does; and the mark and the context could later fold into one value.Verification
tsc, eslint, oxlint and prettier clean.5ef7e7f, 90 affected test files pass locally throughscripts/run-pg-tests.mjs, among them main's and the member-brains stack's memory tests, which now pass an authority. At71d11dband37f68ec, which only merge the stacked branch's /ship fixes, the affected recall, provider, registry and router tests pass again. Ate9ad3c1, 122 affected test files pass, and atb307a27141 do, among them every test of the cron, loop, webhook and reach routes, ofcreateAgentTools, swarms, the tidy, the monitor poller and keychain-ask.b2e3e57only merges typed recall rows' final head cleanly, and CI's full run is green there. Atb4c57f1,test/session-syscalls.test.tspasses 118 of 118;d4eddb7changes one docs line, and CI's full run is green there.self,adminandmaintenancereach the notebook alone (test/memory-provider-router.test.ts).memoryadd writes to the brain once, attested as the member, while an admin's/v1/admin/memoryadd and edit, an admin onboarding update and the member's own/v1/memoryedit and delete change only the notebook. At the base commit the brain got three writes, two of them attested as the admin (test/memory-registry-pg.test.ts).test/memory-registry-pg.test.ts).test/memory-tidy.test.ts).test/session-syscalls.test.ts).test/swarms.test.ts).test/memory-registry-pg.test.ts).cron,webhookorgoaltool, its agent API token gets 403 onPOST /v1/crons,/v1/loopsand/v1/webhooks, and a turn woken into the tidy fire's own conversation from outside the scheduler sees no brain fact and makes no brain call (test/memory-registry-pg.test.ts).waketo a session whose stored context isn't notebook-only leaves mail and starts no run there, and itsinterruptis refused, while a subagent it opened still wakes and keeps the mark. This test fails atf86c853on the interrupt assertion (test/session-syscalls.test.ts).5ef7e7f, after the merge: web surface, real model, Slack off, against a local gbrain 0.60.586.0 (latest-stable) bound through the memory registry to the member's personal scope with recall on and capture explicit, behind a proxy that counted every request QM made to it. All test data was invented.memory_write, attested as the member, and gbrain committed it (fact id 1). Theinvalid_paramsgap the earlier QA hit is gone, because the merged gbrain recipe sends the facts argument. Post-turn automatic capture then added two notebook facts and sent gbrain nothing.DELETE /api/memorychanged only the notebook, kept each fact's author, and made zero gbrain requests. The portal delete returned 200; in the earlier QA it returned 400 because the portal proxy dropped DELETE bodies (Portal drops DELETE request bodies, so Memory "Forget" returns HTTP 400 #2967); main's fix for that is Frame every request body the portal relays, so DELETE bodies arrive #2968.memoryNotebookOnly, recalled only the notebook and forgot a near-duplicate. With its task edited to search memory, call/v1/memory/searchandPOST /v1/memory/factsfrom its computer, it still carried the mark, saw only notebook rows (no(also …)brain address) and committed the write to the notebook alone. Zero gbrain requests across both runs.memory_tidy_profilesran without the mark, made three recalls and one write to gbrain, and its search showed the brain address, so the zero counts above are real.e683cefalso saw the managed tidy fire on its own schedule with the mark set, recall only the notebook, delete six notebook facts and make zero gbrain requests.e9ad3c1, after the tidy lost its scheduling tools: the tool list in the prompt QM sent for the tidy's run-now had nocron,webhookorgoaltool, andPOST {}to/v1/crons,/v1/loopsand/v1/webhookswith its agent API token each returned 403 "a notebook-only turn can't create or change crons, loops or webhooks", with zero gbrain requests. The look-alike job kept those tools and got ordinary 400 validation errors, so the refusal is the tidy's alone.b2e3e57: the same checks pass again, and the tidy armed a backgroundwatchwhose monitor wake ran as a new turn in the tidy's own conversation. That turn recalled only the notebook, had no scheduling tools, found no codename and made zero gbrain requests across the fire and the wake, although gbrain held the codename. The tidy's fire ended withfinish_silently, so its recorded status issilent, a success. The look-alike's wake recalled from gbrain and answered with the gbrain-only codename.sessions.spawn_metaand swarm template JSON carryingmemoryNotebookOnly; and the limitsb4c57f1adds, where a notebook-only turn's session message to a session that isn't notebook-only leaves mail and its interrupt is refused.Ship receipt
Shipped at
d4eddb7. Two full review passes ran: pass 1 atf86c853, and pass 2 overf86c853..b4c57f1after the one fix round.d4eddb7then adds the notebook-only sender to the session guide after the first docs audit flagged it. Pregate: warn at tree834e3f1(7 preflight checks; two MEDIUM email-address findings in test fixtures, no credentials).Pre-Landing Review
REVIEW_LOOP: passes=2 blocking_found=1 deferred=0 noted=2 fixed=1 scope=frozen ledgers_read=2 dispatches=10
Review depth: full (gate class, auth paths). Pass 1: test coverage audit, batched specialist and red team. Pass 2: batched specialist, red team, adversarial (native), outside voice (another model family; clean) and a targeted auth review (no findings). Because the guide's new line makes a security claim, one more targeted auth read covered
d4eddb7and found nothing. The coverage audit noted two small test gaps, such as an empty or whitespace actorId being forced to notebook-only; no tests were added for them.Fixed in
b4c57f1: CRITICALsrc/sessions/session-syscalls.ts:939(pass 1, red team). A notebook-only turn could wake or interrupt an existing ordinary subagent, and that subagent's return then woke its ordinary parent without the rule. A notebook-only sender's wake into a session that isn't notebook-only now becomes mail, and its interrupt there is refused. A regression test fails before the fix and passes after it.Tests:
test/session-syscalls.test.ts, 118 passed, 0 failed (local, Postgres) atb4c57f1;d4eddb7changes onlydocs/persistent-subagent-sessions.md. The full suite runs in CI.Review notes
Below the blocking floor; recorded here and not fixed in this PR. Each ships under the standing owner ruling for findings at FIXABLE or below.
src/memory/strategies/per-turn.ts:166(pass 1, batched specialist). Post-turn capture ignores the notebook-only flag, so a turn a person sends into the tidy's own conversation still sends automatic capture to providers other than the notebook.src/sessions/session-syscalls.ts:1008(pass 2, batched specialist, state consistency). The guard reads the notebook-only flag stored in the target session's context, which can go stale on the tidy's own thread, so a tidy subagent's wake to its parent becomes mail. This fails closed.src/sessions/session-syscalls.ts:1008(pass 2, red team). The guard also turns a wake into another top-level conversation into mail, and no test pins that case.src/sessions/session-syscalls.ts:1008(pass 2, adversarial, fixable). The same stale-flag case, reproduced: a notebook-only subagent's wake to its own tidy parent becomes mail. Suggested fix: save the floored flag in the stored context atsrc/core/orchestrator.ts:1284.src/sessions/subagent-mail.ts:178(pass 2, adversarial, investigate). The rule is enforced at the session-message call site rather than at the layer every path goes through:parentTurnbuilds a parent's wake from the parent's own context and never carries a notebook-only child's flag up.src/sessions/session-syscalls.ts:1008(pass 2, adversarial, investigate). The mail fallback still delivers tidy-written text into sessions with full brain access, where a running turn can read it throughreceiveand an idle one on its next turn.Documentation
The first read-only audit, at
b4c57f1, found thatdocs/persistent-subagent-sessions.mddidn't list the notebook-only sender among the wakes that fall back to the inbox.d4eddb7adds it, and the second audit readd4eddb7:Status: current — the branch's documentation already matches the code; no edits needed.
Scope: read-only audit of 58 committed files from base 01d1a4a to head d4eddb7 (no staged, unstaged or new files). The change routes memory writes by authority (conversation, self, admin, maintenance) and makes the nightly memory tidy notebook-only across tools, the agent API, subagents, swarm workers and session messages. Reviewed the two docs the branch changed, plus related docs on capability tokens, swarms, memory records and the spec.
Coverage debt: the
MemoryAuthoritycontexts are internal and need no reference docs. The notebook-only rule has explanation-level coverage in memory-providers.md but no operator how-to (for example, why a tidy turn's cron call returns 403). That is a minor common gap.Diagram drift: none; the affected docs have no diagrams.
Wall of shame review report
Reviewed commit:
d4eddb7618a25d9c0f8b9b8d0c8718d4e09c7ebfb4c57f1fixes a finding from this PR's /ship review: a notebook-only turn could wake or interrupt an existing session that isn't notebook-only, and that session's later wakes then ran without the rule. Two refusals inwrite()in src/sessions/session-syscalls.ts now leave such a wake as mail and refuse such an interrupt, with one new test and one docs sentence. A claude-haiku-5-5 triage marked Prod-like QA relevant and Over-testing unsure, and fresh claude-sonnet-5-5 reviewers judged those two, the seven always-reviewed lenses, Explaining-away UI and Category gate without census (rows "At b4c57f1").d4eddb7adds the same two limits to docs/persistent-subagent-sessions.md, one line of prose; a claude-haiku-5-5 triage found no lens it touches, so every row carries forward tod4eddb7.f86c853merges main after typed recall rows landed there as one squashed commit. Main's tree at that commit is identical to the typed recall head this branch had already merged, so the one conflict (src/memory/provider-router.ts) resolves to this branch's side, the tree is identical to3b9c7c7, and this PR's diff against main is byte-identical to its earlier diff against typed recall rows. Every row carries forward tof86c853.3b9c7c7,0effef8and35c7a75merge typed recall rows after it took main's attested-audience rule, main's rule that a Slack bot keeps its restrictions when it steers a running turn (botActor), and a cap on a member brain's client secret. This PR's own diff changed only in conflict resolutions: the steer check insrc/sessions/session-syscalls.tslists this PR's notebook-only restriction beside main'sbotActor, and both fields are copied through the same request and meta lists; the memory tests take main's audience fixtures with this PR's authority argument; the swarm fixture builds this PR's sharedconversationfrom main's newchanneloption; and the docs keep both paragraphs. A claude-haiku-5-5 triage of those resolutions marked Category gate without census, Over-testing and Prod-like QA unsure and every other lens not relevant, and fresh claude-sonnet-5-5 reviewers reassessed those three and the seven always-reviewed lenses (rows "At 3b9c7c7"). Every other row carries forward to3b9c7c7.e663079is the head after #2878's SSRF fix for registry brains was merged up. The fix sends every registry brain through one guarded fetch and refuses private brain URLs at registration. This PR's own change in that merge is test-only (fd80b3e): the memory-authority tests in test/memory-registry-pg.test.ts register a public brain URL and reach their loopback test server through the test-onlyroutedTofetch. Fresh reviewers judged it atfd80b3e: the seven always-reviewed lenses, plus Explaining-away UI and Prod-like QA, which a claude-haiku-5-5 triage of the merge marked relevant. Duplication and Explaining-away UI were reassessed at the final refusal wording, and Prod-like QA was judged against a live dev-instance run atfd80b3e. Those rows read "At fd80b3e". The later merges bring in #2878's registration fixes (trailing-dot private names refused; hostname and url trims in linear time) and leave this PR's patch id unchanged, so those rows carry forward toe663079. Every other row carries forward as before.Earlier history:. This is the final reassessment. The lenses were first reviewed at
e683cefand3f9d55eand reassessed at5ef7e7fafter the branch merged typed recall rows (4446607), which carries the member-brains stack (ae53fc3). /ship then found that the notebook-only mark was lost for subagents, woken turns and swarm workers (fixed in68ff9e7), and this review's Category gate reviewers found three more ways out of the rule, each fixed and reassessed in turn: a person's own turn into a notebook-only subagent and a notebook-only steer (d8c630f); crons, loops, webhooks and goals created by a notebook-only turn (e9ad3c1); and a monitor wake or credential-approval resume in the tidy's own conversation (b307a27).b2e3e57merges typed recall rows' final head (9613837) cleanly and leaves this PR's own diff unchanged. Atb2e3e57, Haiku triaged every lens that isn't always reviewed against the whole diff, and a fresh reviewer reassessed Overengineering, Band-aid fixes, Duplication, Config-matrix expansion, God files, Category gate without census, Over-testing, Prod-like QA and Performance. Explaining-away UI, relevant sincee9ad3c1added a refusal message, was reviewed there;b307a27adds no user- or agent-facing text. Hand-rolling, Non-durability and Historylessness carry forward from68ff9e7, and Provider drift from5ef7e7f: the later commits add no library-like code, no process-local state, no compatibility shim and no sandbox or provider change. Non-blocking notes are listed under Open notes.write()(src/sessions/session-syscalls.ts:953, :1008), 7 lines beside the read-only refusals, reuse the storedmetaandleaveMail; no new flag, state or config.memoryNotebookOnlyflag beside the read-only ones; interrupt and wake each need their own check, and the docs sentence appears once.storedContext, the existing refusal shape andleaveMail; nothing reimplements a library or supported API.memoryNotebookOnlyrule rather than adding a mode.5ef7e7f: no sandbox backend changes, and the memory provider contract stays uniform: one notebook-only predicate, one actor source, and disabled registry routes pass through the same router.orchestrator.tsgains one clause and a narrow optional dep; the thread decision lives intidy.tson the existing claim check, and the authority rules stay inmemory-service.ts.memoryNotebookOnlymark copied at spawn, so its census is clear: the tidy's own subagents and thread carry the mark and are unchanged, while ordinary sessions and their parents get mail or a refused interrupt, as What changed now states.