ID-59 {59.4} PLAN — Per-UC write-back decomposition (storage slice + arbitrate + stamps + file-first write-back + source-less guard + Diff-UI)
ID-59 {59.4} PLAN — Per-UC write-back implementation decomposition
Section titled “ID-59 {59.4} PLAN — Per-UC write-back implementation decomposition”Status: PLAN draft — authored 09/06/2026 by a FRESH planner instance (Q-PLANNER-2: NOT the {59.1}/{59.2}/{59.3} author). Inputs read in full: ratified {59.3}
TECH.md(PC-1..PC-21, one Proposed-change per invariant), {59.2}PRODUCT.md(INV-1..INV-21), {59.1}RESEARCH.md, and the live KH surface (orientation §A below). Session 330, Orchestrator-directed.Task: ID-59 (T9) — “Per-UC write-back (UC1/4/6/3/5/8) + Diff-UI re-point”. Largest of the T9–T14 family (3 PLAN units).
Carries the S330 Liam ratifications (folded into the slices below, not re-litigated):
- FS+DB atomicity = file-first + compensating-restore (Option 1). The file write IS in v1 — NOT deferred behind Spike#3. The compensating-restore failure path is its own concern with its own testStrategy (proves restore on simulated DB failure). → {59.9}.
- Source-less guard (PC-3 / bl-266): assert
source_document_id IS NOT NULLbefore any file write; NULL → KH-DB-only +source_less_content_item_edit_backlog → reference bl-266; NO auto-create ofconnector='mcp'source docs. bl-266 is a CONSUMED dependency (gate-confirmed, not re-spec’d). → {59.10}.- bl-267 (doc-diff re-point) is a SEPARATE/SEQUENTIAL slice, NOT this PLAN’s Diff-UI (see §D).
- Both edit_intent column slices in scope:
content_history(PC-13) +q_a_pairs/q_a_pair_history(PC-A4). → {59.5}.
A. Code-intelligence orientation (pre-decomposition, S330)
Section titled “A. Code-intelligence orientation (pre-decomposition, S330)”gitnexus_query / gitnexus_context are DOWN (LadybugDB storage-version mismatch — same transient
the {59.2}/{59.3} authors hit). Applied the S276 ccc + grep (SQL/Python) + ast-dataflow (TS-only)
fallback the CLAUDE.md “Worktree isolation” / GitNexus-stale gotcha sanctions. Findings (live, dated
09/06/2026) — cited verbatim so the Checker can verify the orientation step:
edit_intent/arbitratesurface — greenfield (re-confirmed).grep -rniE 'edit[_]?intent|arbitrat' lib/ app/ components/ types/ hooks/ contexts/→ ZERO hits (exit 0, no matches). T9 implements the rule contract; nothing to re-point onto. Greenfield surface for the arbitration module.- Items-route stamp site — EXISTS, no intent.
app/api/items/[id]/route.ts:getAuthorisedClient(['admin', 'editor'])at L45;if (!auth.success) return authFailureResponse(auth)at L46 (confirms PRODUCT INV-19 / TECH PC-19 — check.success, route viaauthFailureResponse).- The content-item edit
content_historyinsert is at L746 (change_type: 'edit'at L760), castas Database['public']['Tables']['content_history']['Insert'](the pattern at L358 for thepublication_stateinsert).nextVersionpre-computed L744; the DB triggercontent_history_auto_version()handles version numbering (comment L720). Best-effort logging vialogBestEffortWarn(imported L18). This is the PC-7 stamp site.
- Q&A user-direct write route — GREENFIELD.
grep -rln 'q_a_pairs' app/api→ no PATCH/PUT route;ls app/api/q-a-pairs→ no dir. PC-A4 addsapp/api/q-a-pairs/[id]/route.ts(PATCH). q_a_pairs_history_trigger()— EXISTS, copies OLD-row snapshot.supabase/migrations/20260520225456_t6_q_a_pairs_full_schema.sql: fn at L200 (SECURITY DEFINER,SET search_path = public, extensionsL204),REVOKE EXECUTE … FROM anonL260, trigger bound L272. The fn INSERTs an OLD-row snapshot intoq_a_pair_history(VALUES block L237–253) — the newedit_intentcopy-line slots into BOTH the column list (aftervalid_to) and the VALUES block (OLD.edit_intent).q_a_pair_historyCREATE at L153 — noedit_intentcolumn today (confirmed L153–174). The history table is trigger-only-written (no INSERT/UPDATE/DELETE policy, L180–183).- Diff-UI substrate — EXISTS.
components/item-detail/version-history.tsx(12 352 B),components/item-detail/content-editor.tsx, and the mirror patterncomponents/intelligence/prompt-refinement/prompt-diff-view.tsx(4 642 B). History API:app/api/items/[id]/history/route.ts+.../history/[versionId]/route.ts. Rollback contract:app/api/items/[id]/rollback/route.ts(UC3 whole-sweep rollback builds on this). - Helpers present:
lib/supabase/safe.ts(tryQuery()),lib/supabase/warnings.ts(warningsEnvelope()),lib/query/query-keys.ts,lib/query/fetchers.ts(TanStack — Diff-UI fetch goes here, no SWR/raw fetch). - Write-back target + identity model.
source_documents.storage_path text NOT NULL(…pre_squash…:4037);content_historyCREATE at…pre_squash…:3485(noedit_intent,actor_id, orarbitration_inputs). Path = uuid5 PK seed (flow.py — confirmed in {59.3} §Context). Nooriginal_pathcolumn. - bl-266 / bl-267 backlog records — confirmed present.
docs/reference/backlog/266.md(needs_research, “KH-DB-only edit-back is a GUARD, not a path to enable… enumerate content_items write paths; ensure each attaches a source_document… Feeds {59.3}”).docs/reference/backlog/267.md(needs_research, “Diff-UI RETAINED for v1, re-pointed OFF source_document_diffs ONTO markdown-sidecar revision comparison… 6 code surfaces incl. upload diff insert, /documents/[id]/diff page, MCP tool, GDPR export… Confirm sequencing vs ID-59 Diff-UI”). See §D for the cross-Task sequencing call.
No ABSENT/SIGNATURE_DRIFT on any external API the slices rely on. The greenfield “ZERO hits” and
the missing columns/route ARE the intended-to-build surface, not blockers (per {59.1} §6, {59.3}
§Context). DDL applied: NONE (this PLAN authors records only). Migration push is CLI staging-first;
prod = Liam gate (noted in {59.5}).
B. Architecture decisions carried into the decomposition
Section titled “B. Architecture decisions carried into the decomposition”- PC-13 (storage/DDL) is the SERIAL ROOT. The
edit_intentcolumns must exist before any stamp site (PC-7 / PC-A4) or Diff-UI metadata read can compile againstTables<'content_history'>['Insert']with the new field. Every stamp/read slice depends on {59.5}. Both column slices (content_historyANDq_a_pairs+q_a_pair_history+trigger copy-line) ride ONE migration — they share the identical write-only-forward, NULL-allowed, zero-cost profile and the same {64.7}/{64.8} pre-cutover window (TECH §Open-item-1). - The arbitration module ({59.6}) is pure + greenfield + file-isolated —
lib/edit-intent/arbitrate.ts. It has NO sibling dependency (no DB, no route, no schema). It can land in parallel with {59.5}. The stamp sites ({59.8}/{59.11}) depend on BOTH {59.5} (column) and {59.6} (function). - File-first + compensating-restore is v1 (S330 ratification 1). The write-back adapter
({59.9}) writes the file leg FIRST, then the DB leg; on DB failure it restores the prior file bytes.
This is NOT deferred behind Spike#3 — Spike#3 is largely pre-answered by the landed
cocoindex-write-model.mdR1/R4 and is tracked as a cross_doc_link, not a sibling dep. The compensating-restore failure path gets its own testStrategy (simulated DB failure → file restored). - The source-less guard ({59.10}) is a GUARD, not a path — it gates the file write inside the adapter ({59.9}) and is the consuming surface for bl-266. It is a small but distinct concern (assert + branch + anomaly log) and is sibling-ordered immediately after the adapter it guards.
- The Diff-UI ({59.12}) is independent of the storage decision (INV-17): it reads whatever
revision rows exist and can land WITHOUT {59.5}. But to surface
edit_intentin the metadata panel (INV-15) it reads the column when present, so it is sibling-ordered after {59.5} for the full-metadata acceptance — it does NOT hard-block on it for the diff itself. - UC5 ({59.13}) and UC8 ({59.14}) are GATED slices held behind cross_doc_links (Spike#10, UC8
ratification, bl-74, {64.14}). They are included as
pendingrecords so the Orchestrator can track them, but theirdetailsflag the gate and they should NOT be dispatched until the gate clears. - No cross-Task sibling deps. All external deps (Spike#3, Spike#10, UC8 ratification, bl-74, {64.14}, bl-266, bl-267) are cross_doc_links (TECH PC-18 / INV-18). The §C dependency arrows are all sibling-only (Subtasks of ID-59). No escalation required — confirmed against §3.3 / A6.
C. Dependency graph (sibling-only)
Section titled “C. Dependency graph (sibling-only)”{59.5} storage/DDL slice ─────┬─────────────────────────────────────────┐(content_history + q_a_pairs/ │ │ q_a_pair_history + trigger) │ │ │ │{59.6} arbitrate.ts module ────┤ (parallel with {59.5}) │(pure: arbitrate/arbitrateMany)│ │{59.7} coerceIntent + fallback ┘ (depends on {59.6}) │ │ │ ┌──────────────────────┴───────────┐ │ ▼ ▼ ▼{59.8} PC-7 items-route stamp {59.11} PC-A4 Q&A write route {59.12} Diff-UI(DB leg + edit_intent + (new app/api/q-a-pairs/[id], (version-history + arbitration_inputs) stamp + arbitration) revision-diff-view) needs {59.5},{59.6},{59.7} needs {59.5},{59.6},{59.7} needs {59.5} (metadata) │ ▼{59.9} file-first write-back adapter (UC1/UC4 file leg + compensating restore) needs {59.8} │ ├──▶ {59.10} source-less GUARD (bl-266) needs {59.9} └──▶ {59.13} UC3 sweep orchestrator needs {59.9} [builds on adapter]
{59.13} UC5 promotion [GATED: {64.14}] — independent KH-DB-only slice{59.14} UC8 dedup [GATED: Spike#10 + UC8 ratification + bl-74] — independent KH-DB-only slice(Numbering note: UC3 sweep and UC5/UC8 are listed below as {59.13}–{59.15}; the graph arrow labelled “{59.13} UC3 sweep” resolves to the {59.13} record in §E. The §E records are the authoritative IDs.)
Wave plan (parallel vs serial, by file-ownership)
Section titled “Wave plan (parallel vs serial, by file-ownership)”| Wave | Subtasks | Parallelism | File-ownership rationale |
|---|---|---|---|
| W1 (serial root + parallel pure) | {59.5} (migration), {59.6} (arbitrate.ts) | PARALLEL — disjoint files (SQL migration vs lib/edit-intent/arbitrate.ts) | {59.5} owns supabase/migrations/* + regen database.types.ts; {59.6} owns a new lib/edit-intent/ file. No overlap. |
| W2 (fallback) | {59.7} (coerceIntent) | SERIAL after {59.6} | Same file lib/edit-intent/arbitrate.ts (or co-located) — same-file ownership ⇒ serial after {59.6}. |
| W3 (stamps + UI — 3-way parallel) | {59.8} (items stamp), {59.11} (Q&A route), {59.12} (Diff-UI) | PARALLEL — disjoint files: app/api/items/[id]/route.ts vs new app/api/q-a-pairs/[id]/route.ts vs components/item-detail/* + lib/query/* | Three different owners. All three need {59.5}+{59.6}(+{59.7}); {59.12} needs only {59.5}. No write-overlap. |
| W4 (file write-back) | {59.9} (adapter) | SERIAL after {59.8} | {59.9} extends the items-route save flow {59.8} touches + new lib/edit-intent/write-back.ts. Same items-route region ⇒ serial after {59.8}. |
| W5 (guard + sweep — parallel on adapter) | {59.10} (source-less guard), {59.13} (UC3 sweep) | PARALLEL after {59.9} | {59.10} edits the adapter’s guard branch; {59.13} owns a new lib/edit-intent/sweep.ts + rollback route. Coordinate the single adapter touch-point: if {59.10}‘s guard and {59.9}‘s adapter overlap by line, run {59.10} serial after {59.9} (it does — guard lives IN the adapter) and {59.13} parallel. Net: {59.10} serial-after-{59.9}; {59.13} parallel. |
| W6 (GATED — do NOT dispatch until gate clears) | {59.14} (UC5 promotion), {59.15} (UC8 dedup) | PARALLEL (disjoint), but BLOCKED on cross_doc_links | {59.14} gated on {64.14}; {59.15} gated on Spike#10 + UC8 ratification + bl-74. Orchestrator holds these pending until gates clear. |
Subtask count: 11 (IDs 5–15). Well under the 25 soft ceiling. UC3 sweep is {59.13}, UC5 is {59.14}, UC8 is {59.15} (the §C graph’s “{59.13} UC3” label is the graph’s shorthand; §E is canonical).
D. bl-267 sequencing (cross-Task — flagged explicitly, NOT bent into a sibling dep)
Section titled “D. bl-267 sequencing (cross-Task — flagged explicitly, NOT bent into a sibling dep)”The S330 brief asks whether the ID-59 Diff-UI and the bl-267 re-point are the same slice or sequential. They are SEQUENTIAL / DISTINCT slices — confirmed against the live backlog records:
- ID-59 Diff-UI ({59.12}) = the user-edit revision diff on
content_history(content items) andq_a_pair_history(Q&A pairs). It compares two USER-EDIT revisions of one record. It does NOT readsource_document_diffsand does NOT touch the document-diff feature surfaces. It builds NOW. - bl-267 = a broader re-point of the existing document-diff FEATURE (6 code surfaces:
upload-diff insert,
/documents/[id]/diffpage, an MCP tool, GDPR export) OFF thesource_document_diffssubstrate (0 rows) ONTO markdown-sidecar revision comparison. It RETAINS the document-diff feature;source_document_diffsis retired AS PART of that re-point, not standalone. bl-267 isneeds_researchand is NOT in ID-59 scope.
Decision: the two are NOT the same slice. {59.12} ships the user-edit Diff-UI against
content_history/q_a_pair_history; bl-267 is a later, separate work-package that re-points the
document-diff feature. Neither builds against source_document_diffs. Sequencing: {59.12} first
(no dependency on bl-267); bl-267 follows independently. Tracked as a cross_doc_link → bl-267, NOT
a sibling dep. No escalation required.
(Note: the {59.2} PRODUCT corrected the {59.1} RESEARCH “source_document_diffs is not a table” claim
— it DOES exist (…pre_squash…:3970) as the re-ingest review store. {59.12} leaves it untouched in its
existing role per INV-17; bl-267 is what eventually retires it.)
D.1 bl-266 sequencing (cross-Task — CONSUMED dependency)
Section titled “D.1 bl-266 sequencing (cross-Task — CONSUMED dependency)”bl-266 (“enforce source-backing for all content_items”, needs_research, session_refs: [ID-93, ID-59.1], “Feeds {59.3}”) is a consumed dependency: {59.10} is the GUARD that surfaces the
source-less anomaly (source_less_content_item_edit_back log) and references bl-266; bl-266 is the
LATER enforcement that eliminates the source-less population. This PLAN gate-confirms the
sequencing — {59.10} ships the guard now (does not wait on bl-266); bl-266 enforces later. Tracked
as cross_doc_link → bl-266, NOT a sibling dep. This PLAN does NOT re-spec bl-266. No escalation.
E. Subtask records (TM-shape, for the Orchestrator to append to task-list.json)
Section titled “E. Subtask records (TM-shape, for the Orchestrator to append to task-list.json)”Authored within the ledger budget gate (description ≤250 chars, testStrategy ≤300 chars). Overflow lives in the unbudgeted
detailsfield. Alldependenciesare sibling-only (Subtasks of ID-59). Code-intel discipline (gitnexus DOWN → ccc/grep/ast-dataflow fallback; impact-before-edit; detect-changes-before-commit) is embedded in each code-touchingdetails.
[ { "id": 5, "title": "Add edit_intent storage slice (content_history + q_a_pairs/q_a_pair_history + trigger)", "description": "Serial-root migration: add edit_intent (CHECK CV, NULL) + arbitration_inputs jsonb to content_history; add edit_intent to q_a_pairs + q_a_pair_history + a copy-line in the history trigger. Regen types. Staging-first, prod Liam-gated.", "details": "PC-13 + PC-A4 (storage half). ONE migration via `supabase migration new add_edit_intent_columns`. CLAUDE.md: DDL via CLI only (never MCP execute_sql/apply_migration); `cat supabase/.temp/project-ref` then relink staging `supabase link --project-ref turayklvaunphgbgscat` BEFORE db push. Staging-first; PROD PUSH IS LIAM-GATED — do NOT push prod in this Task.\n\ncontent_history (CREATE at supabase/migrations/20260416102457_pre_squash_reconciliation.sql:3485 — confirm no edit_intent today): `ALTER TABLE public.content_history ADD COLUMN IF NOT EXISTS edit_intent text CHECK (edit_intent IN ('cosmetic','data','structural')), ADD COLUMN IF NOT EXISTS arbitration_inputs jsonb;` (both NULL-allowed, write-only-forward, NO backfill). COMMENT both per TECH PC-13. No REVOKE/SET search_path (column-only ALTER, no new fn). RLS unchanged.\n\nq_a_pairs/q_a_pair_history (schema at supabase/migrations/20260520225456_t6_q_a_pairs_full_schema.sql): `ALTER TABLE public.q_a_pairs ADD COLUMN edit_intent text CHECK(...)` AND `ALTER TABLE public.q_a_pair_history ADD COLUMN edit_intent text CHECK(...)`. Then `CREATE OR REPLACE FUNCTION public.q_a_pairs_history_trigger()` (current body L200-258) adding `edit_intent` to BOTH the INSERT column list (after valid_to, before changed_at — L237 region) and the VALUES block (`OLD.edit_intent` after OLD.valid_to — L252 region). MUST re-assert `SET search_path = public, extensions` (L204) AND re-issue `REVOKE EXECUTE ON FUNCTION public.q_a_pairs_history_trigger() FROM anon;` (L260) in the same migration — CLAUDE.md anon-EXECUTE gotcha.\n\nAfter staging push: regen `supabase/types/database.types.ts` per CLAUDE.md command so Tables<'content_history'>['Insert'] + Tables<'q_a_pairs'>['Insert'] carry the new optional fields. Run schema-parity.yml awareness (do NOT let prod drift). Code-intel: gitnexus DOWN; this is a SQL+types slice — grep migrations to confirm no prior edit_intent ALTER; ast-dataflow N/A for SQL. detect-changes N/A (worktree). Verify scope = migration file + regen types only.", "status": "pending", "dependencies": [], "testStrategy": "Migration applies clean on staging (idempotent IF NOT EXISTS). Insert outside CV rejected by DB CHECK on all 3 cols. q_a_pairs UPDATE fires trigger writing q_a_pair_history snapshot WITH copied edit_intent. Pre-feature rows NULL-accepted. database.types.ts regen carries new optional fields." }, { "id": 6, "title": "Build arbitrate()/arbitrateMany() pure module (lib/edit-intent/arbitrate.ts)", "description": "Greenfield pure module lib/edit-intent/arbitrate.ts: EditIntent CV type; arbitrate(a,b)=cosmetic+cosmetic→cosmetic else data (carried truth table); arbitrateMany reduces pairwise seeded with cosmetic. Commutative, idempotent. No DB/route/schema.", "details": "PC-2 + PC-9 + PC-10 (INV-2 timing / INV-9 rule / INV-10 N-way). New file `lib/edit-intent/arbitrate.ts` per `lib/<domain>/<verb>` convention. Pure, mock-free, time-independent — NO DB, NO route, NO schema (zero sibling dep; parallel with {59.5}).\n\nExport `EditIntent` as the single CV source of truth from this module (direct file imports only — NO barrel re-exports per CLAUDE.md). Truth table carried VERBATIM from the pre-canonical TECH.md T-2 (the rule-logic INPUT): `arbitrate(a,b)`: if a==='cosmetic' && b==='cosmetic' return 'cosmetic'; else return 'data'. `arbitrateMany(intents: EditIntent[])`: `intents.reduce((acc,x)=>arbitrate(acc,x), 'cosmetic')` so `arbitrateMany([])==='cosmetic'` and order does not affect result. Arbitration is invoked ONLY on UC1/UC4/UC6-user-direct CRDT paths ({59.8}/{59.11}); single-actor UCs stamp without calling it.\n\nCode-intel: greenfield (grep edit[_]?intent|arbitrat → ZERO hits, gitnexus DOWN → ccc/grep fallback). No impact analysis needed (new file, no callers yet). Verify scope = the new file only.", "status": "pending", "dependencies": [], "testStrategy": "Unit (mock-free, `bun run test` NOT `bun test`): exhaustive 6-pair truth table; cosmetic+cosmetic→cosmetic; any data/structural→data. Property: arbitrate(a,b)===arbitrate(b,a). arbitrateMany(['cosmetic','data','cosmetic'])==='data' regardless of order; arbitrateMany([])==='cosmetic'." }, { "id": 7, "title": "Add coerceIntent() null/unknown fallback + structured log", "description": "Add coerceIntent(received, ctx) to the arbitrate module: returns the value if in CV; else logs edit_intent_arbitration_fallback and returns cosmetic (unit element) so a skewed client never dilutes an explicit intent. Best-effort log, never throws.", "details": "PC-12 (INV-12). Same file `lib/edit-intent/arbitrate.ts` (or co-located) ⇒ SERIAL after {59.6} (same-file ownership). Signature: `coerceIntent(received: unknown, ctx: {userId:string; contentItemId:string; opId:string}): EditIntent`. Runs PER-SIDE before arbitrateMany so a null/undefined/out-of-CV side is treated as cosmetic. Use the existing best-effort logger (logBestEffortWarn shape from app/api/items/[id]/route.ts:18 import) so the fallback never throws. The structured log line is the OBSERVABLE side effect tests assert (not impl detail).\n\nCode-intel: greenfield extension of {59.6}; no external callers yet. Verify scope = the arbitrate module file.", "status": "pending", "dependencies": [6], "testStrategy": "Unit (`bun run test`): coerceIntent(null/undefined/'foo')==='cosmetic'; coerceIntent('data')==='data'. Assert structured edit_intent_arbitration_fallback log emitted (observable side effect) with received + treated_as:cosmetic + ctx ids for the 3 fallback cases; none for valid values." }, { "id": 8, "title": "Stamp edit_intent at the content-item write site (UC1/UC4 DB leg)", "description": "Extend the content_history insert in items route (L746) to carry edit_intent + arbitration_inputs. Resolve via arbitrateMany over coerced inputs (CRDT) else coerceIntent(single). Validate against CV in the request Zod schema (out-of-CV → 400).", "details": "PC-7 (INV-7 for content_history). Edit `app/api/items/[id]/route.ts` — the content_history insert at L746 (change_type:'edit' L760), cast `as Database['public']['Tables']['content_history']['Insert']` (mirror the L358 publication_state insert). Add `edit_intent: resolvedIntent` and `arbitration_inputs: arbitrated ? perActorInputs : null`.\n\n`resolvedIntent = arbitrateMany(perActorInputs.map(p => coerceIntent(p.intent, ctx)))` when the save is a CRDT merge of >=2 intents; else `coerceIntent(singleIntent, ctx)`. Intent arrives on the PATCH body — extend the request Zod schema with `edit_intent?: EditIntent` (validate against CV — out-of-domain = 400, INV-13 enforced at BOTH Zod boundary AND DB CHECK) and `arbitration_inputs?: {actor:string; intent:string}[]` for collab saves. Insert stays inside the existing best-effort try (logBestEffortWarn) but the stamp MUST NOT be silently dropped (Zod 400 guards it). Import arbitrate/arbitrateMany/coerceIntent from `@/lib/edit-intent/arbitrate` (direct import, no barrel). Auth unchanged: getAuthorisedClient(['admin','editor']) L45, authFailureResponse L46.\n\nCode-intel: gitnexus DOWN → before editing the route run ast-dataflow `callers`/`references` on the route handler + grep for the insert site; report blast radius (the items PATCH handler + its tests). detect-changes N/A (worktree) → `git diff --name-only` to confirm scope = route.ts + its test + Zod schema. Needs {59.5} (column) + {59.6}+{59.7} (functions).", "status": "pending", "dependencies": [5, 6, 7], "testStrategy": "Integration (`bun run test`): save edit_intent='data' → content_history.edit_intent='data'. CRDT merge of [cosmetic,data] → edit_intent='data' + arbitration_inputs persisted as [{actor,intent}]. edit_intent outside CV → 400 at Zod. Single-actor save stamps coerced single value." }, { "id": 9, "title": "Build file-first write-back adapter with compensating restore (UC1/UC4 file leg)", "description": "New lib/edit-intent/write-back.ts: resolve abs path from COCOINDEX_SOURCE_PATH + storage_path; snapshot prior bytes; write file FIRST (exact path → stable uuid5 PK), THEN DB; on DB failure RESTORE prior bytes (compensating). One atomic save.", "details": "PC-1 + PC-2 atomicity (INV-1 dual-write path-preserved + INV-2 atomic save). S330 RATIFICATION 1: file-first + compensating-restore is V1 — NOT deferred behind Spike#3. New module `lib/edit-intent/write-back.ts`. Read `source_documents.storage_path` via `tryQuery()` (lib/supabase/safe.ts) — no raw client. Resolve abs path = COCOINDEX_SOURCE_PATH (flow binds at flow.py:2877) joined with storage_path (source-relative POSIX = uuid5 PK seed). Write the new bytes TO THAT EXACT PATH (a path change mints a new identity + orphans old content — INV-1 'MUST NOT write to a different path' hazard). UC4 re-anchors affected citations.\n\nORDERING (INV-2): (1) snapshot prior file bytes; (2) write file leg; on file-write failure → abort BEFORE DB write, one failure state, DB untouched; (3) DB leg (the {59.8} content_items + content_history write); on DB-write failure AFTER successful file write → RESTORE prior bytes (read-then-restore compensating write) so neither leg left applied. Surface via warningsEnvelope() if the restore itself degrades, but user sees ONE save outcome. NOT a true 2PC — document residual crash-window (file ahead of DB) reconciled by next POST/walk on content_text_hash (Risks). Spike#3 = cross_doc_link → cocoindex-write-model.md R1/R4 (pre-answers path resolution); NOT a sibling dep.\n\nCode-intel: gitnexus DOWN → ccc search 'write-back'/'storage_path'; ast-dataflow `references` on storage_path reads; grep flow.py (Python — ast-dataflow N/A) for COCOINDEX_SOURCE_PATH binding. Impact: HIGH (touches the items save flow + FS) — warn before edit. git diff --name-only to confirm scope. Extends the items-route save region {59.8} owns ⇒ serial after {59.8}.", "status": "pending", "dependencies": [8], "testStrategy": "Integration: edit file-backed item → content_items updated AND file at storage_path rewritten at SAME path; uuid5(ci:{rel_path}) unchanged. Force file-write fail → DB untouched, one failure state. Force DB-write fail AFTER file write → file RESTORED to prior bytes (compensating restore proven)." }, { "id": 10, "title": "Add source-less content_item GUARD before file write (bl-266)", "description": "In the write-back adapter, before any file write assert source_document_id IS NOT NULL. NULL → KH-DB-only, NO file write, NO source_document auto-create, NO mcp mint; emit source_less_content_item_edit_back log → bl-266. A guard, not a path.", "details": "PC-3 (INV-3) under S330 RATIFICATION 2 + Liam steer: source-less is an ANOMALY TO GUARD, NOT a first-class branch. Edit the {59.9} adapter's pre-file-write path. Read `source_document_id` alongside `storage_path` via the SAME tryQuery(). If NULL: do NOT write a file, do NOT auto-create a source_document, do NOT mint a connector='mcp' storage path. Perform the KH-DB-only write (content_items row + content_history row WITH edit_intent — the {59.8} DB leg) AND emit a structured anomaly log `event: source_less_content_item_edit_back` (content-item id, caller) so the source-less population is observable + traceable to bl-266. The 'opt-in materialise-to-file' affordance is explicitly NOT built (v1.1 at most, and only if bl-266 enforcement doesn't first eliminate the population).\n\nbl-266 is a CONSUMED cross_doc_link (docs/reference/backlog/266.md, needs_research, 'Feeds {59.3}') — this slice ships the guard NOW; bl-266 enforces later. Do NOT re-spec bl-266. Recommend the OQ-59-3 prod sweep (size source_document_id IS NULL population) runs before this lands so the anomaly-log volume is understood (TECH Open-item-2).\n\nCode-intel: gitnexus DOWN → grep for source_document_id reads near the adapter; ast-dataflow `column-reads` on source_documents/content_items. Lives IN the {59.9} adapter ⇒ serial after {59.9}. Verify scope = write-back.ts guard branch + its test.", "status": "pending", "dependencies": [9], "testStrategy": "Integration/unit: source_document_id IS NULL → NO file write, NO source_document created, NO connector='mcp' mint; source_less_content_item_edit_back log emitted; content_history row STILL written with edit_intent. Source-backed item → file written normally (guard passes)." }, { "id": 11, "title": "Add UC6 user-direct Q&A write route (KH-DB-only) + intent stamp", "description": "New app/api/q-a-pairs/[id]/route.ts (PATCH): Q&A role guard; UPDATE q_a_pairs via tryQuery; history snapshot by the existing trigger; resolve + stamp edit_intent as the items route; NO file write (KH-DB-only v1). Arbitration on the CRDT path.", "details": "PC-A4 / PC-4 (INV-4 KH-DB-only + INV-7 on Q&A surface). GREENFIELD route (verified: no q_a_pairs PATCH/PUT in app/api; no app/api/q-a-pairs dir). Add `app/api/q-a-pairs/[id]/route.ts` PATCH handler. Guard via the Q&A surface's existing role guard (PC-19 — getAuthorisedClient pattern, check auth.success, route via authFailureResponse). UPDATE `q_a_pairs` via tryQuery() (lib/supabase/safe.ts). The `q_a_pair_history` snapshot is written by the EXISTING q_a_pairs_history_trigger() AFTER UPDATE (now copying edit_intent per {59.5}) — NO app-side history insert. Resolve+stamp edit_intent via arbitrateMany/coerceIntent from `@/lib/edit-intent/arbitrate` (direct import) exactly as {59.8}. Arbitration applies on the UC6 user-direct CRDT path. NO file write (file-sidecar materialisation is DEFERRED-v1.1). NEW route is authenticated — do NOT add to proxy.ts publicRoutes (Risks); confirm behind auth.\n\nCode-intel: gitnexus DOWN → grep q_a_pairs in app/api (confirm greenfield); mirror the items-route auth+tryQuery+stamp shape. ast-dataflow `references` on getAuthorisedClient for the guard pattern. New file ⇒ disjoint from {59.8}/{59.12} (parallel). Needs {59.5} (q_a_pairs.edit_intent col + trigger) + {59.6}+{59.7}.", "status": "pending", "dependencies": [5, 6, 7], "testStrategy": "Integration: Q&A revision via the new PATCH route → q_a_pairs updated + q_a_pair_history snapshot via trigger WITH edit_intent; NO file write. Save with edit_intent → stamped on q_a_pairs + snapshot. Viewer/reviewer → authFailureResponse 403. CRDT merge → arbitrated intent stamped." }, { "id": 12, "title": "Build minimal user-edit Diff-UI (content_history + q_a_pair_history)", "description": "Extend version-history.tsx with a compare-two-versions affordance + new revision-diff-view.tsx. Read content_history / q_a_pair_history via TanStack Query. Old↔new text + metadata incl. edit_intent. NOT source_document_diffs. Minimal v1 view.", "details": "PC-14/15/16/17 (INV-14..INV-17). v1 MINIMAL view (OQ-59-4): pick two versions (default latest two) → old↔new of changed text + each version's metadata (version, change_type, change_summary, created_at, created_by, AND the new edit_intent). Richer side-by-side markdown + inline word-level highlighting = v1.1.\n\nSurface: extend `components/item-detail/version-history.tsx` (12 352 B) with a 'compare two versions' affordance + new `components/item-detail/revision-diff-view.tsx` (mirror `components/intelligence/prompt-refinement/prompt-diff-view.tsx`). Data: read two content_history rows via `app/api/items/[id]/history/route.ts` + `.../history/[versionId]/route.ts`; Q&A reads q_a_pair_history. TanStack Query EXCLUSIVELY — add keys to lib/query/query-keys.ts, fetchers to lib/query/fetchers.ts (NO SWR/raw fetch). Diff computed on-demand client-side over two revision blobs — NO new table. Does NOT read source_document_diffs (left in its re-ingest role, INV-17).\n\nPresentation (INV-16): additions/removals/unchanged distinguished by MORE than colour (+/- gutters, labels, weight) per WCAG 2.1 AA. Warm Meridian SEMANTIC TOKENS ONLY (no raw Tailwind colours; new tokens in app/globals.css per warm-meridian-implementation-spec.md). Empty/identical revisions → explicit 'no changes between these versions' state, never blank. UK English, DD/MM/YYYY. INDEPENDENT of storage decision (works off whatever revision rows exist) — needs {59.5} only to surface edit_intent in metadata. bl-267 (doc-diff re-point) is a SEPARATE later slice (PLAN §D) — do NOT build against source_document_diffs.\n\nCode-intel: gitnexus DOWN → ccc search 'version-history'/'diff-view'; ast-dataflow `importers` on version-history.tsx to size the surface. Run `.claude/checks/` semantic-token check before commit.", "status": "pending", "dependencies": [5], "testStrategy": "Component (`bun run test`): pick two versions (default latest two) → old↔new + metadata incl. edit_intent; no inline word-highlighting v1. Reads content_history/q_a_pair_history, NEVER source_document_diffs. +/- gutters not colour-only; semantic tokens only; identical → explicit empty state." }, { "id": 13, "title": "Build UC3 sweeping-rename orchestrator (batched single-actor file write-back)", "description": "New lib/edit-intent/sweep.ts: iterate affected walked files, rewrite each at its storage_path via the {59.9} adapter; every record in a sweep shares a sweep-id (per-match provenance + whole-sweep rollback). Single-actor → no arbitration.", "details": "PC-6 (INV-6). New module `lib/edit-intent/sweep.ts` (or lib/source-documents/sweep.ts). Iterate the affected walked files, rewriting each at its storage_path via the PC-1 adapter ({59.9}) — so {59.9} lands first. Every record touched by one sweep shares a single SWEEP IDENTIFIER, recorded per-match for audit + whole-sweep rollback per the existing rollback contract `app/api/items/[id]/rollback/route.ts` (4 911 B). Batched single-actor → does NOT invoke arbitrate(); each touched record stamps the sweep's single intent (typically 'structural' or 'data'). Per-match provenance recorded so a user can audit + revert per-match.\n\nGate: UC3 file write-back is gated on Spike#3 (cross_doc_link → cocoindex-write-model.md R1/R4) — but Spike#3 is pre-answered, so this builds on the landed {59.9} adapter. New file ⇒ parallel with {59.10} (which lives in the adapter). Owns sweep.ts + extends rollback route.\n\nCode-intel: gitnexus DOWN → ast-dataflow `callers` on the rollback route handler before extending it (report blast radius); ccc search 'rollback'. git diff --name-only to confirm scope = sweep.ts + rollback route + tests.", "status": "pending", "dependencies": [9], "testStrategy": "Integration: sweep rewrites N files at their storage_path; shared sweep-id recorded per-match; whole-sweep rollback restores all N to prior bytes. arbitrate() NOT called (batched single-actor). Per-match provenance auditable + per-match revert works." }, { "id": 14, "title": "[GATED on {64.14}] Build UC5 bid→Q&A promotion (KH-DB-only with lineage)", "description": "Promotion endpoint creating a q_a_pairs draft with lineage to the source response + question; NO file write; no arbitration. Source context uses form_* naming ({64.14}). User reviews before publish. GATED: do not dispatch until {64.14} clears.", "details": "PC-5 (PRODUCT §A.7 / INV-5-UC5). GATED — cross_doc_link → {64.14} (bid_*→form_* rename; ratified, STEP-0 = form_*). Do NOT dispatch until {64.14} clears. Promotion creates a `q_a_pairs` draft carrying lineage back to the source response + originating question; NO file write; does NOT invoke arbitration. Write against `form_*` symbols, NOT stale `bid_*`. The user reviews the draft before publish. Net new: a promotion endpoint + lineage columns (the {64.14}/UC5-adjacent surface); intent (if any) is the promoter's single selection (coerceIntent, no arbitrateMany). KH-DB-only, scoped to content the user can already edit (PC-20).\n\nThis is included as a pending record for Orchestrator tracking; its gate ({64.14}) is a cross_doc_link NOT a sibling dep — no escalation. Code-intel at dispatch time: gitnexus (if up) impact on the form_* response tables; grep form_ in app/api to confirm the rename landed before building.", "status": "pending", "dependencies": [5], "testStrategy": "Integration (post-gate): promote a form response → q_a_pairs draft created with lineage to source response + question; NO file write; arbitrate() NOT called; draft is review-before-publish. Uses form_* naming (not bid_*). Scoped to the user's editable content." }, { "id": 15, "title": "[GATED on Spike#10 + UC8 ratification + bl-74] Build UC8 cross-workspace dedup", "description": "KH-server dedup orchestrator + Claude similarity reasoning merges near-duplicate q_a_pairs, recording lineage in q_a_pair_history (superseded_by; source_workspace_id). NO file write; no arbitration. The only cross-workspace write in scope.", "details": "PC-A8 (PRODUCT §A.8 / INV-8-UC8). GATED on THREE cross_doc_links (INV-18) — do NOT dispatch until ALL clear: Spike#10 (dedup substrate — cocoindex post-target-write @coco.fn vs mempalace KG vs hybrid), UC8 ratification (v1 Candidate A scope), bl-74 (q_a_pair_history superseded_by + source_workspace_id lineage columns — these merge-audit cols do NOT exist on q_a_pair_history today; verified schema at supabase/migrations/20260520225456:153). KH-server dedup orchestrator + Claude similarity reasoning merges near-duplicate q_a_pairs, recording merge lineage in q_a_pair_history (surviving pair references the superseded one). NO file write; arbitrate() NOT invoked (DB-level merge, not a CRDT edit). It is the ONLY cross-workspace write in scope (PC-20) — records source_workspace_id lineage.\n\nIncluded as a pending record for Orchestrator tracking; the three gates are cross_doc_links NOT sibling deps — no escalation. bl-74 is flipped `ready` and lands before ID-45 promotion ({64.8} gate G3). Code-intel at dispatch: confirm bl-74 columns present + Spike#10 substrate ratified before building. If Claude API used → read the claude-api skill before touching the SDK.", "status": "pending", "dependencies": [5], "testStrategy": "Integration (post-gate): dedup merges near-duplicate q_a_pairs; q_a_pair_history records merge lineage (superseded_by + source_workspace_id, bl-74 cols); surviving pair references superseded; NO file write; arbitrate() NOT called; cross-workspace merge records source_workspace_id." }]F. Verification checkpoints (for the Orchestrator’s wave sequencing)
Section titled “F. Verification checkpoints (for the Orchestrator’s wave sequencing)”| After wave | Checkpoint |
|---|---|
| W1 ({59.5}+{59.6}) | Migration applies clean on STAGING (prod NOT pushed — Liam gate); database.types.ts regen carries new optional fields; bun run test green for the arbitrate unit suite. |
| W2 ({59.7}) | coerceIntent fallback unit suite green; structured-log side effect asserted. |
| W3 ({59.8}+{59.11}+{59.12}) | Items stamp + Q&A route integration green; Diff-UI component suite green + .claude/checks/ semantic-token pass. End-to-end: edit → intent stamped → visible in Diff-UI metadata. |
| W4 ({59.9}) | File-first dual-write integration green; compensating-restore proven on simulated DB failure. |
| W5 ({59.10}+{59.13}) | Source-less guard integration green (no file, no auto-create, anomaly log); UC3 sweep + whole-sweep rollback green. |
| W6 (GATED) | Do NOT enter until {64.14} (for {59.14}) / Spike#10+UC8-ratification+bl-74 (for {59.15}) clear. |
G. Risks and open items (carried from {59.3}, for Orchestrator awareness)
Section titled “G. Risks and open items (carried from {59.3}, for Orchestrator awareness)”| Risk | Mitigation |
|---|---|
| FS+DB atomicity is not true 2PC ({59.9}) | file-first + snapshot-prior-bytes + compensating restore on DB fail; residual crash-window reconciled by next POST/walk on content_text_hash. Own testStrategy proves the restore. |
q_a_pair_history needs its OWN edit_intent col + trigger copy-line ({59.5}) | the migration touches BOTH q_a_pairs (live) AND q_a_pair_history (snapshot) AND the trigger fn copy-line — easy to miss (items path is app-insert; Q&A path is trigger-written). |
| Schema parity prod↔staging | ALTER lands staging-first; prod = Liam gate; run schema-parity.yml after staging push; do not let columns drift pre-cutover. |
| Source-less population sizing (bl-266) | OQ-59-3 prod sweep before {59.10} lands so guard anomaly-log volume + bl-266 enforcement scope are understood. |
| New Q&A route + publicRoutes ({59.11}) | the route is authenticated — NOT added to proxy.ts publicRoutes; confirm behind auth. |
Open items deferred to Liam / Orchestrator (NOT PLAN decisions): OQ-59-3 prod sweep authorisation (before {59.10}); prod DDL push gate (after {59.5} staging validation); UC8 gate-clearance timing ({59.15}).
H. Cross-Task dependency flags (explicit — NOT bent into sibling deps)
Section titled “H. Cross-Task dependency flags (explicit — NOT bent into sibling deps)”Per §3.3 / A6 sibling-only rule, ALL of the following are cross_doc_links on ID-59, NOT sibling subtask deps. No escalation required (every §C arrow is a genuine sibling ordering):
| External dep | Gates | Status | Tracked as |
|---|---|---|---|
| bl-266 (source-backing enforcement) | {59.10} consumes it (guard surfaces anomaly → bl-266 enforces later) | needs_research; “Feeds {59.3}“ | cross_doc_link → docs/reference/backlog/266.md |
| bl-267 (doc-diff feature re-point) | SEQUENTIAL to {59.12} (distinct slice — §D) | needs_research | cross_doc_link → docs/reference/backlog/267.md |
| Spike#3 (write-back adapter contract) | {59.9}/{59.13} file leg (pre-answered by cocoindex-write-model R1/R4) | pre-answered | cross_doc_link → themes/canonical-pipeline/reference/cocoindex-write-model.md |
| {64.14} (bid_→form_ rename) | {59.14} UC5 response context | ratified, STEP-0=form_* | cross_doc_link → ID-64 {64.14} |
| Spike#10 (UC8 dedup substrate) | {59.15} UC8 | PENDING | cross_doc_link → ID-31 PLAN §4.9 |
| UC8 ratification | {59.15} UC8 | PENDING | cross_doc_link |
| bl-74 (q_a_pair_history lineage cols) | {59.15} UC8 merge audit | ready (lands before ID-45) | cross_doc_link → docs/reference/backlog/74.md |
End of PLAN. 11 implementation Subtasks ({59.5}–{59.15}), sibling-ordered with {59.5} (storage/DDL) as the serial root. All dependencies sibling-only; all external deps are cross_doc_links (no escalation). 11 of 25 soft ceiling. No DDL applied; no code edits; no ledger writes performed by this PLAN. Prod migration push remains Liam-gated.