Skip to content

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):

  1. 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}.
  2. Source-less guard (PC-3 / bl-266): assert source_document_id IS NOT NULL before any file write; NULL → KH-DB-only + source_less_content_item_edit_back log → reference bl-266; NO auto-create of connector='mcp' source docs. bl-266 is a CONSUMED dependency (gate-confirmed, not re-spec’d). → {59.10}.
  3. bl-267 (doc-diff re-point) is a SEPARATE/SEQUENTIAL slice, NOT this PLAN’s Diff-UI (see §D).
  4. 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 / arbitrate surface — 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 via authFailureResponse).
    • The content-item edit content_history insert is at L746 (change_type: 'edit' at L760), cast as Database['public']['Tables']['content_history']['Insert'] (the pattern at L358 for the publication_state insert). nextVersion pre-computed L744; the DB trigger content_history_auto_version() handles version numbering (comment L720). Best-effort logging via logBestEffortWarn (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 adds app/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, extensions L204), REVOKE EXECUTE … FROM anon L260, trigger bound L272. The fn INSERTs an OLD-row snapshot into q_a_pair_history (VALUES block L237–253) — the new edit_intent copy-line slots into BOTH the column list (after valid_to) and the VALUES block (OLD.edit_intent). q_a_pair_history CREATE at L153 — no edit_intent column 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 pattern components/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_history CREATE at …pre_squash…:3485 (no edit_intent, actor_id, or arbitration_inputs). Path = uuid5 PK seed (flow.py — confirmed in {59.3} §Context). No original_path column.
  • 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”
  1. PC-13 (storage/DDL) is the SERIAL ROOT. The edit_intent columns must exist before any stamp site (PC-7 / PC-A4) or Diff-UI metadata read can compile against Tables<'content_history'>['Insert'] with the new field. Every stamp/read slice depends on {59.5}. Both column slices (content_history AND q_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).
  2. The arbitration module ({59.6}) is pure + greenfield + file-isolatedlib/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).
  3. 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.md R1/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).
  4. 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.
  5. 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_intent in 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.
  6. 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 pending records so the Orchestrator can track them, but their details flag the gate and they should NOT be dispatched until the gate clears.
  7. 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.

{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)”
WaveSubtasksParallelismFile-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) and q_a_pair_history (Q&A pairs). It compares two USER-EDIT revisions of one record. It does NOT read source_document_diffs and 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]/diff page, an MCP tool, GDPR export) OFF the source_document_diffs substrate (0 rows) ONTO markdown-sidecar revision comparison. It RETAINS the document-diff feature; source_document_diffs is retired AS PART of that re-point, not standalone. bl-267 is needs_research and 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 details field. All dependencies are 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-touching details.

[
{
"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 waveCheckpoint
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)”
RiskMitigation
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↔stagingALTER 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 depGatesStatusTracked 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_researchcross_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-answeredcross_doc_link → themes/canonical-pipeline/reference/cocoindex-write-model.md
{64.14} (bid_→form_ rename){59.14} UC5 response contextratified, STEP-0=form_*cross_doc_link → ID-64 {64.14}
Spike#10 (UC8 dedup substrate){59.15} UC8PENDINGcross_doc_link → ID-31 PLAN §4.9
UC8 ratification{59.15} UC8PENDINGcross_doc_link
bl-74 (q_a_pair_history lineage cols){59.15} UC8 merge auditready (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.