Skip to content

Commit ed1798a

Browse files
authored
Memory: one mutation authority for every caller, notebook-only tidy (#2966)
* Add a durable memory registry and its org-admin API 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. * Keep the registry's internal names unexported knip flagged BRAIN_PRESETS, brainEndpoint and three types as exports nobody imports. * Serve personal brains only in phase 1 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. * Set a memory brain's client secret through a core-only drop link 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. * Encrypt brain client secrets at rest 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. * Address the QI2a lens review - 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. * Keep refused brain links usable and show why 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. * Show only known brain auth fields in the admin view 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. * Require a value in every drop form field 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. * Let members connect their own memory brain 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. * Fail an oversized memory reply at once and gate member brain routes on 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. * Keep recall and search rows' identity and merge scopes by turn, not score 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. * Show brain rows in gbrain's own line format A structured gbrain fact renders as "- <fact> (<date>; <kind>)", the line gbrain itself returns, so recall text for gbrain rows reads as before. * Key the recall delta 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. * Merge one lane per source, keep the recall record unchanged, and fail 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. * Drop provider arm handling until a provider sends it, and tidy review 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. * Give every memory change an authority and keep the nightly tidy on the 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. * Drop a tidy-test assertion that cannot fail The copy is a separately created cron, so its id never equals the tidy job id. * Report a disabled binding only where a served one would act 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. * Keep a disabled binding quiet where its brain would be withheld 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. * Route a disabled binding through the router like any other A disabled binding was reported from a side channel in the facade, so each router rule (capture policy, the skips for system actors and other conversations, disclosure in shared rooms) had to be re-applied there by hand, and the last two commits did exactly that. Each disabled binding is now its own one-route router over a stub brain that refuses with MemoryProviderDisabled, which failedOutcome maps to outcome disabled with the binding's code. The router and the disclosure apply their own rules to it, so the facade's flag, the exported capturesInto and scopeDisclosed helpers, and the routeMatches and captureAllowed exports are gone, and provider-router.ts and disclosure.ts match main again. * Drop the memory registry's invented length caps The registry's text fields refused values over made-up maximums: 256 characters for clientId and scope, 2,048 for url, 4,096 for clientSecret and 80 for labels. No provider, Postgres or measured bound needs them, and a real provider's long client id or secret would have been refused. Fields now only need to be non-empty strings. Binding ids reuse provider-config's id rule instead of carrying a copy. * Keep brain credentials as sent and keep the agent off a brain's url putBrain trimmed clientId and clientSecret before storing them, so a credential with surrounding whitespace failed authentication once bound. Both are now checked for blankness and kept byte for byte, as the MEMORY_PROVIDER_CONFIG path already keeps them. An admin's agent could repoint a brain that holds a secret to a new url, which clears the secret and leads the admin to set it again for a host they never chose. The agent's PUT now refuses a url change the way it refuses clientSecret, and both checks run after authorization and body validation, so a non-admin agent gets 403 and a null body gets 400 instead of 500. Review-Finding: s11/outside-voice#1 reader=outside-voice Review-Finding: s11/adversarial-native#1 reader=adversarial-native Review-Finding: s11/adversarial-native#5 reader=adversarial-native * Keep an admin's agent from deleting memory registry entries The url guard only covered an existing brain, so an agent could delete a secret-bearing brain's binding and the brain, register the same id at a url it chose and bind it again. The agent's DELETE of a brain or a binding is now refused with the same outside-chat 400, and the docs say exactly what the agent can and can't do. Review-Finding: p2/red-team#1 reader=red-team * Test the brain-link sibling rule where only it decides The test minted both drops with a thread, so the keychain drop's thread already kept the brain link out of its siblings and the test passed with the rule removed. Minting both without a thread leaves the rule as the only thing that separates them. * Refuse an agent's registry deletes through the route gate The two DELETE handlers threw their own 400 for an agent caller. The router's admin.agent.portalOnly gate already refuses an agent on an admin route with 403 before the handler runs, so both routes use it now. The url refusal no longer claims a move clears a secret the brain may not hold. The agent test now also shows that a brain the agent registers and binds gets no recall, search or write until its secret is set outside chat, and gets the recall once it is. * Keep a project's brain link working after its members change A brain link minted in a project conversation went through the conversation scope check on submit, which pins the project's membership version, so anyone joining or leaving the project made the admin's link refuse with 409 for good. A brain secret goes to the org's memory registry, not to the conversation, so only admin standing and the brain's url gate it now, and the drop no longer records the scope version. brainTarget also checks admin standing before it looks up the brain, so a non-admin's agent gets 403 for any brain id instead of learning which ids exist from 404s. Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team * Derive member brain ids with the shared hashId helper memberBrainId hand-rolled sha256(owner) cut to 16 hex characters, which is hashId([owner]) from src/util/crypto.ts, so it now calls the helper and every member brain id stays byte-identical. The member-brain docs sentence now says a member has no route outside chat to their brain, instead of reading as if the brain itself had none. Review-Finding: src/memory/registry.ts:349:duplication reader=core Review-Finding: docs/memory-providers.md:313:docs-clarity reader=core * Refuse overlapping registry bindings and relay the registry API A binding for the kind personal already covers every member's scope, so a second binding of one member to the same brain gave that member two routes: recall and explicit writes ran twice, and the member binding could not narrow the kind binding. putBinding now refuses either overlap with 409, matching scopes the way the router does. Org admins reach /v1/admin/memory-registry outside chat through the web UI admin relay, as they do for mcp-servers. The docs name that path and say that an agent's binding to a brain that already holds a secret serves within the member links the brain's operator provisioned. Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/specialist-batched#1 reader=security Review-Finding: docs/memory-providers.md:301:completeness reader=core * Close member brains' rollback, size, session and http gaps A record that holds a member's brain is now schema 2. Builds from before member brains read only schema 1, so a mixed or rolled-back deploy keeps its last valid record instead of serving member brains over its unguarded fetch and past the org switch. A fresh instance on such a build serves no registry brains until the member brains are removed; the memory docs give the recovery step. A member's url, clientId and label together are capped at 8,000 characters, the request-target length RFC 9110 asks HTTP software to accept, so a member's brain never outgrows what one URL may carry. One member PUT near the API's 1,000,000-byte body limit was measured turning into a 1.35 MB shared registry row that every instance reloads after each change. Admin fields stay uncapped. A member save that changes nothing returns without a write or a version bump. An MCP call cut off by its deadline or the response cap still ends its session: the session-end request no longer carries the call's stop signal, so it isn't cancelled before it is sent. An https MCP server can't send any request, its OAuth token request included, to http. * Gate registry writes on private reads and stop shadowing env providers Every registry PUT replies with the whole registry, so both PUT routes now carry privateRead "always" like the GETs: an admin's agent reads or changes the registry only in the admin's DM or where sharing is open, and the gate refuses before any body or 409 message is produced. The DELETEs were already portal-only for agents. A test drives every registry route from a channel and from the admin's DM. A binding whose brain id an env provider took (id_taken) stays in the admin's disabled list but gets no disabled route, so it no longer shadows the env provider's recalled rows or writes. The registry unit test resolves two members bound to one brain again, which covers the provider dedup. Review-Finding: v1/outside-voice#1 reader=outside-voice Review-Finding: v1/adversarial-native#3 reader=adversarial-native Review-Finding: fc1/fix-check#1 reader=fix-check * Point the memory providers intro at the registry The intro said only MEMORY_PROVIDER_CONFIG routes memory. It now names the memory registry, where org admins bind members to personal-memory brains at runtime. Raised by /ship's documentation audit. * Show member brains only where the registry may be shown The member-brains switch now answers an agent only in a DM or an open conversation, like every other registry route, because its reply carries the whole registry. A member's own brain routes refuse in a closed shared conversation too, since their replies carry the brain's url, client id and personal binding. The registry privacy test walks these routes alongside the org-admin ones. * End a wedged MCP session without holding the call A call cut off by its deadline or the response cap now ends its session in the background, so a server that never answers the session-end request can't hold the caller past its deadline. The session end is still sent, and the guarded fetch's own timeout bounds it. A call that finished keeps the caller's stop signal on its session end, so the same deadline cancels a session end the server never answers instead of leaving the connection open. The registry docs' rollback note now points to the member-brain removal steps: a build from before member brains refuses a schema 2 record rather than ignoring its owner fields, so deleting bindings alone leaves fresh instances on that build with no registry brain. * Bound a detached MCP session end by the call timeout After a call is cut off by its deadline or the response cap, its session end runs in the background. That request now carries the same total deadline as any MCP call (CALL_TIMEOUT_MS), so a server that trickles bytes to keep the guarded fetch's idle timer alive can no longer hold the socket, and the client waiting to close behind it, open indefinitely. The session end is still sent. The registry docs now say that deletions freeze on an instance from before member brains, as the rest of its record does: an org binding an admin deletes keeps serving there until that instance is replaced. Review-Finding: s11/adversarial-native#1 reader=adversarial-native Review-Finding: s11/outside-voice#2 reader=outside-voice * Bring gbrain's contract tests to typed recall rows Main's gbrain recipe tests answered memory_recall with facts that had no id, which the vendored contract requires, and expected gbrain's own line text. Typed rows need the id and render facts in QM's line shape. * Drop the score from the gbrain recipe test's notebook fake Recall rows no longer carry a score, and this fake from main's gbrain recipe tests was the last one that still set it. * Place a held-twice claim at its kept copy's own turn copiesInTurn ordered a claim by the first turn that reached any copy of it, then printed the kept copy (the first notebook copy) in that slot. A brain that ranked a dual-written fact first lifted the notebook copy to the top, and a claim held in two notebooks printed under the first notebook ahead of that notebook's own better-ranked facts, contradicting "gains no rank". Each list now skips the copies it does not keep, so the claim prints at the kept copy's own place and the other lists lose no turn. Found by the /ship review at 4446607. * Keep provider facts to one line and name every copy in search The /ship review at e02e945 found four gaps in the structured-fact path: - A fact whose text held a line break became one search row, so a provider could print a line that looks like another scope's hit. Whitespace runs in a fact's text now become one space, and a fact id that is empty or holds whitespace fails the read as malformed_fact. - A blank fact rendered as a bare "- " row; it is now skipped, as the text path already skipped blank lines. - Search printed a claim held in two notebooks once and named only the provider copies, so the other notebook's fact id disappeared. Its copy is now named as "[scope] #id" in the "(also ...)" list. - The budget fallback, where a kept copy no longer fits but a copy in a block already open does, had no test. * Keep a fact's date and kind on one line, and skip facts that don't fit The adversarial review at 6ae6bd8 found three gaps: - A line break in a fact's date or kind, or a U+0085 anywhere (JS \s misses it, while untrustedText turns it into a newline), still split one fact into several search lines. One oneLine helper now covers text, date, kind and the id check. - rows() stopped at the first fact that didn't fit maxChars after always keeping the first, so a provider whose first fact was oversized gave nothing, because recall never clips a structured row. It now skips a fact that doesn't fit and keeps going. - A provider serving two scopes printed its own address again in "(also ...)"; the kept copy's address is now left out. * Share one flatText helper and refuse any whitespace in a fact id The Wall of shame Duplication review at d9e84b3 found that the provider's oneLine was a copy of agent-tools/listing.ts's private flatText. Both now import flatText from src/util/text.ts, beside untrustedText, which treats U+0085 as a line break too. The /ship fix-check at d9e84b3 found that the id guard built on it, oneLine(id) !== id, let an id with single inner spaces through, so an id like "1 [org:acme] #12" could forge a notebook address in a search line. The guard now refuses any whitespace or U+0085 in an id, as the docs say. * Carry the notebook-only mark to subagents and swarm workers The K4a /ship review at 37f68ec found that memoryNotebookOnly stopped at the tidy turn itself: a subagent it opened, later turns woken into that subagent, and swarm workers started from it ran with conversation authority and could recall and write external providers. Sidebar sessions are web-only, but subagents and swarms are not. The mark now travels the way readOnly does: in the session's stored spawn context, in every child run request built from it, in a session message into a notebook-only child, and in the swarm template and dispatch contract. * Keep a notebook-only subagent notebook-only on every turn into it A person's own turn into a notebook-only subagent read the notebook-only mark from the turn input alone, so it got brain reach, and the subagents, sessions and swarm workers it started lost the mark. The orchestrator now also reads the mark from the subagent's spawn metadata, the way it floors read-only, and hands it on to the session syscalls. Swarm templates take it from the source session's spawn metadata too, and a notebook-only sender no longer steers a running turn that has full memory routes, matching the read-only guard. * Keep notebook-only turns from scheduling work outside the rule A notebook-only turn kept the cron, webhook and goal tools, and its agent API token could create or change crons, loops and webhooks. Any job it made fired later as an ordinary turn with full brain reach. The orchestrator now hands the notebook-only mark to the agent turn, which drops those tools, and the cron, loop and webhook routes refuse a notebook-only capability for anything but reads. The memory providers doc says so. * Floor every turn in the tidy's own conversation as notebook-only A notebook-only turn could still arm a background watch, and the monitor then woke the tidy's conversation as an ordinary turn with full brain reach. A credential approval for a request the tidy made resumed the same way. The orchestrator now treats any turn in a conversation that belongs to the managed tidy as notebook-only, the way it floors read-only for a subagent, so every producer that wakes that conversation gets the rule. Swarm templates also take the mark from the calling turn's agent token, which carries that decision. * Call registry brains through the guarded fetch and refuse private brain URLs Org-admin-registered brains were called with plain fetch, and registration accepted http://localhost, 127.0.0.1 and *.internal, so an admin, or an admin's agent, could point core at its own loopback or the cloud metadata service. Registry brains now go through publicFetch, which checks the address actually dialed and rejects redirects, and registration refuses loopback and private hosts. Operator MEMORY_PROVIDER_CONFIG providers keep plain fetch, as #2801 decided for deployment configuration. * Word the registry brain refusal so every caller can act on it A member registering their own brain can't set MEMORY_PROVIDER_CONFIG, so the refusal now states the rule (a public https address) and points at the docs, which say where a private brain goes. * Point the member brain transport docs at the shared rule instead of restating it * Refuse a registry brain on a trailing-dot private name The registration check compared the hostname as written, so https://localhost./mcp and https://brain.internal./mcp passed as public. isPrivateHost now drops trailing dots before matching the named private hosts. The MEMORY_PROVIDER_CONFIG http check keeps its exact match, so it still refuses http:// on those names. Also drop a sleep before reading the audit log, which already waits for pending writes. Review-Finding: src/memory/provider-config.ts:78:security reader=qa Review-Finding: test/memory-registry-pg.test.ts:424:timing-dependent-tests reader=core * Strip a registry hostname's trailing dots in linear time The trailing-dot check used hostname.replace(/\.+$/, ""), which backtracks quadratically on a long run of dots that isn't at the end. The URL parser accepts such a hostname, so a single registration with a hostname of 200,000 dots held core's event loop for about 26 seconds, and a 1 MB one would hold it for minutes. An admin's agent can register a brain, and so can a member's agent once member brains land. The dots are now counted back from the end in a loop, which takes about 5 ms for 200,000 dots. * Trim a registry url's trailing slashes in linear time too A registry brain's url also runs through privateMcpUrl, which ended with raw.replace(/\/+$/, ""). That regex backtracks quadratically on a long run of slashes that isn't at the end: a path of 200,000 slashes held core's event loop for about 26 seconds. Only operator config reached it before the registry route did. One local trailing-character trim now serves both the hostname's dots and the url's slashes. * Check the minting person's admin standing when a browser mints a brain 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. * List the member brains switch among the admin page routes 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. * Restate the brain's url when a secret link saves its secret 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. * Give memory brain secret links their own audience and show the full endpoint 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. * Tell an admin when a memory brain changed after its secret link was made 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. * Name the member's own audience in the member brain tests 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. * Wait for audit rows and stream close in member brain tests 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 * Cap the client secret a member's brain link can save 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 * Keep the memory tidy from starting work in sessions outside its notebook-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 * Note the memory tidy's session-message limits in the subagent sessions 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.
1 parent e628d56 commit ed1798a

58 files changed

Lines changed: 946 additions & 235 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/memory-providers.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ Capture policies are:
9999
- `explicit`: writes only through deliberate memory actions;
100100
- `automatic`: explicit writes plus post-turn capture.
101101

102+
Only a conversation's own writes reach a provider other than the notebook: the `memory` tool's `add`, `POST /v1/memory/facts` and post-turn capture. Everything else changes the notebook alone: an admin's `/v1/admin/memory` writes, onboarding status, a member's own edits through `/v1/memory`, QM's note about missing channel history, and any write made from another conversation. The nightly memory tidy goes further: its turn recalls, searches and writes the notebook alone, through its tools and its agent API token alike. It can't schedule work that would run without that rule: its turn has no cron, webhook or goal tools, its agent API token is refused when it creates or changes crons, loops or webhooks, and the subagents and swarm workers it starts follow the same rule. A session message it sends 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. Every later turn in the tidy's own conversation is notebook-only too, whatever wakes it, such as a background watch or a credential approval. QM recognizes the tidy by the job it created for that notebook, not by the job's title or task, so editing the job keeps the rule and no other scheduled job is affected.
103+
102104
MCP reads receive `query` and `acting_user` by default. Writes receive `content` and `acting_user`. Operation entries can map optional fields with `queryArg`, `contentArg`, `factsArg`, `actorArg`, `scopeArg`, `maxCharsArg`, `inputArg`, `replyArg`, `capturedAtArg`, `sourceArg`, and `idempotencyArg`. Only configured optional fields are sent, so providers can match strict MCP schemas. A provider without `scopeArg` sends the same call for every scope it serves, so QM refuses it at startup unless it serves one scope (a single scope ID such as `persona:<id>`, or the org scope, of which an instance has one) or only personal scopes, which the acting member tells apart, on routes that do not manage a notebook, because notebook reads carry no member. Providers whose operations call the same server URL and tool without `scopeArg` count as one provider here, since their calls look the same to the server. A provider routed to `channel`, `group` or `team` scopes, to several scope IDs, or to more than one scope kind maps `scopeArg` on read and write. A write with `factsArg` sends the facts under that name as a list of `{ "fact": "…", "provenance": "…" }` objects instead of `content`, where `provenance` is the scope ID of the conversation the facts came from, such as `personal:U0JORDAN`. A write operation sets `factsArg` or `contentArg`, not both. On a turn a member wrote, a read's query is the message they sent, cut to its first 2,000 characters as the notebook's recall query also cuts it, rather than the notebook's query, which opens with the turn's recent dialogue. Other turns, such as subagent results and automations, and search send their query as it is.
103105

104106
Read and write operations use separate OAuth client-credential pairs. Omit `write` and set route capture to `off` for a read-only provider. Recall and search time out after `timeoutMs` (3 seconds by default), because they run before every turn. Writes time out after 30 seconds, the most `timeoutMs` allows, with every batch of a split write sharing that one deadline: a write takes longer the more facts it carries, and one that times out is recorded as unknown even when the provider stored it. A deadline cancels the connection, the OAuth token request and the tool call alike, and QM stops reading any response at 1 MB, so an oversized or endless reply fails the call there. QM continues to decide readable/writable scopes and passes the acting user to the provider.
@@ -113,7 +115,7 @@ By default an MCP provider learns who is asking only from the actor argument (`a
113115

114116
An attested read returns what that member alone may see, so a route to an attested provider may list only `personal` or `personal:<id>` selectors. QM refuses any other selector at startup, naming the route, because a channel, group, team or organization conversation would show one member's private memory to everyone in it. Within a personal route QM calls the provider only for that scope's own member, and only when the answer goes back to them. A read there by anyone else, by a QM system actor (`system:…`), or in a scheduled turn whose answer goes to a teammate or a channel is withheld without a call; a write there by another member, or in such a scheduled turn, is refused without a call and recorded as `denied` with code `private_only`.
115117

116-
QM attests only ids of 1 to 256 visible ASCII characters (`!` through `~`), the same rule the credential broker and attested MCP connectors apply. When the member's canonical id breaks it, QM makes no call and records the outcome as `denied` with code `invalid_actor`; link the member to a conforming principal id with `POST /v1/admin/principal-links`. On a strict route that denial fails every recall the route serves for that member, so keep attested routes fail-open until every member is linked. QM attests only the member a run acts for: their own turns, and scheduled runs they own, such as the nightly tidy of their notebook. A write with no member or by a system actor, such as an admin's edit to someone's notebook, QM's onboarding marker or its own note about missing channel history, is not sent to an attested provider and records no outcome for it. An attested provider also cannot manage a notebook (`manage: true`), because notebook reads for admin views carry no member.
118+
QM attests only ids of 1 to 256 visible ASCII characters (`!` through `~`), the same rule the credential broker and attested MCP connectors apply. When the member's canonical id breaks it, QM makes no call and records the outcome as `denied` with code `invalid_actor`; link the member to a conforming principal id with `POST /v1/admin/principal-links`. On a strict route that denial fails every recall the route serves for that member, so keep attested routes fail-open until every member is linked. QM attests only the member a run acts for: their own turns, and scheduled runs they own. A write by a system actor, or one that changes only the notebook as described under capture policies above, is not sent to an attested provider and records no outcome for it. An attested provider also cannot manage a notebook (`manage: true`), because notebook reads for admin views carry no member.
117119

118120
## Provider outcomes
119121

‎docs/persistent-subagent-sessions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ A delegated task runs in a durable session with its own transcript and a mutable
88

99
Reusing a request key recovers the same child and initial run after a lost receipt; using that key for different work is rejected. Children inherit the caller's scope and audience. The existing internal `readOnly` restriction remains durable: `noComputer: false` cannot lift a caller's restriction or an existing child's restriction. Work a Slack bot starts keeps the bot's restrictions the same way: its children, the parent turns their mail wakes, and swarm workers it spawns all run as bot work. A bot's message never joins or stops a person's running turn: it queues as the bot's own turn, and a bot cannot interrupt a person's task or a person's work queued on a bot's task. A bot's stop command stops only bot work. A bot cannot direct a person's swarm, and its turn never counts as the live person that human-consent checks require, such as a loop's ship gates or registering a webhook.
1010

11-
`subagents.send_message` sends attributed text to a parent, child, sibling, or other accessible session. Targets accept IDs, child/sibling titles, or `parent`. By default it wakes the recipient: an idle attached child starts a turn on it, and a busy one gets it as its next turn, behind the active one. `wake: false` instead adds it to the recipient's durable inbox without starting a turn: a running recipient sees it at its next tool boundary, an idle one only when something else wakes it. A top-level conversation's wake starts a turn in another idle top-level conversation in the same context (`sessions.send_message` wakes unless `wake: false`, which sends routine FYI mail); that turn runs on the recipient's own stored context, never the sender's, and a busy recipient reads the message at its next tool boundary or is woken when its turn ends. Such a waking message is framed to the recipient as a task handed over by another conversation, which it may do now, defer, or decline by messaging the sender with `wake: false`; replies and status reports between conversations go `wake: false` so they wake no one. A wake that may not start a turn (a top-level conversation that is not the sender's own parent when the sender is a subagent, a read-only sender, a sender on a private turn, or a ninth peer wake in a row) falls back to the inbox and the result says so. The result names which happened. Stable call IDs deduplicate retries. `subagents.wait` waits up to 60 seconds for mail. `send_message` with `interrupt: true` aborts active child work. Messages are external data, not human authorization. Each message is screened independently of its carrier tool and persisted at the receiving session's scope. Quarantined messages remain recoverable for approval; denying the approval consumes the message. Read-only and automated authorization restrictions still apply to assigned work.
11+
`subagents.send_message` sends attributed text to a parent, child, sibling, or other accessible session. Targets accept IDs, child/sibling titles, or `parent`. By default it wakes the recipient: an idle attached child starts a turn on it, and a busy one gets it as its next turn, behind the active one. `wake: false` instead adds it to the recipient's durable inbox without starting a turn: a running recipient sees it at its next tool boundary, an idle one only when something else wakes it. A top-level conversation's wake starts a turn in another idle top-level conversation in the same context (`sessions.send_message` wakes unless `wake: false`, which sends routine FYI mail); that turn runs on the recipient's own stored context, never the sender's, and a busy recipient reads the message at its next tool boundary or is woken when its turn ends. Such a waking message is framed to the recipient as a task handed over by another conversation, which it may do now, defer, or decline by messaging the sender with `wake: false`; replies and status reports between conversations go `wake: false` so they wake no one. A wake that may not start a turn (a top-level conversation that is not the sender's own parent when the sender is a subagent, a read-only sender, a notebook-only sender such as the nightly memory tidy when the target session isn't notebook-only, a sender on a private turn, or a ninth peer wake in a row) falls back to the inbox and the result says so. The result names which happened. Stable call IDs deduplicate retries. `subagents.wait` waits up to 60 seconds for mail. `send_message` with `interrupt: true` aborts active child work, except that a notebook-only sender cannot interrupt a session that isn't notebook-only. Messages are external data, not human authorization. Each message is screened independently of its carrier tool and persisted at the receiving session's scope. Quarantined messages remain recoverable for approval; denying the approval consumes the message. Read-only and automated authorization restrictions still apply to assigned work.
1212

1313
Read access checks scope, current participant access, and audience visibility. Sending and adoption require current scope access. A wider target audience cannot receive a narrower source's contents.
1414

‎src/agent/agent-run.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,7 @@ export function createAgent(opts?: AgentOptions): Agent {
797797
...(turn.surfacePrompt ? { surfacePrompt: turn.surfacePrompt } : {}),
798798
...(turn.clientTools?.length ? { clientTools: turn.clientTools } : {}),
799799
...(turn.readOnly ? { readOnly: true } : {}),
800+
...(turn.memoryNotebookOnly ? { memoryNotebookOnly: true } : {}),
800801
...(opts?.execTimeoutMs !== undefined ? { execTimeoutMs: opts.execTimeoutMs } : {}),
801802
...(opts?.execTimeoutCeilingMs !== undefined ? { execTimeoutCeilingMs: opts.execTimeoutCeilingMs } : {}),
802803
...(opts?.backgroundJobTtlMs !== undefined ? { backgroundJobTtlMs: opts.backgroundJobTtlMs } : {}),

‎src/agent/agent-tools.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export interface AgentToolsOptions {
9393
sandboxResources?: boolean;
9494
memorySizedBackends?: string[];
9595
readOnly?: boolean;
96+
memoryNotebookOnly?: boolean;
9697
surfaceTools?: boolean;
9798
delegateWork?: boolean;
9899
surfaceName?: string;
@@ -102,7 +103,7 @@ export interface AgentToolsOptions {
102103

103104
export type CoreToolOptions = Omit<
104105
AgentToolsOptions,
105-
"readOnly" | "surfaceTools" | "surfaceName" | "surfacePrompt" | "delegateWork" | "clientTools"
106+
"readOnly" | "memoryNotebookOnly" | "surfaceTools" | "surfaceName" | "surfacePrompt" | "delegateWork" | "clientTools"
106107
>;
107108

108109
export function coreToolOptions(config: Config): CoreToolOptions {
@@ -118,6 +119,7 @@ export function coreToolOptions(config: Config): CoreToolOptions {
118119
}
119120

120121
const READ_ONLY_TOOL_NAMES = new Set(["memory", "history", "finish_silently", "runtime", "sessions", "subagents"]);
122+
const SCHEDULING_TOOL_NAMES = new Set(["cron", "webhook", "goal"]);
121123

122124
export function endedOnPurpose(ref: {
123125
runtimeHandoff?: unknown;
@@ -199,9 +201,11 @@ export function createAgentTools(ref: ToolContextRef, opts?: AgentToolsOptions):
199201
];
200202
const { inline, inlineTools, connectorTools } = mcpTools(kit, new Set(tools.map((t) => t.name)));
201203
tools.push(...inlineTools, ...connectorTools);
202-
const active = opts?.readOnly
203-
? tools.filter((t) => READ_ONLY_TOOL_NAMES.has(t.name) || inline.has(t.name) || connectorTools.includes(t))
204-
: tools;
204+
const active = tools.filter((t) =>
205+
opts?.readOnly
206+
? READ_ONLY_TOOL_NAMES.has(t.name) || inline.has(t.name) || connectorTools.includes(t)
207+
: !(opts?.memoryNotebookOnly && SCHEDULING_TOOL_NAMES.has(t.name)),
208+
);
205209
const clientNames = new Set(clientTools.map((tool) => tool.name));
206210
return active.map((tool) => {
207211
const wrapped = withRuntimeBarrier(

‎src/agent/agent.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export interface AgentTurnInput {
101101
runtimeControl?: RuntimeControl;
102102
runtimeActorId?: string;
103103
readOnly?: boolean;
104+
memoryNotebookOnly?: boolean;
104105
surfaceTools?: boolean;
105106
delegateWork?: boolean;
106107
surfaceName?: string;

‎src/api/app-turn.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ export function createTurnMethods(
318318
}
319319
: {}),
320320
...(req.skipMemory ? { skipMemory: true } : {}),
321+
...(req.memoryNotebookOnly ? { memoryNotebookOnly: true } : {}),
321322
...(req.unattendedGrants?.length ? { unattendedGrants: req.unattendedGrants } : {}),
322323
...(req.cronScript && origin.kind === "automation" ? { cronScript: req.cronScript } : {}),
323324
...(req.infrequentCron && origin.kind === "automation" ? { infrequentCron: true } : {}),

‎src/api/routes/admin/memory.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { sendJson } from "../../http.ts";
22
import { audit, authorizeAdmin, orgScope, requireScopedAdmin } from "../shared.ts";
33
import { renderFacts } from "../../../memory/facts.ts";
4+
import type { MemoryAuthority } from "../../../memory/memory-service.ts";
45
import { memoryFacts, parseEdits, parseFacts, parseIds } from "../surface.ts";
56
import { type ApiCtx } from "../route.ts";
67
import { discoverScopes } from "./common.ts";
@@ -51,18 +52,19 @@ export async function adminMemory(ctx: ApiCtx): Promise<void> {
5152
const { actor, scope } = authz;
5253
const memory = deps.memory;
5354
if (!memory) return sendJson(res, 404, { error: "not_found" });
55+
const by: MemoryAuthority = { actorId: actor.id, context: "admin" };
5456
if (method === "POST") {
5557
const facts = parseFacts(body);
5658
if (typeof facts === "string") return sendJson(res, 400, { error: "bad_request", message: facts });
57-
await memory.capture(scope, facts, Date.now(), actor.id, { mode: "explicit" });
59+
await memory.capture(scope, facts, Date.now(), by, { mode: "explicit" });
5860
} else if (method === "PATCH") {
5961
const edits = parseEdits(body);
6062
if (typeof edits === "string") return sendJson(res, 400, { error: "bad_request", message: edits });
61-
await memory.edit?.(scope, edits, actor.id);
63+
await memory.edit?.(scope, edits, by);
6264
} else if (method === "DELETE") {
6365
const ids = parseIds(body);
6466
if (typeof ids === "string") return sendJson(res, 400, { error: "bad_request", message: ids });
65-
await memory.delete?.(scope, ids, actor.id);
67+
await memory.delete?.(scope, ids, by);
6668
}
6769
const action = method === "GET" ? "memory.read" : "memory.update";
6870
audit(deps, { principalId: actor.id, action, resource: "memory", scopeLabel: scope });

‎src/api/routes/admin/users.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,10 @@ export async function setUserOnboarding(ctx: ApiCtx): Promise<void> {
461461
}
462462
const personal = makeScopeId("personal", principalId);
463463
const today = new Date().toISOString().slice(0, 10);
464-
await setOnboardingStatus(deps.memory, personal, status as OnboardingStatus, today, actor.id);
464+
await setOnboardingStatus(deps.memory, personal, status as OnboardingStatus, today, {
465+
actorId: actor.id,
466+
context: "admin",
467+
});
465468
audit(deps, {
466469
principalId: actor.id,
467470
action: "user.onboarding.set",
@@ -479,7 +482,7 @@ export async function resetUserToBrandNew(ctx: ApiCtx): Promise<void> {
479482
const { principalId } = await resolveAdminUser(deps, params.principalId!);
480483
const personal = makeScopeId("personal", principalId);
481484
const today = new Date().toISOString().slice(0, 10);
482-
await setOnboardingStatus(deps.memory, personal, "not_started", today, actor.id);
485+
await setOnboardingStatus(deps.memory, personal, "not_started", today, { actorId: actor.id, context: "admin" });
483486
let deletedSessions = 0;
484487
if (deps.sessions) {
485488
const own = (await deps.sessions.listByParticipant(principalId)).filter((s) => s.scopeId === personal);

‎src/api/routes/index.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ export const rawRoutes: ReadonlyArray<Route<BaseCtx>> = [
9797
...loopIngressRawRoutes,
9898
];
9999

100+
function closedToNotebookOnly(routes: ReadonlyArray<Route<ApiCtx>>): Route<ApiCtx>[] {
101+
return routes.map((route) => ({
102+
...route,
103+
handle: (ctx) =>
104+
ctx.method !== "GET" && ctx.capability?.memory?.notebookOnly
105+
? sendJson(ctx.res, 403, {
106+
error: "forbidden",
107+
message: "a notebook-only turn can't create or change crons, loops or webhooks",
108+
})
109+
: route.handle(ctx),
110+
}));
111+
}
112+
100113
export const apiRoutes: ReadonlyArray<Route<ApiCtx>> = [
101114
...browserModelRoutes,
102115
...browserUploadRoutes,
@@ -129,12 +142,8 @@ export const apiRoutes: ReadonlyArray<Route<ApiCtx>> = [
129142
...fileUploadRoutes,
130143
...projectRoutes,
131144
...contextPolicyRoutes,
132-
...cronRoutes,
133-
...loopItemRoutes,
134-
...loopRoutes,
135145
...reachRoutes,
136-
...webhookRoutes,
137-
...loopIngressRoutes,
146+
...closedToNotebookOnly([...cronRoutes, ...loopItemRoutes, ...loopRoutes, ...webhookRoutes, ...loopIngressRoutes]),
138147
...directoryRoutes,
139148
...contextRoutes,
140149
...pinRoutes,

‎src/api/routes/memory-access.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function memoryForRequest(ctx: ApiCtx, sourcePrincipal?: string): MemoryS
2525
nativeScopes,
2626
audience: targetScope === personalScope(id) ? [actor] : (ctx.capability?.members ?? []),
2727
open: !ctx.capability || livePersonCapability(ctx.capability),
28+
...(ctx.capability?.memory?.notebookOnly ? { notebookOnly: true } : {}),
2829
config: ctx.deps.config,
2930
isCurrentSharedScopeMember: (person, scope) => ctx.app.isCurrentSharedScopeMember(person, scope),
3031
currentScopeMembers: (scope) => ctx.app.currentScopeMembers(scope),

0 commit comments

Comments
 (0)