codex/v2-canvas → agent-rework · the policy graph rework
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.
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.
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.
PROMOTE_AT=3 scopes under an ancestor held the same rule, applyEvent minted an ancestor rule and superseded the children — automatically.reason: null; strength was inferred from absorbed evidence. No one declared it.promoteId(event, ancestor) via sha256 kept re-folds byte-identical — a tell that the write was machine-authored.applyEvent creates / reinforces / supersedes, then returns promotions: []. Always.find_policy computes the observed majority vendor and suggests it — never writes it.PROPOSE_AT=2). An emerging pattern becomes a signal the agent phrases as a question; a human's yes → create_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.
findPolicy now returns a proposal on every tier alongside the answer. Tier 2 split into two cases:
| Tier | What it means | Caller does |
|---|---|---|
| 1 | A rule in this scope's chain governs the trigger. | Follow it, cite the why. |
| 2a new | Derived default — clear observed majority for this trigger across the workspace ("no rule, but Yonic ×8 over 5 properties"). | Suggest + ask. |
| 2b | Lone precedent — a single out-of-chain sighting, nothing to generalize on. | Suggest (weaker) + ask. |
| 3 | Nothing. 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.
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.
log_event(create_policy) gained resolveScope, which tries property then owner. It resolves-but-won't-create: a match only counts if created === false, so a typo'd scope errors instead of minting a phantom entity and misfiling the rule under it.trigger.kind = "new_work_order" added
So a human-declared rule's trigger is shaped identically to the observed dispatch events it generalizes. Without it, canonical-equality absorption couldn't fold the existing carve-outs up into the new rule.A 3-lens review of the E+F commit confirmed engine purity and the absorb/supersede invariants were clean. It caught proposal-path bugs:
| Sev | Bug | Fix |
|---|---|---|
| HIGH | Company 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. |
| MED | Proposal 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. |
| LOW | Nondeterministic 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.
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.
data/wo-trades.json — a per-WO job-trade cache
All 97 seeded WOs classified by reading each ticket (source: claude-read) — zero OpenAI tokens. Reseeds are now token-free; replay-lapm reads the cache via jobTrade(id, fallback) and falls back to vendor trade only for an unclassified WO.cleaning no longer derives Kori (just the patio deep-clean → Nayeli). Also fixed Guox's trade plumbing → handyman (a generalist).scripts/classify-wos.mjs — incremental OpenAI populator
For live intake / future new WOs; only classifies uncached ones. The seed path uses the cached hand-reads.This is the policy-trade-keyed-on-vendor correction: intake keys on the classifier's job trade, then reseeds.
| File | Δ | Role in the memory rework |
|---|---|---|
core/policy-graph.mjs | +140 / −74 | Killed climb; added derived default + subtree-aware computeProposal. |
tools/find_policy.mjs | +52 | Returns suggest + observation signal on every tier. |
tools/log_event.mjs | +26 | resolveScope (owner scopes, no phantom create); default trigger kind. |
scripts/replay-lapm.mjs | +11 | Keys seed triggers on cached job trade. |
data/wo-trades.json | +584 | Token-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.