Proactive memory — implementation plan
Status: Implemented (rev 3 — two rounds of cross-model plan review, fresh Fable then Codex gpt-5.6-sol high, 2026-08-26. Round 1: 4 blockers / 9 should-fix / 5 nits. Round 2: 7 blockers / 5 should-fix, including two features round 1 and the author had invented. All folded in; §Rev 3 corrections lists what changed and why)
Author: Claude (orch memory proactive)
Date: 2026-08-26
Repo: north-os, branch feat/memory-proactive
Related: docs/superpowers/specs/2026-08-26-memory-proactive-design.html
Shape
Five work packages, sequenced WP1 → (WP2 ∥ WP3 ∥ WP4) → WP5. WP1 is not just "the core": it carries every mechanical consumer fix its own deletions cause, so the tree typechecks before three workers touch it in parallel. WP5 runs last, after the other three merge — an orphan sweep over a moving tree is worthless.
No SQL migration. Three non-SQL prerequisites are real and were missing from rev 1: a scheduler_role INSERT grant on memory_rule, a TS enum widening on the ledger, and an advisory lock standing in for the unique constraint that does not exist. bun db:generate must be run and shown to be a no-op, so the "no migration" claim is verified rather than asserted.
WP1 — memory core + confirm removal + consumer fixes (backend worker, omp)
1a. The confirm interrupt goes (this is the ask's most visible violation)
save_memory and update_memory raise an in-tool confirmArtifact interrupt — a "Save this note?" card the lawyer must accept — before every write (tools/save-memory.ts:157-175, tools/update-memory.ts:154-171), and their descriptions say "The user confirms first." (save-memory.ts:232, update-memory.ts:228). Delete the interrupt in both. The NetDocuments guard and the re-check at write time stay; that re-check becomes the only guard. Add a test asserting saveMemoryTool never interrupts.
The concurrency classifications stay. Rev 2 listed tools/concurrency.ts:257-258 as part of the confirm chain. It is not — the classifier is about mutation, not interruption (concurrency.ts:9-13,20-25,94-101), and both memory tools still mutate tenant state after the card is gone. save_memory: "serial" and update_memory: "serial" stay, and stay in MUST_STAY_SERIAL; concurrency.test.ts:42-55,92-98,191-195 pins them and a further test asserts every dispatchable tool is classified, so removing them is both a correctness regression and a red gate. Only the comment implying interruption was their reason gets rewritten.
The resume path is a compatibility problem, not just a deletion. artifacts/confirm.ts:46-66 persists the confirm payload into the LangGraph checkpoint, chat-runtime/src/run.ts:1960-1992 refuses every fresh message while a non-ask_user interrupt is pending, and chat-ui/src/assistant-ui/thread.tsx:421-429,780-790 renders the resumable widget only while the tool is in CONFIRM_TOOL_NAMES. Delete all of that at once and every thread already paused on a "Save this note?" card is permanently wedged — no button, and every new message returns thread_awaiting_input. Blue/green makes it worse: the old colour keeps minting such checkpoints during the release skew. So: the tools stop calling interrupt(), and the memory action, the action→tool map and the confirm widget stay for one release as an explicitly deprecated resume-only shim, with a regression test that seeds a pre-deploy paused save_memory checkpoint and proves it resumes and writes exactly once. The shim is removed in a later change, once the count of legacy pauses is zero.
What WP1 does cut in the same package, or nothing compiles: agent-runtime/src/artifacts/confirm.ts comments. The client half is WP4's (below), and the coupling is literal: chat-ui/src/assistant-ui/working-notes.ts:267-285 matches the tools' result strings character for character, so WP1 must publish its exact new strings for WP4 to match, or the working-note collapse breaks silently.
1b. The gate
memory/disposition.ts— deleteclassifyMemoryDisposition,MemoryDisposition, theAUTO_ACTIVE_*sets and theconfidenceinput.disposeWithRulesreturns{ outcome: "active" | "skip" }.resolveRuleActionreturnsnever_rememberor null and treats any other stored action (legacyalways_review,always_remember) as no match — today an unrecognised action falls toalways_review(disposition.ts:107), which after this change would block saves forever. What remains is ~25 lines of rule matching: fold it intorules.tsand deletedisposition.tsrather than keep a module named for a gate that no longer exists.memory/calibrate.ts+calibrate.test.ts+calibration-eval.test.ts— delete; drop fromindex.ts.MEMORY_CALIBRATE_*appears in no example or doc file (verified), so nothing else to clean.memory/access.ts— deleteapproveMemory+MemoryApprovalResult.CreateMemoryArgslosescalibrationVerdict/calibrationReason, and itsstatusnarrows to"active"only (superseded/archivedare never insert states).setMemoryStatusaccepts"archived"/"superseded"— and now stampsreviewedByUserIdon archive (today onlyactive/rejectedget the actor,access.ts:310), because WP2's delete signal depends on knowing who deleted.memory/types.ts—MemoryStatuskeeps mirroring the DB enum (it types rows read back, legacy included); addWritableMemoryStatus = "active" | "superseded" | "archived"and use it on every write path so apendingwrite stops typechecking.memory/suggestions.ts— deletelistSuggestions,confirmSuggestion,dismissSuggestion,SuggestionResult.decideSuggestionis retargeted in WP2.memory/backfill-progress.ts:23+ the zod schema — remove thependinganddowngradedcounters and drop them fromMineThreadResult. Old Redis records still parse (zod strips unknown keys).tools/save-memory.ts,tools/update-memory.ts— no calibration, no confirm;save_memorywritesactive;update_memorycallssupersedeMemoryso the correction is live at once. Result text and descriptions say saved/updated and live, editable in Settings → Memory — never "queued".tools/write-cores.ts— exportsupersedeMemory(WP3 needs it). WP1 owns this file so WP1 and WP3 do not collide on it.agents/legal-assistant/prompt.ts:618— currently tells the modelsave_memory"proposes … into a review queue (it does not take effect until approved)". Rewrite: it remembers immediately, the lawyer edits or deletes it in Settings → Memory, and durable preferences and matter facts are captured as they surface without being asked.- Every consumer of the changed shapes, same commit. Rev 2 claimed WP1 could be "mechanical fixes only" and still typecheck; that was disproved. Removing
save_memoryfrom the confirm action union breaks the exhaustiveRecordatchat-runtime/src/run.ts:511-523; removingpendingfromMemoryBackfillProgressbreaksmemory/queue.ts:175,backfill-progress.test.ts:14-26,backfill-progress.integration.test.ts:26-37,queue.test.ts:32-55; removingpending/downgradedfromMineThreadResultbreaksworker-memory/src/backfill.ts:420,478,synthesize.ts:182,193,backfill.test.ts:217,713,739,mine.test.ts:169-206,synthesize.integration.test.ts:92-115; and deletingapproveMemory+ the suggestion functions requires real route removal inapps/web/lib/memory/router.ts, not literal fixes. The shape change is atomic: WP1 carries all of it, includingchat-runtime/src/run.ts. A deliberately broken public type surface is not something to parallelise across.bun run typecheckgreen from the repo root — not just this package — is the gate.
1c. Prerequisites the sweep needs
packages/db/scripts/provision-roles.ts— addGRANT SELECT, INSERT ON "memory_rule" TO scheduler_role(SELECT too — the sweep must read existing rules before minting one; today scheduler has no access to that table at all) with a one-line reason. Without it WP2's write fails with permission denied at runtime, not at compile time.packages/db/src/schema/memory_rule_suggestion.ts:47-49— widensuggested_actionto includenever_remember. Text enum: no SQL emitted. Runbun db:generateand confirm it produces nothing.
Tests: rewrite disposition/rules, suggestions, access.integration, both tool test files; add "save_memory never interrupts"; add the shared-matter pin — a matter memory written under user A is returned by loadActiveMemories for user B in the same org while A's user_private memory is not, plus the inverse: B archiving A's matter memory hides it for A too.
WP2 — worker-memory (backend worker, omp)
mine.ts— drop the calibration call and its columns;disposition.outcomeisactiveorskip. No confidence filter. Rev 2 had the unattended miner droplow-confidence candidates as "the only quality lever left". Round 2 was right to call that a new retention policy smuggled in under a confirmation-removal change: silent non-persistence with no recovery surface is not what was asked for, and it was introduced only to stopconfidencelooking orphaned. Every extracted candidate is savedactiveregardless of rating;confidencestays on the row as audit metadata. A discard threshold, if ever wanted, is its own change with recall/false-positive numbers behind it. Remove thepending/downgradedprogress writers (backfill.ts,mine.ts:167,176,424-425,synthesize.ts), and the stale "Saved to your review queue" copy quoted inside the extraction prompt atmine.ts:106.trust-profile.ts— the signal is deliberate deletion within one cohort. Rev 2's version mixed two clocks (numerator counted rows archived in the window, denominator rows created in it), so an old backlog deleted this week could exceed 100% against a small recent denominator — a query-shape bug, not wording, sincecreated_atandreviewed_atare separate columns (agent_memory.ts:108-128). One exact half-open cohort instead: eligible rows are agent-produceduser_privaterows withcreated_at >= cutoff AND created_at < run_started_at; deleted rows are members of that same cohort withstatus='archived',reviewed_by_user_id = target_user_id, andreviewed_atin the same half-open window.created_by='user'is excluded — a lawyer deleting five memories they wrote themselves must never train North to stop remembering facts. Both a deletion floor and an eligible-sample floor. One clock, captured once per sweep from the database. There are only four type buckets (fact|preference|playbook_rule|entity), so a false positive suppresses an enormous class indefinitely; the floors exist for that reason. Where it fires, insert thememory_rule(never_remember, personal,created_by_user_id= that lawyer) and thememory_rule_suggestionledger row (status: "confirmed",suggested_action: "never_remember", rate + sample size) in one transaction. Buckets with a ledger row are skipped — that is what stops a deleted rule coming back.- Make it idempotent without a constraint. Neither table has a uniqueness constraint (
memory_rule.ts:62-64,memory_rule_suggestion.ts:64-68are ordinary indexes), the sweep fires 60 s after every boot, and workers restart on every deploy. An advisory lock alone only serialises the sweep against itself — a lawyer may already hold an identical manualnever_rememberrule with no ledger row, and a concurrent Settings create takes no lock. So: deterministic primary keys derived from (org, user, scope, type) for both the auto-rule and the ledger row,ON CONFLICT DO NOTHINGon the existing PKs, all inside one transaction that also re-reads the ledger and matching real rules underpg_advisory_xact_lock(hashtext(org || ':' || user)). Adding a unique index would close the cross-writer race properly but is a migration; the residual race with a manual create is documented, not pretended away.
Tests: mine.test.ts / backfill.test.ts assert active and that low is skipped; trust-profile tests prove (a) a delete-heavy window mints exactly one rule + one ledger row, (b) a second run mints nothing, (c) deleting the rule does not resurrect it, (d) inaction alone never fires.
WP3 — MCP + Cowork surface (backend worker, omp)
tools/memory-save.ts—status: "pending"→active; set a documented neutral default instead of the gate-driven"low". Rev 2 said this let "the model's real rating survive" — false: the tool exposes no confidence input at all (memory-save.ts:37-63), so there is no rating to preserve, and swapping one fabricated literal for another proves nothing. Adding a confidence argument to a public MCP tool is not part of this change. Description keeps "call this WITHOUT asking whenever a durable, reusable fact surfaces" and replaces the "it is queued and inert" justification with the true one: the memory is live for the lawyer's next North session and they edit or delete it in Settings → Memory. Result text says saved and live. Thenever_rememberrefusal is unchanged. Correct the comment claiming the forcedpending"softens" the NetDocuments wall — that refusal is unconditional and runs first.tools/memory-update.ts— real supersede viasupersedeMemoryinstead of a queued replacement; description and result text likewise.tools/memory-recall.ts— instruct recall before answering a matter or preference question, not only on request; state that matter memories are the firm's shared record for that matter. It takes an explicitmatter_id, so the description must say how to get one (resolve withnorth__matter_searchfirst) or the instruction cannot be followed.server-instructions.ts— one bullet inNORTH_MCP_SERVER_INSTRUCTIONS, in the register of the existing ones: recall at the start of substantive work, save durable facts unprompted, saves are live and editable in North, never ask permission to remember.packages/agent-runtime/src/cowork/briefing.ts:46-63+briefing.test.ts:62-102— the dynamic textnorth__briefingreturns, which today tells the host that saves "always land in the lawyer's review queue", "take effect only after they approve" and "it queues for review". This is executable host guidance on the exact MCP surface in the ask, not stale prose, and its test locksCOWORK_WRITE_DISCIPLINE_BLOCKas an invariant. Rewrite the block (recall before substantive work; save durable facts without asking) and assert the composed briefing contains the live/editable wording and none of "review queue" / "approve" / "queued".plugins/north-cowork/instructions.md:27-30— this is what Cowork actually reads, and it currently says "Memories you save always go to the lawyer's review queue in North — never claim one is active" and "(it queues for review — no need to ask)". Left alone it contradicts every new result string. Rewrite points 4 and 5: saves are live, recall before substantive work, never ask.apps/mcp-server/test/tools-writes-silent.test.ts:69-145— pins "ALWAYS persists status pending". Rewrite; "silent" now means unconfirmed, not inert.
WP4 — Settings → Memory + chat confirm card (frontend worker, claude opus)
- The client half of 1a — remove
save_memory/update_memoryfromchat-ui/src/chat/confirm-tools.ts:39-40, the widget action/title inartifact-confirm-widget.tsx:56,83("Save this note?"), the action map inchat-runtime/src/run.ts:522; and rewrite the literal result-string matches inchat-ui/src/assistant-ui/working-notes.ts:267-285against WP1's exact new strings. apps/web/lib/memory/index.ts—MemorySettingslosespendingandsuggestions;listMemoriescalled with["active"];MemoryViewlosescalibrationReasonand gainsproposedByUserId(see below). Drop theMemoryRuleSuggestionre-export at:24-27.apps/web/lib/memory/router.ts— delete the three suggestion routes and thekeep/discardarms ofPATCH /memory/:id. Archive is DELETE only; the UI currently archives viaPATCH {action:"archive"}(memory-manager.tsx:357-363) — switch the call and delete the PATCH arm, so one verb does one thing.apps/web/components/settings/memory-manager.tsx— remove the Review tab, batch keep-all / discard-all,pendingReason, the suggestion cards and every confirm/dismiss handler and string. Two tabs: Memories (inline edit + delete on every row) and Rules (create + delete). The rule composer offersnever_rememberalone (it defaults toalways_reviewtoday at:807and lists all three at:100-102); the API zod narrows to match. An auto-written rule renders like any other rule: no provenance line. Rev 2 wanted one; round 2 showed it cannot be accurate with this schema —memory_rulehas no origin column and no ledger id,memory_rule_suggestionhas no rule id, and both manual and automatic rules stampcreatedByUserIdto the lawyer, so joining on (org,user,scope,type,action) mislabels a later manual rule as automatic once the auto one is deleted and its ledger row survives. It is also a feature nobody asked for. The lawyer sees the rule and can delete it; that was the requirement.- Shared-matter rows —
memory-manager.tsx:142-145builds a source-thread link fromthreadId; for a matter memory captured in a colleague's thread that link goes somewhere the reader cannot open. WithproposedByUserIdin the view: when it is not me, show "captured by a colleague" and no link. The delete copy on a matter row must say the memory is removed for the whole firm — B deleting A's capture is by design, but it must not read as a personal action. apps/web/app/(settings)/memory/page.tsx— docstring matches the new surface.
Tests: apps/web/lib/memory/router.test.ts (removed routes 404; edit and delete work), memory-source-link.test.ts:30 fixture drops calibrationReason; a ui-test pass at the end.
WP5 — legacy script, evals, docs, orphan sweep (backend worker, omp) — runs last
packages/db/scripts/backfill-memory-proactive.ts— idempotent, dry-run by default,--applyto write, run by a human against an environment and never by an agent. (a)pending→active, neverrejected. Each lineage is processed in its own transaction with a compare-and-set on the predecessor's status, becausesupersedes_idcarries no FK and no uniqueness (agent_memory.ts:79-82) and two pending siblings can point at one predecessor. Exactly one sibling may retire an active predecessor and go live; every losing sibling, and every candidate whose predecessor isarchived,rejectedor alreadysuperseded, is archived rather than activated — activating those would revive a fact the lawyer deliberately removed. A candidate matching a currentnever_rememberrule is archived too. Missing predecessors are reported and left untouched. Fixtures for each case: archived, rejected, missing, two siblings, a three-row chain, an intervening never-rule; dry-run and apply must report identical dispositions. (b) deletememory_rulerows withaction='always_review'andaction='always_remember'— rev 2 kept the latter, which would leave rules visible in Settings that the new matcher deliberately ignores, i.e. exactly the orphan the ask forbids. Report counts separately. (c) delete allmemory_rule_suggestionrows. Dry-run prints counts per org × scope ×created_by× confidence, so the volume going live at once is a decision someone makes with numbers rather than a surprise.- Evals —
scripts/eval-memory-calibration.tsscores a gate that will not exist: delete it and its gate slot (package.json:31,packages/agent-runtime/package.json:49).eval-memory-extraction.ts:222counts["active","pending"]→["active"], which keeps measuring miner precision after the gate is gone. Trajectory fixtures/verifiers andsetup-eval-org.tsstop assertingpending. - Docs and comments —
packages/e2e/tests/memory-calibration.spec.ts:123(asserts a "Review" tab),apps/worker-memory/README.md:19,tools/write-cores.ts:20,provision-roles.ts:417, and the schema header docs inagent_memory.ts:26-34,memory_rule.ts:12-19,memory_rule_suggestion.ts:13-24— the last of these now describes the applied-rule ledger. - Named contradictions (a generic sweep already missed these):
apps/mcp-server/README.md:12,apps/mcp-server/scripts/c0-mcp-smoke-client.ts:157-174,apps/mcp-server/src/tools.ts:129,apps/web/lib/api/app.ts:119,packages/chat-ui/src/assistant-ui/tool-narration.ts:97. And the trajectory fixtures are not a "stop asserting pending" edit:eval/trajectories/memory-04-decline-persist.json:15-136exists entirely to decline the memory confirm gate — delete it — whilememory-01-hse-memo-standing-instruction.json:15,85,174,190andmemory-03-firm-playbook-vs-personal.json:15,82,195-218encode confirmation beats and queue semantics throughout and are rewritten around unprompted active save + next-session recall. - Orphan sweep —
rgforpending|rejected|confirm|dismiss|review queue|approveacross the memory surfaces and account for every hit. Note for the record: no memoryproduct_eventexists (packages/analytics/srchas none), so that part of the sweep is a no-op rather than work.packages/knowledge-contracts/src/memory.ts:31-36is a different legacy enum — verify it has no consumer before touching it.apps/web/content/changelog.json:574,1119,1131,1171is immutable release history: it is explicitly exempt from the sweep, not silently rewritten and not claimed absent. - Status + changelog —
bun docs/superpowers/status.ts set: this plan and spec toimplemented, both memory-calibration docs tosuperseded. The user-memory spec staysimplemented(only its review-queue half is superseded, and the new spec says so). Add the/whats-newentry.
Rev 3 corrections — what plan review round 2 changed
Round 2 (Codex, gpt-5.6-sol high) reviewed rev 2 and found seven blockers. Three of them were the author's own inventions or errors rather than gaps, which is worth recording:
- Two features had been invented. Rev 2 dropped every
low-confidence mined candidate and rendered a provenance line on auto-written rules. Neither was asked for; the first is a silent retention policy with no recovery surface, the second cannot be computed accurately from this schema. Both cut. - A correctness guard was about to be deleted for the wrong reason. The concurrency classifications for the two memory tools are about mutation, not confirmation. They stay.
- Deleting the confirm card strands live threads. Threads already paused on a persisted confirm checkpoint would have no button and would reject every new message. The tools stop interrupting; the resume path survives one release as a deprecated shim.
- WP1 could not have typechecked alone. The shape change is atomic and now carries every consumer,
chat-runtimeincluded. - The trust signal mixed two clocks and counted the lawyer's own authored memories against them. One half-open cohort, agent-produced rows only, DB clock.
- Idempotency needed more than a lock — deterministic PKs +
ON CONFLICT DO NOTHING, and the scheduler grant needs SELECT as well as INSERT. - The legacy script could revive deleted memories and left
always_rememberrules visible but inert.
Round 2 also confirmed the load-bearing claim rev 2 asserted without proof: suggested_action is a Drizzle text(..., {enum}), i.e. TypeScript inference metadata over a plain text column with no PG enum and no CHECK, so widening it emits no SQL. bun db:generate stays a gate and must leave the migrations directory byte-for-byte unchanged.
Decisions taken at plan review
| Question | Decision |
|---|---|
firm_global blast radius | A — all scopes auto-active. Retrieval reads only the personal and matter buckets, so a firm memory steers nobody's answer today; the risk is Settings clutter, not a firm-wide steer. Standing condition: the change that ever adds a firm bucket to retrieval re-opens sign-off. |
| What filters the unattended miner once Layer 3 is gone | Nothing does. An earlier draft had synthesis/backfill skip low-confidence captures; review cut it as invented scope — silent non-persistence with no recovery surface is a retention policy, not a confirmation removal. Every capture is saved; confidence stays as audit metadata that nothing reads. |
Legacy confirmed ledger rows | Deleted with the rest. Their action vocabulary would block buckets from earning the rule they deserve. |
| Archive verb | DELETE only; the PATCH arm goes and the UI call switches. |
| Auto-rule visibility | The rule itself, in Settings → Memory, deletable. No provenance line — it cannot be computed accurately from this schema and was not asked for. |
| Low-confidence mined candidates | Saved active like everything else. confidence stays as audit metadata; a discard threshold is a separate change with data behind it. |
| Threads paused on a confirm card at deploy time | Resume-only shim for one release, with a seeded-checkpoint regression test. Removed once legacy pauses reach zero. |
Risks
| Risk | Answer |
|---|---|
| A wrong memory steers answers with no human in the loop | Visible and deletable in Settings → Memory; the prompt fence still marks memory as untrusted data deferring to the live conversation; repeated deletes mint a never_remember rule; and writes are refused inside headless automation runs, where there is no lawyer to correct them |
| Volume grows without a gate | Retrieval is capped per bucket (20 personal / 15 matter) with semantic ranking past the cap; dedup and near-duplicate enrichment untouched |
| Legacy activation floods the firm at once | Dry-run counts first, human decides (WP5) |
| A lawyer is silently stopped from getting memories | Cohort-and-floor signal instead of an all-time keep-rate. There is no provenance copy on the rule — review cut it as unbackable by this schema; the lawyer's recourse is deleting the rule, which the ledger makes permanent |
| Two sweeps write duplicate rules | Advisory lock around check+insert |
Gates
bun run lint + bun run typecheck from the root, plus tests for @workspace/agent-runtime, @workspace/chat-ui, @workspace/chat-runtime, apps/worker-memory, apps/mcp-server and apps/web. bun db:generate must produce nothing. Retrieval and memory behaviour change, so bun run eval:gate:cheap runs before the PR. Dual adversarial review of the diff to convergence, including an explicit deletions audit (git diff origin/main -U0 | grep '^-') — this change removes far more than it adds, which is exactly where a silent drop hides.