Skip to content

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 sibling TECH.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 = a content_history.edit_intent text column (CHECK cosmetic|data|structural, NULL-allowed, write-only-forward) + optional arbitration_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-less content_item is 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.


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.

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 / arbitrate surface — 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 surface for the arbitration module. The pre-canonical TECH’s arbitrate() / arbitrateMany() is a rule contract that never landed as code (only the rule spec landed, S244 commit 12a20963). T9 implements it.
  • Live content-item edit write-back surface — EXISTS, no intent. app/api/items/[id]/route.ts guards getAuthorisedClient(['admin','editor']) (route.ts:45–46, returns { success } — checked via auth.success, failures via authFailureResponse(auth)), validates transitions, and inserts a content_history row with change_type='edit' (route.ts:746–762) inside a best-effort try that logs via logBestEffortWarn and adds a warnings entry on failure. No edit_intent is stamped. The DB trigger content_history_auto_version() handles version numbering; the route also pre-computes nextVersion (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/apino user-direct PATCH/PUT route exists today. q_a_pair_history is trigger-written (q_a_pairs_history_trigger() AFTER UPDATE on q_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 retired kb_pipeline). source_documents.storage_path text NOT NULL (…20260416102457…:4037; flow.py:1189/1975 "storage_path": rel_path). rel_path is 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). .md is a first-class content suffix (_NON_FORM_SUFFIXES = {".md",".txt",".html"}, flow.py:1350) — confirming the {59.1} §4.1 finding that a sidecar .md would mint a junk content_item. There is no original_path column.
  • Safe access + envelopes. tryQuery() (lib/supabase/safe.ts:160) and warningsEnvelope() (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…:3485id, 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).


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.sql
ALTER 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-only slice (RESEARCH §4.4). No REVOKE/SET search_path clauses (no new function; column-only ALTER). RLS is unchanged — content_history keeps its existing policies; this column inherits them.
  • No pipeline coupling. content_history is app-written, never a flow.py write target (verified: flow.py write targets are content_items/source_documents/content_chunks/q_a_extractions/ entity_mentions, NOT content_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}‘s content_history retention disposition, which a new write-only column adds nothing to).
  • Type regen after staging push: regenerate supabase/types/database.types.ts per the CLAUDE.md command so Tables<'content_history'>['Insert'] carries the new optional fields (the items route casts its insert to Database['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:

lib/edit-intent/arbitrate.ts
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 structuraldata. 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:

  1. Resolve the on-disk absolute path: join COCOINDEX_SOURCE_PATH (the flow binds it, flow.py:2877) with source_documents.storage_path (the source-relative POSIX rel_path). Use tryQuery() to read storage_path — no raw client call.
  2. 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).
  3. Stamp the merged edit_intent in the DB leg (PC-7) so the next walk’s §8.2 re-classification policy is honoured (cosmetic skips; data re-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.) The content_history insert’s existing best-effort posture is acceptable for the history row but the primary content_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.md R1/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 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 (the content_items row + content_history row with edit_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), so app/api/q-a-pairs/[id]/route.ts is 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 sibling TECH-qa-*.md specs (numbering collides across this directory). UC6 user-direct is a greenfield write route in app/api (verified: no q_a_pairs PATCH/PUT route today). Add app/api/q-a-pairs/[id]/route.ts (PATCH) that:

  • Guards via the Q&A surface’s role guard (PC-19), uses tryQuery() for the q_a_pairs UPDATE.
  • The q_a_pair_history snapshot is written by the EXISTING q_a_pairs_history_trigger() AFTER UPDATE — no app-side history insert.
  • Resolves + stamps edit_intent identically to PC-7. q_a_pair_history has no edit_intent column 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 function q_a_pairs_history_trigger() must copy a NEW q_a_pairs.edit_intent column 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 one NEW.edit_intent/OLD.edit_intent copy line and re-asserts SET search_path = public, extensions + keeps its REVOKE 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 new components/item-detail/revision-diff-view.tsx (mirror the adjacent prompt-diff-view.tsx pattern). Default selection = the latest two versions.
  • Data: read two content_history rows (existing app/api/items/[id]/history/route.ts + .../history/[versionId]/route.ts); for Q&A read 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 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 new edit_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.css per 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_intent is stored; lands independently of PC-13. source_document_diffs untouched.
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.

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.

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.


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.

InvariantVerification
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.

  • 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 /walk reconciling on content_text_hash; document the residual window. (Spike#3 owns the final adapter contract.)
  • Sidecar .md hazard is designed-out, not merely avoided. The edit_intent lives in content_history precisely so no new file class enters the frozen walked layout (flow.py:1350 .md is content). Any future “file-co-located revision artefact” is v1.1 and MUST add a pre-walk skip rule.
  • Schema parity (prod ↔ staging). The ALTER lands staging-first; prod push is Liam-gated. Run schema-parity.yml after the staging push; do NOT let the column drift between envs before cutover.
  • q_a_pair_history needs its OWN edit_intent column + 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 live q_a_pairs column 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 NULL should 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.)
  • publicRoutes allowlist. The new app/api/q-a-pairs/[id] route is authenticated (not public) — it does NOT go in proxy.ts publicRoutes; confirm it is correctly behind auth.

  1. q_a_pair_history.edit_intent slice (PC-A4): the migration must add edit_intent to BOTH q_a_pairs (live) and q_a_pair_history (snapshot) and add a copy line to q_a_pairs_history_trigger(). Confirm this rides the SAME pre-cutover {64.7}/{64.8} window as the content_history slice (recommended — same zero-cost, write-only-forward profile).
  2. OQ-59-3 prod sweep (bl-266): authorise the source_document_id IS NULL prod sweep before the UC1/UC4 file leg lands, to size the source-less population the PC-3 guard logs and bl-266 enforces.
  3. 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.
  4. Prod DDL push remains Liam-gated — this Task applies NO migration to prod (staging-first only).

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.