Skip to content

Memory: one mutation authority for every caller, notebook-only tidy - #2966

Merged
capy-ai[bot] merged 124 commits into
mainfrom
capy/k4a-mutation-authority
Nov 8, 2026
Merged

capy-ai[bot] merged 124 commits into
mainfrom
capy/k4a-mutation-authority

Conversation

@garrytan

@garrytan garrytan commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor
{
  "schema_version": 1,
  "pr": 2966,
  "head": "d4eddb7618a25d9c0f8b9b8d0c8718d4e09c7ebf",
  "version": "0.1.0",
  "base": "main",
  "gate": {
    "targeted": {
      "pass": 118,
      "fail": 0,
      "skip": 0
    }
  },
  "session_kind": "unattended",
  "head_tree": "834e3f1491a5c97ad0da6c1737010bbfcc870877",
  "product_patch_id": "273f4e3d9c33085d8889db99d4cb6e5b95006e77",
  "gstack_version": "1.98.0.0",
  "review_depth": {
    "depth": "full",
    "class": "gate",
    "runtime_lines": 284,
    "rules": "c6a079fdf812f479",
    "base": "01d1a4ac707a286a6640995d52deffb189f66a93",
    "registry": "01d1a4ac707a286a6640995d52deffb189f66a93",
    "gate_scan": {
      "tagged": 0,
      "head": "d4eddb7618a25d9c0f8b9b8d0c8718d4e09c7ebf"
    },
    "reasons": [
      "gate-class",
      "risk:auth",
      "runtime=284"
    ],
    "risk": [
      "auth"
    ],
    "mode": "panel"
  },
  "pregate": "warn @834e3f1491a5 (7 checks, current for d4eddb7618a2)",
  "docs": {
    "status": "current",
    "scope": "docs/, skills-seed/ and README.md against the 58 changed files"
  },
  "review_loop": {
    "passes": 2,
    "blocking_found": 1,
    "deferred": 0,
    "noted": 2,
    "fixed": 1,
    "scope": "frozen",
    "rounds": 2,
    "ledgers_read": 2,
    "dispatches": {
      "specialist": 8,
      "red-team": 2
    }
  },
  "review_targeted": {
    "classes": [
      "auth"
    ],
    "rules": "8811fd300bf305f0",
    "brief_sha": "7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5",
    "product_patch_id": "273f4e3d9c33085d8889db99d4cb6e5b95006e77",
    "status": "done",
    "findings": 0,
    "raised_by": "targeted"
  }
}
{"head":"fd80b3e9af3462a89a78263d5c2ac3e037f12917","version":"0.1.0","gstack_version":"1.96.0.0","review_loop":{"passes":1,"blocking_found":3,"deferred":0,"noted":4,"fixed":0,"scope":"frozen","rounds":1,"unrecorded":3,"ledgers_read":2,"dispatches":{"specialist":4,"red-team":1}},"depth":"full","carried_from":"b2e3e576019abdf899989bb61b12aac3704f0bcb"}
{"head":"e6630799a2e626de56d99bbe53b3df46047d0f80","version":"0.1.0","gstack_version":"1.96.0.0","review_loop":{"passes":1,"blocking_found":3,"deferred":0,"noted":4,"fixed":0,"scope":"frozen","rounds":1,"unrecorded":3,"ledgers_read":2,"dispatches":{"specialist":4,"red-team":1}},"depth":"full","carried_from":"b2e3e576019abdf899989bb61b12aac3704f0bcb"}

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, edit and delete take by: { actorId, context } where the bare author string used to be. The context is conversation, self, admin or maintenance, and each caller names its own:

Caller Context
memory tool add, edit, delete; POST, PATCH, DELETE /v1/memory/facts conversation, or maintenance in a tidy turn
Post-turn capture conversation (a tidy turn, like every scheduled turn, makes none)
PATCH, DELETE /v1/memory (a member's own notebook) self
POST, PATCH, DELETE /v1/admin/memory; onboarding status set or reset by an admin admin
QM's note about missing Slack channel history maintenance, as the channel or group itself

Only 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.actorId is gone. Providers attest by.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 reads by.actorId too.

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 fire memoryNotebookOnly. 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 its memory tool search and read see the notebook alone. Its writes carry the maintenance authority, so they change the notebook alone too. Its agent API token carries memory.notebookOnly, so /v1/memory/search and /v1/memory/facts called 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 readOnly does: 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, webhook or goal tool, 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.md says 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 carry memoryNotebookOnly: true on 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_meta carries memoryNotebookOnly: true for 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: true when 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.author for QM's missing-history note was NULL and is now the scope's own actor, system:channel:<id> or system:group:<id>, the id a channel's tidy already writes as. Existing rows are untouched.

Not persisted:

  • The agent capability token's memory claim, { write?, orgWrite?, read }, gains notebookOnly?: true on tidy turns.
  • In-process types: MemoryService mutation methods take by?: MemoryAuthority instead of author?: string; MemoryCaptureContext drops actorId; MemoryDisclosure gains notebookOnly?; SchedulerDeps gains notebookOnlyMemory?; TurnRequest, TriggerSpec, SpawnMeta, AgentTurnInput and AgentToolsOptions gain memoryNotebookOnly?; OrchestratorDeps gains notebookOnlyMemory?(threadRef); the tidy service gains managesThread.
  • API: an agent API token whose memory claim carries notebookOnly, which only notebook-only turns get, receives 403 {"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_CONFIG or 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

  • Edit and delete carry by, but nothing reads its context yet, because only the notebook can edit or delete. K4b reads it for brain forget.
  • memoryNotebookOnly is a TurnRequest field, so a caller of the public turns API can set it too. It only narrows what that turn reaches.
  • The scheduler still marks the tidy's fires itself, and the orchestrator's floor on the tidy's conversation now covers those fires too. Dropping the scheduler's mark is a later trim.
  • The notebook-only mark is spelled four ways (memoryNotebookOnly, notebookOnly, memory.notebookOnly, notebookOnlyMemory) and is copied beside readOnly through 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.
  • The tidy can no longer leave a next-fire note on its own job, because it has no cron tool. Its task never asks for one.
  • The test of the tidy's tool list builds the tools from the turn's input, so it doesn't exercise agent-run.ts's one-line pass-through; the live QA reads the tool list from the prompt QM actually sent.
  • A scope whose routes include no notebook, which only MEMORY_PROVIDER_CONFIG can 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.
  • Small reviewer nits left as they are. self, admin and maintenance behave alike until K4b reads them; post-turn capture builds its conversation authority by hand instead of through turnAuthority; a tidy fire looks up its claim twice (the skip check and the mark); "notebook route" is still the existing provider.facts test, 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.
  • At 5ef7e7f, 90 affected test files pass locally through scripts/run-pg-tests.mjs, among them main's and the member-brains stack's memory tests, which now pass an authority. At 71d11db and 37f68ec, which only merge the stacked branch's /ship fixes, the affected recall, provider, registry and router tests pass again. At e9ad3c1, 122 affected test files pass, and at b307a27 141 do, among them every test of the cron, loop, webhook and reach routes, of createAgentTools, swarms, the tidy, the monitor poller and keychain-ask. b2e3e57 only merges typed recall rows' final head cleanly, and CI's full run is green there. At b4c57f1, test/session-syscalls.test.ts passes 118 of 118; d4eddb7 changes one docs line, and CI's full run is green there.
  • New tests, each failing at the base commit through the real path:
    • The router sends a capture to a non-notebook provider only for a named conversation actor; no authority, a blank actor, self, admin and maintenance reach the notebook alone (test/memory-provider-router.test.ts).
    • With a gbrain-shaped brain bound through the admin registry routes, a DM memory add writes to the brain once, attested as the member, while an admin's /v1/admin/memory add and edit, an admin onboarding update and the member's own /v1/memory edit 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).
    • A run of the managed tidy with that brain bound edits, deletes and adds notebook facts through its tools, then uses its own agent API token to search and add; the brain gets zero calls, and the tidy's prompt and search never show the brain's fact. At the base commit the brain's fact was in the tidy's prompt (test/memory-registry-pg.test.ts).
    • The scheduler marks the claimed tidy job's scheduled fire and run-now, including after its task is edited, and doesn't mark a separate job with the same title and task (test/memory-tidy.test.ts).
    • A subagent opened by a notebook-only turn, and a later turn woken into it, carry the mark, and a notebook-only sender queues a turn instead of steering a running one that lacks it (test/session-syscalls.test.ts).
    • Swarm workers stay notebook-only whether the mark comes from the turn's request, the subagent's stored context or the turn's agent API token (test/swarms.test.ts).
    • With a brain bound, Alice's own turn into a notebook-only subagent sees no brain fact and makes no brain call, and the subagent it opens is notebook-only (test/memory-registry-pg.test.ts).
    • The tidy turn has no cron, webhook or goal tool, its agent API token gets 403 on POST /v1/crons, /v1/loops and /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).
    • A notebook-only turn's wake to a session whose stored context isn't notebook-only leaves mail and starts no run there, and its interrupt is refused, while a subagent it opened still wakes and keeps the mark. This test fails at f86c853 on the interrupt assertion (test/session-syscalls.test.ts).
  • Live dev-instance QA at 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.
    • Positive control: a turn asking QM to remember a fact sent exactly one memory_write, attested as the member, and gbrain committed it (fact id 1). The invalid_params gap 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.
    • An admin create and two edits through the admin memory API, an admin onboarding update, and the member's own portal edit and portal DELETE /api/memory changed 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.
    • The managed tidy's run-now carried memoryNotebookOnly, recalled only the notebook and forgot a near-duplicate. With its task edited to search memory, call /v1/memory/search and POST /v1/memory/facts from 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.
    • A look-alike job with the same title and identical task text but no claim in memory_tidy_profiles ran 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.
    • An earlier live QA at e683cef also 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.
    • Rerun at e9ad3c1, after the tidy lost its scheduling tools: the tool list in the prompt QM sent for the tidy's run-now had no cron, webhook or goal tool, and POST {} to /v1/crons, /v1/loops and /v1/webhooks with 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.
    • Rerun at b2e3e57: the same checks pass again, and the tidy armed a background watch whose 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 with finish_silently, so its recorded status is silent, a success. The look-alike's wake recalled from gbrain and answered with the gbrain-only codename.
    • Not exercised live, covered only by the tests above: Slack and channel turns, including the missing-history note; the admin memory delete; a member-owned brain; the tidy firing on its own schedule at this head, where only run-now was used; a person's own turn into the tidy's conversation, which the portal can't address; a credential-approval continuation; a subagent opened by a notebook-only turn and turns woken into it; a person's own turn into a notebook-only subagent; swarm workers; a refused steer; the stored sessions.spawn_meta and swarm template JSON carrying memoryNotebookOnly; and the limits b4c57f1 adds, 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 at f86c853, and pass 2 over f86c853..b4c57f1 after the one fix round. d4eddb7 then adds the notebook-only sender to the session guide after the first docs audit flagged it. Pregate: warn at tree 834e3f1 (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 d4eddb7 and 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: CRITICAL src/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) at b4c57f1; d4eddb7 changes only docs/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.

  • INFORMATIONAL 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.
  • INFORMATIONAL 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.
  • INFORMATIONAL 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.
  • Low 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 at src/core/orchestrator.ts:1284.
  • Low 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: parentTurn builds a parent's wake from the parent's own context and never carries a notebook-only child's flag up.
  • Low 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 through receive and an idle one on its next turn.

Documentation

The first read-only audit, at b4c57f1, found that docs/persistent-subagent-sessions.md didn't list the notebook-only sender among the wakes that fall back to the inbox. d4eddb7 adds it, and the second audit read d4eddb7:

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.

File Status
docs/memory-providers.md Current — notebook-only write routing and the tidy's restrictions are described accurately
docs/persistent-subagent-sessions.md Current — wake and interrupt limits for notebook-only senders match session syscalls
docs/capability-tokens.md Current — the internal memory claim is not documented, so nothing goes stale
docs/swarms.md Current
docs/memory-records.md Current
docs/SPEC.md Current

Coverage debt: the MemoryAuthority contexts 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: d4eddb7618a25d9c0f8b9b8d0c8718d4e09c7ebf

b4c57f1 fixes 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 in write() 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"). d4eddb7 adds 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 to d4eddb7.

f86c853 merges 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 to 3b9c7c7, and this PR's diff against main is byte-identical to its earlier diff against typed recall rows. Every row carries forward to f86c853.

3b9c7c7, 0effef8 and 35c7a75 merge 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 in src/sessions/session-syscalls.ts lists this PR's notebook-only restriction beside main's botActor, 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 shared conversation from main's new channel option; 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 to 3b9c7c7.

e663079 is 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-only routedTo fetch. Fresh reviewers judged it at fd80b3e: 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 at fd80b3e. 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 to e663079. Every other row carries forward as before.

Earlier history:. This is the final reassessment. The lenses were first reviewed at e683cef and 3f9d55e and reassessed at 5ef7e7f after 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 in 68ff9e7), 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). b2e3e57 merges typed recall rows' final head (9613837) cleanly and leaves this PR's own diff unchanged. At b2e3e57, 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 since e9ad3c1 added a refusal message, was reviewed there; b307a27 adds no user- or agent-facing text. Hand-rolling, Non-durability and Historylessness carry forward from 68ff9e7, and Provider drift from 5ef7e7f: 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.

Lens Reviewer Verdict Score Assessment
Overengineering Subagent, claude-sonnet-5-5, fresh context Accept 88/100 At b4c57f1. Two guards in write() (src/sessions/session-syscalls.ts:953, :1008), 7 lines beside the read-only refusals, reuse the stored meta and leaveMail; no new flag, state or config.
Band-aid fixes Subagent, claude-sonnet-5-5, fresh context Accept 82/100 At b4c57f1. The refusal sits at the layer every session write passes through and states the same containment rule as the read-only and private-turn guards, so it closes the leak rather than hiding it. Note: marking the target when work is queued would be more principled, but refusing here is simpler and covers both the parent wake and the later watch.
Duplication Subagent, claude-sonnet-5-5, fresh context Accept 90/100 At b4c57f1. The two refusals reuse the stored memoryNotebookOnly flag beside the read-only ones; interrupt and wake each need their own check, and the docs sentence appears once.
Hand-rolling Subagent, claude-sonnet-5-5, fresh context Accept 94/100 At b4c57f1. The checks reuse storedContext, the existing refusal shape and leaveMail; nothing reimplements a library or supported API.
Non-durability Subagent, claude-sonnet-5-5, fresh context Accept 95/100 At b4c57f1. Both refusals read the session's stored context, and a refused wake falls back to the durable mail path; no process-local state.
Config-matrix expansion Subagent, claude-sonnet-5-5, fresh context Accept 94/100 At b4c57f1. No env var, setting, schema or persisted shape; the refusals tighten the existing memoryNotebookOnly rule rather than adding a mode.
Provider drift Sonnet 5.5 reviewer Accept 95/100 Reviewed at 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.
God files Sonnet 5.5 reviewer Accept 88/100 orchestrator.ts gains one clause and a narrow optional dep; the thread decision lives in tidy.ts on the existing claim check, and the authority rules stay in memory-service.ts.
Mismatched UI Haiku 5.5 (triage) Not relevant – No UI, CSS or template files; changes are in src/, test/ and one docs file.
Over-indexing on YC Haiku 5.5 (triage) Not relevant – No YC-specific names, config or architecture; the only org-like strings are synthetic test fixtures.
Regex Haiku 5.5 (triage) Not relevant – No production regex added or changed; new regexes are test assertions.
YC info leaking into public qm Haiku 5.5 (triage) Not relevant – No YC identity, internal reference or screenshot in the diff.
Discarded error evidence Haiku 5.5 (triage) Not relevant – No catch block or error-swallowing change; errors pass through or return with a message.
Arbitrary limits Haiku 5.5 (triage) Not relevant – No new numeric cap, length or timeout.
Explaining-away UI Subagent, claude-sonnet-5-5, fresh context Accept 92/100 At b4c57f1. The new refusal messages are tool results read by an agent, not UI copy, and they enforce a real boundary; the wake case falls back to the existing mail path.
Category gate without census Subagent, claude-sonnet-5-5, fresh context Accept 88/100 At b4c57f1. The gate keys on the stored memoryNotebookOnly mark 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.
Historylessness Subagent, claude-sonnet-5-5, fresh context Accept 95/100 At b4c57f1. The refusals read only the typed stored flag, with no prefix or ID matching, compatibility shim or legacy shape, and the docs state current behaviour only.
Over-testing Subagent, claude-sonnet-5-5, fresh context Accept 85/100 At b4c57f1. One behavioural test on real Postgres asserts the refused interrupt, the queued mail, no run in the ordinary session and the kept mark on the tidy's own child; main's read-only steer test is restored as it was.
Prod-like QA Subagent, claude-sonnet-5-5, fresh context Accept 72/100 At b4c57f1. A small guard in the sessions tool with a Postgres-backed test and no provider change, so nothing blocking. No live dev-instance run covers this commit; the stored-context marking across real queued runs is the part a unit test can't fully prove.
Performance Sonnet 5.5 reviewer Accept 93/100 The floor costs a regex match for ordinary threads and two keyed reads only for cron-fired turns; nothing runs per tick.
Token performance Haiku 5.5 (triage) Not relevant – No system-prompt or per-turn prompt text changes; notebook-only turns drop tool definitions rather than adding any.
Honorable mentions Haiku 5.5 (triage) Not relevant – No image or screenshot files and no added code comments, lint or type suppressions.

Open in Capy

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 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.
Base automatically changed from capy/k3-typed-recall-rows to main November 8, 2026 05:17
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.
@capy-ai
capy-ai Bot added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit ed1798a Nov 8, 2026
25 of 27 checks passed
@capy-ai
capy-ai Bot deleted the capy/k4a-mutation-authority branch November 8, 2026 07:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant