Memory system: what changed

codex/v2-canvasagent-rework · the policy graph rework

7 min

The headline: silent generalization is gone. The graph used to secretly mint a company/owner rule once 3 scopes agreed (the climb / PROMOTE_AT=3 path). Now it never writes authority on its own — it derives a default to suggest and emits a promotion signal a human confirms. Authority always carries a name and a reason. Codex's E+F design, plus an adversarial-review fix and a reseed that keys rules on the job's trade, not the vendor's.

① Branch topology ② The core change ③ Resolver tiers ④ The promotion signal ⑤ Write path ⑥ Review fixes ⑦ Reseed on job trades ⑧ Files & tests

① Branch topology

skill-split is a dead end — it's an ancestor of canvas, fully superseded. v2-canvas is the exact merge-base; agent-rework is canvas + 7 commits (a clean fast-forward). So the diff is those 7 commits, and 3 of them are the memory rework.

skill-split 3c1f34b · deprecated v2-canvas 2b7dc3f · merge-base agent-rework 333241e · HEAD +7 commits

② The core change: stop writing rules behind the PM's back

The "memory system" is the policy graph — an event-sourced projection where dispatch events fold into scoped rules (core/policy-graph.mjs), read by find_policy, written by log_event. The rework removes the one place it acted without a human.

BEFORE — climb() removed

  • Silent promotion. When PROMOTE_AT=3 scopes under an ancestor held the same rule, applyEvent minted an ancestor rule and superseded the children — automatically.
  • Nameless authority. The promoted rule had reason: null; strength was inferred from absorbed evidence. No one declared it.
  • Deterministic id hack. promoteId(event, ancestor) via sha256 kept re-folds byte-identical — a tell that the write was machine-authored.

AFTER — derive + propose E+F

  • The reducer only touches the most-specific scope. applyEvent creates / reinforces / supersedes, then returns promotions: []. Always.
  • Derived default (read-time). find_policy computes the observed majority vendor and suggests it — never writes it.
  • Ask-to-promote (PROPOSE_AT=2). An emerging pattern becomes a signal the agent phrases as a question; a human's yescreate_policy. Code writes neither the rule nor the sentence.

The two tree-walk helpers (ancestorsOf, scopeIsUnder) survive — the absorb invariant and the new proposal derivation still need them. Only the silent-write machinery was cut.

③ The resolver, retiered

findPolicy now returns a proposal on every tier alongside the answer. Tier 2 split into two cases:

TierWhat it meansCaller does
1A rule in this scope's chain governs the trigger.Follow it, cite the why.
2a newDerived default — clear observed majority for this trigger across the workspace ("no rule, but Yonic ×8 over 5 properties").Suggest + ask.
2bLone precedent — a single out-of-chain sighting, nothing to generalize on.Suggest (weaker) + ask.
3Nothing. Including an untyped query — no trade/job_type means nothing to derive on.Ask the PM open-ended, then log it.

The old tier-2 field precedents became suggest in the tool layer — a uniform shape whether the suggestion is derived or a precedent. The skunk-without-trade test now honestly returns tier 3 (was: "not tier 1") — an untyped query can't reach a typed rule, full stop.

④ The promotion signal: facts, not a script

This is the F bar, and it's subtree-aware (the review fix). It walks the query's ancestor chain most-specific first — property's owner, then company root — and proposes at the first scope where one vendor dominates ≥2 child scopes and no rule yet governs that slot. This restores climb's per-subtree view without the write: a Harrison WO can surface "Kori for Harrison (owner)" even when another vendor leads company-wide.

// find_policy hands up an `observation` on any tier:
{
  signal: "promotion_candidate",
  vendor: "Yonic",
  trade:  "plumbing",
  scope:  "company",            // a token log_event accepts
  seen:   { uses: 14, scopes: 5, at: ["Rose","Walnut",...] },
  make_rule: { scope, scope_kind, vendor, trigger }   // pass verbatim on a yes
}

The graph emits the signal (vendor, scope, evidence counts). The conversational agent phrases the "make it standard?" question in its own voice. A human's yes hands make_rule straight to log_event(create_policy). Code never writes the rule, and never writes the sentence.

This is exactly the boundary captured in memory: the graph emits observations, the agent phrases the PM question.

⑤ Write path: owner scopes + absorption shaping

⑥ The adversarial-review fixes 141c4b0

A 3-lens review of the E+F commit confirmed engine purity and the absorb/supersede invariants were clean. It caught proposal-path bugs:

SevBugFix
HIGHCompany proposals emitted the display name 'LAPM' as make_rule.scope, which log_event can't resolve (only 'company' maps to root) — every company-level promotion threw.Emit a 'company' token (scope_arg) that round-trips.
MEDProposal always used the global top vendor, losing per-subtree nuance.Walk the ancestor chain; propose at the first qualifying scope (subtree-aware).
MED"Already governed?" suppression was bidirectional — a narrower carve-out wrongly silenced a broader proposal.Directional forward-match: an equal/broader rule suppresses; a strictly-narrower carve-out does not.
LOWNondeterministic proposal.scopes order; "across N properties" copy broke at spread 0.Sort scopes; guard the phrasing.

Not touched: reconcile()'s FK fragility under Supabase replica lag — pre-existing, surfaced by concurrent replays. The engine fold itself is provably pure.

⑦ Reseed on the job's trade, not the vendor's 65f9eaa

This changes what folds into the graph, so it's part of the memory story. Dispatch triggers used to be keyed on the dispatched vendor's trade — a clogged drain a handyman cleared was mis-keyed handyman. Now trigger.trade = the job's trade.

This is the policy-trade-keyed-on-vendor correction: intake keys on the classifier's job trade, then reseeds.

⑧ Files touched & test status

FileΔRole in the memory rework
core/policy-graph.mjs+140 / −74Killed climb; added derived default + subtree-aware computeProposal.
tools/find_policy.mjs+52Returns suggest + observation signal on every tier.
tools/log_event.mjs+26resolveScope (owner scopes, no phantom create); default trigger kind.
scripts/replay-lapm.mjs+11Keys seed triggers on cached job trade.
data/wo-trades.json+584Token-free per-WO trade cache.
scripts/smoke-graph.mjs±Tests rewritten for derive/propose; 20/20 green, determinism preserved.

Verified: smoke-graph.mjs 20/20 — derived defaults, N=1 no-propose, N=2 propose-to-owner, no auto-promotion, PM-yes folds carve-outs in. Plus a full classifier eval (eval-classify-full.mjs) over all seeded WOs. The jobs table + classify-trade.mjs in the same 7 commits are adjacent intake work, not the memory layer.