ID-59 {59.3} TECH — Per-UC write-back + content_history.edit_intent column + arbitrate() wiring + Diff-UI
ID-59 {59.3} TECH — Per-UC write-back + content_history.edit_intent + arbitrate() wiring + Diff-UI
Section titled “ID-59 {59.3} TECH — Per-UC write-back + content_history.edit_intent + arbitrate() wiring + Diff-UI”Status: TECH draft — authored 08/06/2026 by a FRESH planner instance (Q-PLANNER-2: NOT the {59.1}/{59.2} author). Inputs read in full: ratified {59.2}
PRODUCT.md(21 invariants, 08/06/2026), {59.1}RESEARCH.md(08/06/2026), and the pre-canonical siblingTECH.md(05/06/2026, RULE-LOGIC INPUT — its arbitration truth table T-1..T-5 is carried; its storage / path / landed-code claims were already superseded by {59.2}, NOT re-litigated here).Carries the Liam 08/06/2026 ratifications:
edit_intent= acontent_history.edit_intent textcolumn (CHECKcosmetic|data|structural, NULL-allowed, write-only-forward) + optionalarbitration_inputs jsonb; lands pre-cutover in the {64.7}/{64.8} window (prod push Liam-gated). OQ-59-3 = KH-DB-only fallback as a GUARD against an invalid state, NOT a path to enable — a source-lesscontent_itemis an anomaly, NOT a first-class branch; do NOT build the auto-create-source path (cross-ref bl-266). OQ-59-4 = minimal revision-diff Diff-UI v1.Scope discipline: one Proposed change per PRODUCT invariant (INV-1..INV-21), mapped 1:1 so the Checker can verify per-invariant compliance. UK English throughout; DD/MM/YYYY dates.
Context
Section titled “Context”What is being built
Section titled “What is being built”T9 wires user-edit write-back for six use-cases (UC1/UC4 file-backed content; UC6 KH-DB-only Q&A;
UC3 sweeping rename; UC5 bid→Q&A promotion; UC8 cross-workspace dedup), captures + arbitrates an
edit_intent label at the write site, and builds a minimal user-edit Diff-UI over the existing
revision tables. See PRODUCT.md for the behaviour; this TECH is the implementation plan.
Code-intelligence orientation
Section titled “Code-intelligence orientation”gitnexus_query / gitnexus_context were unavailable at authoring time (LadybugDB
storage-version mismatch — index v41 vs build v40; the same transient the {59.2} author hit). Applied
the S276 ccc/grep + git diff --name-only fallback the CLAUDE.md “Worktree isolation” gotcha
sanctions. ast-dataflow is TS-only and does not cover the Python pipeline or SQL migrations, so the
write-back-layout claims below are grounded by direct flow.py + migration reads, not ast-dataflow.
The fallback findings ARE the correct greenfield signal {59.1}/{59.2} already established:
edit_intent/arbitratesurface — greenfield (re-confirmed 08/06/2026).grep -rniE 'edit[_]?intent|arbitrat' lib/ app/ components/ types/ hooks/ contexts/→ ZERO hits.gitnexus orientation: no existing symbols match — greenfield surfacefor the arbitration module. The pre-canonical TECH’sarbitrate()/arbitrateMany()is a rule contract that never landed as code (only the rule spec landed, S244 commit12a20963). T9 implements it.- Live content-item edit write-back surface — EXISTS, no intent.
app/api/items/[id]/route.tsguardsgetAuthorisedClient(['admin','editor'])(route.ts:45–46, returns{ success }— checked viaauth.success, failures viaauthFailureResponse(auth)), validates transitions, and inserts acontent_historyrow withchange_type='edit'(route.ts:746–762) inside a best-efforttrythat logs vialogBestEffortWarnand adds awarningsentry on failure. Noedit_intentis stamped. The DB triggercontent_history_auto_version()handles version numbering; the route also pre-computesnextVersion(route.ts:744). Sibling routes:app/api/items/[id]/history/route.ts,.../history/[versionId]/route.ts,.../rollback/route.ts. - Q&A user-direct write surface — GREENFIELD in
app/api.grep -rln 'q_a_pairs' app/api→ no user-direct PATCH/PUT route exists today.q_a_pair_historyis trigger-written (q_a_pairs_history_trigger()AFTER UPDATE onq_a_pairs,…20260520225456_t6_q_a_pairs_full_schema.sql:200,269; SECURITY DEFINER,SET search_path,REVOKE EXECUTE … FROM anon). UC6 user-direct adds the write route; the snapshot is captured by the existing trigger. - Diff-UI substrate — EXISTS.
components/item-detail/version-history.tsx(12 352 B) +components/item-detail/content-editor.tsx(Tiptap). The Diff-UI EXTENDS this surface. source_document_diffs— EXISTS, NOT the user-edit store.…20260416102457_pre_squash_reconciliation.sql:3970(old_document_id,new_document_id,diff_type ∈ added|removed|modified|unchanged,status ∈ pending_review|applied|dismissed,diff_mode ∈ qa|full_text). It is the re-ingest-version review store; out of scope for the user-edit Diff-UI (PRODUCT INV-17).- Write-back target + identity model (flow.py — ACTIVE
cocoindex_pipeline, NOT retiredkb_pipeline).source_documents.storage_path text NOT NULL(…20260416102457…:4037; flow.py:1189/1975"storage_path": rel_path).rel_pathis derived by_to_source_relative(file.file_path.path, source_path)(flow.py:1558,1816) AND is the uuid5 PK seed:content_item_id = uuid5(_KH_PIPELINE_DOC_NS, f"ci:{rel_path}")(flow.py:1833/1946),source_document_id = uuid5(…, f"sd:{rel_path}")(flow.py:1945), chunks/qa/entity_mentions likewise. Path = identity. A walked file that is not content is ingested as content unless explicitly skipped; the ONLY skip today is the single-filename manifest match (if file.file_path.path.name == _WORKSPACE_MANIFEST_FILENAME: return, flow.py:1671)..mdis a first-class content suffix (_NON_FORM_SUFFIXES = {".md",".txt",".html"}, flow.py:1350) — confirming the {59.1} §4.1 finding that a sidecar.mdwould mint a junkcontent_item. There is nooriginal_pathcolumn. - Safe access + envelopes.
tryQuery()(lib/supabase/safe.ts:160) andwarningsEnvelope()(lib/supabase/warnings.ts:100) are the no-silent-failure helpers used below.
content_history current shape (the column slice extends this)
Section titled “content_history current shape (the column slice extends this)”…20260416102457…:3485 — id, content_item_id, version, title, content, brief, detail, reference, metadata jsonb, change_summary, change_type varchar, created_by uuid, created_at, change_reason.
change_type CHECK = create|edit|ai_update|import|merge|rollback|archive|delete|metadata_change|owner_change
(+ publication_state added later). No edit_intent, no actor_id, no arbitration_inputs. The
NEW edit_intent (+ optional arbitration_inputs) columns are this Task’s pre-cutover slice. Multi-actor
attribution rides in arbitration_inputs; created_by carries the single/primary actor (no actor_id).
Proposed changes
Section titled “Proposed changes”Mapping legend: each entry names the PRODUCT invariant(s) it satisfies. Invariants that share one mechanism are grouped under the change that implements them (noted explicitly) so the 1:1 audit survives grouping.
PC-13 → INV-13 · The content_history.edit_intent column slice (foundation — lands first)
Section titled “PC-13 → INV-13 · The content_history.edit_intent column slice (foundation — lands first)”Implements INV-13 (and is the storage foundation INV-7/INV-9/INV-10/INV-12 stamp onto).
CLI migration (supabase migration new add_edit_intent_to_content_history; staging-first
db push; prod push is Liam-gated and NOT applied in this Task). Per CLAUDE.md: DDL via CLI only,
never MCP execute_sql / apply_migration; verify cat supabase/.temp/project-ref and relink to
staging (turayklvaunphgbgscat) before any push.
-- supabase/migrations/<ts>_add_edit_intent_to_content_history.sqlALTER TABLE public.content_history ADD COLUMN IF NOT EXISTS edit_intent text CHECK (edit_intent IN ('cosmetic', 'data', 'structural')), -- NULL allowed ADD COLUMN IF NOT EXISTS arbitration_inputs jsonb; -- NULL allowed; [{actor,intent}]
COMMENT ON COLUMN public.content_history.edit_intent IS 'Post-arbitration edit intent (S234 ONT.14 closed CV cosmetic|data|structural). Gates next-walk ' 're-classification per 02-data-flow §8.2. App-written only; NEVER pipeline-written. Write-only-forward ' '— pre-edit-feature history rows legitimately NULL (no backfill).';COMMENT ON COLUMN public.content_history.arbitration_inputs IS 'Per-actor inputs when a CRDT merge arbitrated >1 intent: jsonb array of {actor: uuid, intent: text}. ' 'NULL for single-actor saves. Forensic reconstruction of the arbitration (INV-13).';- NULL-allowed, write-only-forward, no backfill, no data migration — a
C4 shape-onlyslice (RESEARCH §4.4). NoREVOKE/SET search_pathclauses (no new function; column-onlyALTER). RLS is unchanged —content_historykeeps its existing policies; this column inherits them. - No pipeline coupling.
content_historyis app-written, never a flow.py write target (verified: flow.py write targets arecontent_items/source_documents/content_chunks/q_a_extractions/entity_mentions, NOTcontent_history). The ID-45 re-ingest never touches this column → the slice does NOT gate the run (closes the C3 question ID-93 §4 raised; couples only to {64.7}‘scontent_historyretention disposition, which a new write-only column adds nothing to). - Type regen after staging push: regenerate
supabase/types/database.types.tsper the CLAUDE.md command soTables<'content_history'>['Insert']carries the new optional fields (the items route casts its insert toDatabase['public']['Tables']['content_history']['Insert'], route.ts:358 — the new fields become available there automatically). - Pre-cutover landing: ratify + land in the {64.7}/{64.8} window. Optional-pre-cutover (cheap even post-cutover) per RESEARCH §4.4; recommend pre-cutover (zero cost).
PC-2 → INV-2 (+ INV-9 timing) · The arbitration module + write-site hook (greenfield)
Section titled “PC-2 → INV-2 (+ INV-9 timing) · The arbitration module + write-site hook (greenfield)”Implements INV-2 (KH-server pre-write concern, NOT in cocoindex / NOT a Yjs awareness concern) and the timing half of INV-9 (runs AFTER the CRDT merge, BEFORE the durable write).
New module lib/edit-intent/arbitrate.ts (per the lib/<domain>/<verb> convention —
lib/change-reports/, lib/coverage/, lib/templates/). Pure, mock-free, time-independent:
export type EditIntent = 'cosmetic' | 'data' | 'structural';
// INV-9 truth table (carried from pre-canonical TECH T-2): cosmetic is the unit element;// any data|structural present ⇒ data (v1 safe default — conservative re-run).export function arbitrate(a: EditIntent, b: EditIntent): EditIntent { if (a === 'cosmetic' && b === 'cosmetic') return 'cosmetic'; return 'data';}
// INV-10: N-way reduces pairwise, order-independent (commutative + idempotent).export function arbitrateMany(intents: EditIntent[]): EditIntent { return intents.reduce((acc, x) => arbitrate(acc, x), 'cosmetic' as EditIntent);}The EditIntent type is the single source of truth for the CV; export it from this module (or a
co-located types/edit-intent.ts) — direct file imports only, no barrel re-exports. The write site
threads the arbitrated value into the content_history insert (PC-7).
PC-9 / PC-10 → INV-9 / INV-10 · Arbitration rule (carried truth table) + N-way
Section titled “PC-9 / PC-10 → INV-9 / INV-10 · Arbitration rule (carried truth table) + N-way”Implements INV-9 (rule body) and INV-10 (N-way order-independence). The truth table is carried
verbatim from the pre-canonical TECH T-2 (the RULE-LOGIC INPUT): cosmetic+cosmetic→cosmetic; any
combination including data OR structural → data. arbitrateMany seeds the reduction with
cosmetic (the unit element) so arbitrateMany([]) === 'cosmetic' and order does not affect the
result. Arbitration is invoked ONLY on the UC1/UC4/UC6-user-direct CRDT paths (PC-7/PC-A4); the
single-actor UCs (UC3/UC5/UC6-AI/UC8) stamp the actor’s single selection WITHOUT calling arbitrate()
(PC-A6/PC-A5/PC-A5b/PC-A8).
PC-12 → INV-12 · Null / unknown-intent fallback → cosmetic + structured log
Section titled “PC-12 → INV-12 · Null / unknown-intent fallback → cosmetic + structured log”Implements INV-12. Normalise at the module boundary BEFORE arbitration:
export function coerceIntent(received: unknown, ctx: { userId: string; contentItemId: string; opId: string }): EditIntent { if (received === 'cosmetic' || received === 'data' || received === 'structural') return received; logStructured({ event: 'edit_intent_arbitration_fallback', received, treated_as: 'cosmetic', user_id: ctx.userId, content_item_id: ctx.contentItemId, op_id: ctx.opId }); return 'cosmetic';}coerceIntent runs per-side before arbitrateMany, so a version-skewed client (null/undefined/out-of-CV)
is treated as the unit element and does NOT dilute an explicit intent on the other side. The structured
log line is the observable side effect tests assert against (not implementation detail). Use the
existing best-effort logger (logBestEffortWarn shape in the items route) so the fallback never throws.
PC-7 → INV-7 · Stamp edit_intent at the content-item write site (UC1/UC4 DB leg)
Section titled “PC-7 → INV-7 · Stamp edit_intent at the content-item write site (UC1/UC4 DB leg)”Implements INV-7 for content_history. Extend the items-route content_history insert
(route.ts:746–762) to carry the resolved intent:
await supabase.from('content_history').insert({ /* …existing fields… */ change_type: 'edit', created_by: user.id, edit_intent: resolvedIntent, // INV-7 (NEW) arbitration_inputs: arbitrated ? perActorInputs : null, // INV-13 (NEW, only when arbitration ran)} as Database['public']['Tables']['content_history']['Insert']);resolvedIntent = arbitrateMany(perActorInputs.map(p => coerceIntent(p.intent, ctx))) when the save is a
CRDT merge of ≥2 intents; otherwise coerceIntent(singleIntent, ctx). The intent arrives on the PATCH
payload (extend the request body Zod schema with edit_intent?: EditIntent and, for collab saves,
arbitration_inputs?: {actor: string; intent: string}[]). The insert stays inside the existing
best-effort try but the stamp must not be silently dropped: validate edit_intent against the CV
in the Zod schema so an out-of-domain value is a 400, not a silent NULL (INV-13 “MUST NOT write a value
outside that domain” is enforced at BOTH the Zod boundary and the DB CHECK).
PC-1 / PC-2(atomicity) → INV-1 / INV-2 · UC1/UC4 dual-write (DB + file) with single atomic save
Section titled “PC-1 / PC-2(atomicity) → INV-1 / INV-2 · UC1/UC4 dual-write (DB + file) with single atomic save”Implements INV-1 (write to BOTH the content_items row AND the on-disk file at storage_path,
path preserved byte-for-byte; UC4 re-anchors affected citations) and INV-2 (single user-visible
atomic save — neither leg left applied in isolation; intent resolved before either write).
The file leg is the Spike#3 write-back adapter — a new KH-server module
lib/edit-intent/write-back.ts (or lib/source-documents/write-back.ts), largely pre-answered by the
landed cocoindex-write-model.md R1/R4:
- Resolve the on-disk absolute path: join
COCOINDEX_SOURCE_PATH(the flow binds it, flow.py:2877) withsource_documents.storage_path(the source-relative POSIXrel_path). UsetryQuery()to readstorage_path— no raw client call. - Write the new bytes to that exact path (path preserved → uuid5 PK stable on next
POST /walk; a path change mints a new identity and orphans the old content — the INV-1 “MUST NOT write to a different path” hazard). - Stamp the merged
edit_intentin the DB leg (PC-7) so the next walk’s §8.2 re-classification policy is honoured (cosmeticskips;datare-runs).
Atomicity ordering (INV-2): write the file leg first, then the DB leg, surfacing a single save outcome.
- On file-write failure → abort before the DB write; return one failure state; DB untouched.
- On DB-write failure AFTER a successful file write → roll the file back to its prior bytes (read-then-restore;
snapshot the prior bytes before step 2) so neither leg is left applied. Surface via
warningsEnvelope()if the rollback itself degrades, but the user sees ONE save outcome. (True 2-phase commit across FS+DB is not available; the read-then-restore compensating write is the v1 atomicity contract — call this out in Risks.) Thecontent_historyinsert’s existing best-effort posture is acceptable for the history row but the primarycontent_items+ file write is the atomic pair INV-2 governs.
Gate: UC1/UC4 file write-back is gated on the Spike#3 adapter contract (PRODUCT INV-18 cross_doc_link →
cocoindex-write-model.mdR1/R4). The DB-leg + intent-stamp (PC-7) can land ahead of the file leg.
PC-3 → INV-3 · Source-less content_item GUARD (NOT a fallback path) — bl-266
Section titled “PC-3 → INV-3 · Source-less content_item GUARD (NOT a fallback path) — bl-266”Implements INV-3 under the Liam 08/06/2026 steer: a content_item with source_document_id IS NULL
is an anomaly to guard, NOT a first-class branch. The write-back adapter (PC-1) MUST:
- On the UC1/UC4 file-write path, assert
source_document_id IS NOT NULL(read alongsidestorage_pathvia the sametryQuery()). If NULL: do NOT write a file, do NOT auto-create asource_document, do NOT mint aconnector='mcp'storage path. Perform the KH-DB-only write (thecontent_itemsrow +content_historyrow withedit_intent) AND emit a structured anomaly log line (event: source_less_content_item_edit_back, the content-item id, caller) so the source-less population is observable and traceable to bl-266 (enforce source-backing for all content_items). - The Diff-UI (PC-14) still works for these items because it reads
content_history.
This is a guard rail, not an affordance: the code path EXISTS only to keep an invalid state from silently
corrupting (mis-routing an edit to a fabricated file), and to surface the anomaly to bl-266. The “later
opt-in materialise-to-file” affordance is explicitly NOT built (v1.1 at most, and only if bl-266’s
source-backing enforcement does not first eliminate the source-less population). Cross_doc_link →
bl-266 (docs/reference/backlog/266.md).
PC-A4 / PC-4 → INV-4 · UC6 user-direct Q&A revision (KH-DB-only) + intent
Section titled “PC-A4 / PC-4 → INV-4 · UC6 user-direct Q&A revision (KH-DB-only) + intent”Implements INV-4 (write q_a_pairs + a q_a_pair_history snapshot; NO file write) and INV-7 on
the Q&A surface.
{127.38} verification note (25/07/2026): this section is CORRECT as written — no edit needed.
{59.30}later contradicted it by adding a sidecar dual-write (INV-12 write-back + INV-13 materialise-on-first-edit) to this very route. That leg is now RETIRED (ID-127 {127.38} / DR-086), soapp/api/q-a-pairs/[id]/route.tsis KH-DB-only again and matches this spec’s “NO file write” exactly. INV-4 HERE means UC6 KH-DB-only — a different INV-4 from the two in the siblingTECH-qa-*.mdspecs (numbering collides across this directory). UC6 user-direct is a greenfield write route inapp/api(verified: noq_a_pairsPATCH/PUT route today). Addapp/api/q-a-pairs/[id]/route.ts(PATCH) that:
- Guards via the Q&A surface’s role guard (PC-19), uses
tryQuery()for theq_a_pairsUPDATE. - The
q_a_pair_historysnapshot is written by the EXISTINGq_a_pairs_history_trigger()AFTER UPDATE — no app-side history insert. - Resolves + stamps
edit_intentidentically to PC-7.q_a_pair_historyhas noedit_intentcolumn today (verified…20260520225456…:153). INV-7 “applies to the Q&A revision surface as well” → add a SECOND additive column slice in PC-13’s migration (or a sibling migration):ALTER TABLE public.q_a_pair_history ADD COLUMN edit_intent text CHECK (…)+ the trigger functionq_a_pairs_history_trigger()must copy a NEWq_a_pairs.edit_intentcolumn into the snapshot (so the intent rides the OLD-row snapshot the same way the other audit columns do). Net:q_a_pairs.edit_intent(live) +q_a_pair_history.edit_intent(snapshot), both NULL-allowed, write-only-forward; the trigger fn gains oneNEW.edit_intent/OLD.edit_intentcopy line and re-assertsSET search_path = public, extensions+ keeps itsREVOKE EXECUTE … FROM anon. - Arbitration applies on the UC6 user-direct CRDT path exactly as UC1/UC4 (PC-9).
PC-A5b → INV-5 · UC6 AI-suggest (out of the arbitration path)
Section titled “PC-A5b → INV-5 · UC6 AI-suggest (out of the arbitration path)”Implements INV-5. No new code in THIS Task beyond not-invoking arbitration: the AI-suggest proposal
lands in the existing re-ingest change-detection / source_document_diffs review flow; a single user
commit writes q_a_pairs. It is a single-actor commit → does NOT call arbitrate(). The committed
revision’s edit_intent is the user’s commit-time selection (single value), stamped via the same Q&A
write path (PC-A4). Indistinguishable in storage from a hand-typed edit (INV-21).
PC-6 → INV-6 · UC3 sweeping rename (batched single-actor file write-back)
Section titled “PC-6 → INV-6 · UC3 sweeping rename (batched single-actor file write-back)”Implements INV-6. A KH-server sweep orchestrator (lib/edit-intent/sweep.ts or
lib/source-documents/sweep.ts) iterates the affected walked files, rewriting each at its
storage_path via the PC-1 adapter. 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). The rename is batched single-actor → does NOT invoke
arbitration; 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.
Gate: UC3 file write-back is gated on the Spike#3 adapter contract (INV-18 cross_doc_link). The sweep orchestrator builds ON the PC-1 adapter, so PC-1 lands first.
PC-5 → INV-5(UC5) · UC5 bid→Q&A promotion (KH-DB-only with lineage)
Section titled “PC-5 → INV-5(UC5) · UC5 bid→Q&A promotion (KH-DB-only with lineage)”Implements PRODUCT §A.7 (UC5). Promotion creates a q_a_pairs draft carrying lineage back to the
source response + originating question; NO file write; does NOT invoke arbitration. Source-response
context uses the form_* naming per {64.14} (cross_doc_link, INV-18 — the bid_*→form_* rename;
write against form_* symbols, NOT stale bid_*). The user reviews the draft before publish. Net new
code: a promotion endpoint + lineage columns are the {64.14}/UC5-adjacent surface; intent (if any) is
the promoter’s single selection.
PC-A8 → INV-8(UC8) · UC8 cross-workspace dedup (KH-DB-only merge with lineage)
Section titled “PC-A8 → INV-8(UC8) · UC8 cross-workspace dedup (KH-DB-only merge with lineage)”Implements PRODUCT §A.8 (UC8). 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; arbitration NOT invoked. Gated on three cross_doc_links (INV-18):
Spike#10 (dedup substrate), UC8 ratification, and bl-74 (q_a_pair_history superseded_by +
source_workspace_id lineage columns — the merge-audit columns do not exist on q_a_pair_history today;
verified …20260520225456…:153). The UC8 slice does NOT start until those clear; it is the only
cross-workspace write in scope (PC-20).
PC-14 / PC-15 / PC-16 / PC-17 → INV-14..INV-17 · The user-edit Diff-UI
Section titled “PC-14 / PC-15 / PC-16 / PC-17 → INV-14..INV-17 · The user-edit Diff-UI”Implements INV-14 (source = content_history for items, q_a_pair_history for Q&A; NOT
source_document_diffs; no new diff-storage table — diff computed on demand from two selected revision
blobs), INV-15 (v1 = minimal revision-diff view; richer side-by-side markdown → v1.1),
INV-16 (presentation invariants), INV-17 (independence from the storage decision +
source_document_diffs left in its re-ingest role).
- Surface: extend
components/item-detail/version-history.tsx(the existing version list) with a “compare two versions” affordance + a newcomponents/item-detail/revision-diff-view.tsx(mirror the adjacentprompt-diff-view.tsxpattern). Default selection = the latest two versions. - Data: read two
content_historyrows (existingapp/api/items/[id]/history/route.ts+.../history/[versionId]/route.ts); for Q&A readq_a_pair_history. TanStack Query exclusively — add keys tolib/query/query-keys.ts, fetchers tolib/query/fetchers.ts; no SWR / raw fetch. - Diff computation: on-demand, client-side or a thin RPC over the two revision blobs. No new table.
- v1 minimal view (INV-15): old↔new of the changed text + each version’s metadata (
version,change_type,change_summary,created_at,created_by, and the newedit_intent). Inline word-level highlighting + richer markdown side-by-side = v1.1. - Presentation (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.cssper the design spec). Empty/identical revisions → explicit “no changes between these versions” state, never a blank panel. UK English, DD/MM/YYYY timestamps. - Independence (INV-17): read-only; works off whatever revision rows exist regardless of where
edit_intentis stored; lands independently of PC-13.source_document_diffsuntouched.
PC-18 → INV-18 · External deps are cross_doc_links, not sibling subtask deps
Section titled “PC-18 → INV-18 · External deps are cross_doc_links, not sibling subtask deps”Implements INV-18. Spike#3 (write-back adapter — gates UC1/UC3/UC4 file leg), Spike#10 (UC8
substrate), UC8 ratification, bl-74 (q_a_pair_history lineage cols), {64.14} (bid_*→form_*) are
tracked as cross_doc_links on ID-59, NOT sibling-subtask deps (§3.3 sibling-only rule). Within ID-59,
the storage slice (PC-13) sibling-orders BEFORE the UC1/UC4/UC6 slices that stamp it — a valid
sibling-only ordering, no cross-Task subtask dep, no escalation required.
PC-19 → INV-19 · Authorisation
Section titled “PC-19 → INV-19 · Authorisation”Implements INV-19. Content-item write-back (UC1/UC4/UC3) keeps getAuthorisedClient(['admin','editor'])
(items route:45–46); failures route via authFailureResponse(auth) (check auth.success, NOT
auth.authorised). The NEW Q&A write route (PC-A4) uses the Q&A surface’s existing role guard. Viewers/
reviewers cannot write back. The Diff-UI (read-only) is visible to any role that can already read the
underlying version history.
PC-20 → INV-20 · Workspace scoping
Section titled “PC-20 → INV-20 · Workspace scoping”Implements INV-20. UC8 dedup is the ONLY cross-workspace write in scope (records
source_workspace_id lineage — bl-74). UC1/UC4/UC6/UC3/UC5 write-backs are scoped to content the acting
user can already edit (enforced by the existing RLS predicates on content_items/q_a_pairs under the
authorised client — no edit may leak content across a workspace boundary the user cannot otherwise reach).
PC-21 → INV-21 · AI-invisible-infrastructure
Section titled “PC-21 → INV-21 · AI-invisible-infrastructure”Implements INV-21. Where AI participates (UC6 AI-suggest, UC8 similarity reasoning) the involvement is
NOT surfaced as a user-facing “AI feature”: the user reviews + commits ordinary revisions/merges, and the
resulting content_items / q_a_pairs behave as if the data was always there. The edit_intent on an
AI-suggested-then-user-committed revision is the user’s commit-time selection, indistinguishable in storage
from a hand-typed edit.
Testing and validation
Section titled “Testing and validation”Maps each PRODUCT invariant to a concrete verification. bun run test (Vitest — NOT bun test) for
unit/component; bun run test:integration for real-Supabase/real-Yjs paths. Tests verify real behaviour,
not implementation, per test-philosophy.md.
| Invariant | Verification |
|---|---|
| INV-1 (UC1/UC4 dual-write, path preserved) | Integration: edit a file-backed item → assert content_items updated AND the file at storage_path rewritten at the SAME path; assert uuid5(ci:{rel_path}) unchanged. |
| INV-2 (atomic save) | Integration: force file-write failure → DB untouched, one failure state; force DB-write-after-file → file rolled back to prior bytes. |
| INV-3 (source-less GUARD) | Unit/integration: item with source_document_id IS NULL → NO file write, NO source_document created, source_less_content_item_edit_back log emitted; content_history row still written with edit_intent. |
| INV-4 (UC6 KH-DB-only) | Integration: Q&A revision via the new PATCH route → q_a_pairs updated + q_a_pair_history snapshot via trigger; NO file write. |
| INV-5 (UC6 AI-suggest) | Behavioural: AI-suggested commit writes q_a_pairs with the user’s single intent; arbitrate() NOT called. |
| INV-6 (UC3 sweep) | Integration: sweep rewrites N files at their storage_path; shared sweep-id recorded; whole-sweep rollback restores all N. |
| INV-7 (intent stamped) | Integration: a save with edit_intent='data' → content_history.edit_intent='data' (and Q&A equivalent). |
| INV-8 (audit survives) | Unit: arbitrated save persists arbitration_inputs as [{actor,intent}]; reconstructable. |
| INV-9 (arbitration rule) | Unit (mock-free): exhaustive 6-pair truth table; cosmetic+cosmetic→cosmetic; any data/structural→data. Property test: arbitrate(a,b)===arbitrate(b,a). |
| INV-10 (N-way) | Unit: arbitrateMany(['cosmetic','cosmetic'])==='cosmetic'; arbitrateMany(['cosmetic','data','cosmetic'])==='data'; order-independent. |
| INV-11 (UX deferred) | Inspection: no override-prompt UX shipped; documented v1.1. |
| INV-12 (null fallback + log) | Unit: coerceIntent(null/undefined/'foo')==='cosmetic'; structured edit_intent_arbitration_fallback log asserted as observable side effect. |
| INV-13 (persistence + CHECK) | Integration: insert outside CV → 400 at Zod AND rejected by DB CHECK; pre-feature rows NULL accepted; no backfill. |
| INV-14 (Diff-UI source) | Component: Diff-UI reads content_history/q_a_pair_history; never queries source_document_diffs. |
| INV-15 (minimal v1) | Component: pick two versions (default latest two) → old↔new + metadata incl. edit_intent; no inline word-highlighting in v1. |
| INV-16 (presentation) | Component + check: +/− gutters/labels (not colour-only); semantic tokens only (.claude/checks/); identical revisions → explicit empty state; DD/MM/YYYY. |
| INV-17 (independence) | Inspection: Diff-UI lands without PC-13; source_document_diffs untouched. |
| INV-18 (cross_doc_links) | Inspection: Spike#3/#10, UC8-ratification, bl-74, {64.14} tracked as cross_doc_links, not sibling deps; storage slice sibling-orders before the stamp slices. |
| INV-19 (auth) | Integration: viewer/reviewer write-back → authFailureResponse 403; admin/editor OK; Diff-UI read-visible to readers. |
| INV-20 (scoping) | Integration: cross-workspace edit (non-UC8) blocked by RLS; UC8 merge records source_workspace_id. |
| INV-21 (AI-invisible) | Inspection: no “AI feature” surfacing; AI-committed edit_intent storage-indistinguishable from hand-typed. |
Risks and mitigations
Section titled “Risks and mitigations”- FS+DB atomicity is not a true 2-phase commit (INV-2). The v1 contract is file-first, then DB, with a
read-then-restore compensating write on DB failure. A crash between the file write and the compensating
restore can leave the file ahead of the DB — mitigate by snapshotting prior bytes BEFORE the write and
by the next
POST /walkreconciling oncontent_text_hash; document the residual window. (Spike#3 owns the final adapter contract.) - Sidecar
.mdhazard is designed-out, not merely avoided. Theedit_intentlives incontent_historyprecisely so no new file class enters the frozen walked layout (flow.py:1350.mdis content). Any future “file-co-located revision artefact” is v1.1 and MUST add a pre-walk skip rule. - Schema parity (prod ↔ staging). The
ALTERlands staging-first; prod push is Liam-gated. Runschema-parity.ymlafter the staging push; do NOT let the column drift between envs before cutover. q_a_pair_historyneeds its OWNedit_intentcolumn + trigger copy line (PC-A4) — easy to miss because the items path uses an app-side insert while the Q&A path is trigger-written. The migration must touch BOTH the liveq_a_pairscolumn and the snapshot column, and the trigger fn must copy it.- Source-less population sizing (bl-266). The PRODUCT-stage prod sweep on
source_document_id IS NULLshould run before the UC1/UC4 file leg lands, so the guard’s anomaly-log volume is understood and bl-266’s enforcement scope is sized. (OQ-59-3 disposition is a guard; bl-266 is the enforcement.) publicRoutesallowlist. The newapp/api/q-a-pairs/[id]route is authenticated (not public) — it does NOT go inproxy.tspublicRoutes; confirm it is correctly behind auth.
Open items for Liam
Section titled “Open items for Liam”q_a_pair_history.edit_intentslice (PC-A4): the migration must addedit_intentto BOTHq_a_pairs(live) andq_a_pair_history(snapshot) and add a copy line toq_a_pairs_history_trigger(). Confirm this rides the SAME pre-cutover {64.7}/{64.8} window as thecontent_historyslice (recommended — same zero-cost, write-only-forward profile).- OQ-59-3 prod sweep (bl-266): authorise the
source_document_id IS NULLprod sweep before the UC1/UC4 file leg lands, to size the source-less population the PC-3 guard logs and bl-266 enforces. - Atomicity contract (INV-2 / Spike#3): confirm the file-first + compensating-restore v1 contract is acceptable (vs deferring the file leg entirely behind Spike#3 ratification). The DB-leg + intent stamp (PC-7) can land first regardless.
- Prod DDL push remains Liam-gated — this Task applies NO migration to prod (staging-first only).
Whether {59.4} PLAN is warranted
Section titled “Whether {59.4} PLAN is warranted”Yes — recommend authoring {59.4} PLAN. This spec pair is squarely in the {N.4} trigger set:
compound invariants (one PRODUCT invariant — e.g. INV-7 — maps across content-item AND Q&A surfaces),
multiple migrations (content_history slice + q_a_pairs/q_a_pair_history slice + the trigger
change), multiple adapter kinds (file write-back adapter, sweep orchestrator, Q&A write route,
dedup orchestrator), chain-dependent slices (PC-13 storage before PC-7/PC-A4 stamps; PC-1 adapter before
PC-6 sweep), and effort > 2h (the Task carries a 3-PLAN-unit estimate). Decompose into roughly: the
storage slice; the arbitration module + fallback; the UC1/UC4 DB-leg + stamp; the Spike#3 file adapter
(gated); the UC6 Q&A write route + slice; the UC3 sweep (gated); the Diff-UI; and the gated UC5/UC8
slices — comfortably under the 25-subtask ceiling, sibling-ordered with PC-13 first. The pre-canonical
arbitration TECH.md is an INPUT to {59.4}, not a replacement.
End of TECH. One Proposed-change per PRODUCT invariant (INV-1..INV-21). No DDL applied; no ledger edits; no code edits. Prod migration push is Liam-gated.