Skip to content

ID-117 {117.3} TECH — Unified document/revision diff surface (UnifiedRevision adapter + extend-RevisionDiffView render modes + extend-ui binary leg + source_document_diffs retirement)

ID-117 {117.3} TECH — Unified document/revision diff surface

Section titled “ID-117 {117.3} TECH — Unified document/revision diff surface”

Inputs (read in full): {117.2} PRODUCT.md (20 Behavior invariants, clusters A–F) and {117.1} RESEARCH.md (CORRECTED S384). This TECH maps one Proposed change per PRODUCT invariant (§2) and is authored by a FRESH Planner (Q-PLANNER-2) who did NOT write PRODUCT.md — the 1:1 mapping below is the cross-review pass against PRODUCT.

Fresh-Planner PRODUCT cross-review verdict: PRODUCT.md is coherent and implementable; no defect blocks TECH. The two OQs that TECH resolves (OQ-117-3, -4) are resolved below; OQ-117-5/-6 dispositions carried; OQ-117-1 remains ESCALATED TO LIAM and is a hard pre-migration gate (the RETIRE path is designed as primary, the RE-HOME variant as a contained addendum — §2 cluster E and §6).

Quality bars inherited by every Executor slice: semantic Warm Meridian tokens only (no raw Tailwind colours), UK English, DD/MM/YYYY, getAuthorisedClient() + authFailureResponse(auth), sb() / tryQuery() from @/lib/supabase/safe, no barrel re-exports (direct file imports), TanStack Query exclusively (keys in lib/query/query-keys.ts), bun run test (never bun test), behaviour-first tests (reference/test-philosophy.md). New PL/pgSQL (if RE-HOME): SET search_path = public, extensions + explicit REVOKE EXECUTE … FROM anon.

ID-117 builds ONE unified diff/revision surface comparing two snapshots of one logical record at three view-depths (binary / canonical-markdown / user-edit) over a common revision abstraction, and retires the 0-row source_document_diffs engine. It extends the landed source-agnostic RevisionDiffView engine (ID-59 {59.12}) rather than replacing it — see PRODUCT.md for behaviour.

Code-intelligence orientation (S384 + S385 re-verified, repo:'canonical', cited verbatim):

  • gitnexus_query('source_document_diffs document-diff RevisionDiffView content_history') → legacy compute engine lib/source-documents/document-diff.ts (computeFullTextDiff @332–483, computeDocumentDiff @506–655, module Source-documents, processes proc_8_post/proc_147_post/proc_148_post/proc_149_post “POST → …”); the legacy diff page app/documents/[id]/diff/page.tsx (DocumentDiffPage @141–364); the impact engine lib/source-documents/source-document-impact.ts (analyseDocumentImpact @39–152); the render-engine seams components/item-detail/revision-diff-view.tsx + the two callers (CompareVersionsPanel, QARevisionHistory); the query seams lib/query/fetchers.ts (fetchItemHistoryList @108–115, fetchItemHistoryVersion @118–125) + lib/query/query-keys.ts (sourceDocuments.diff @170–171, sourceDocuments.history @172–173).
  • gitnexus_context('RevisionDiffView')components/item-detail/revision-diff-view.tsx @228–282 (module Item-detail). incoming.calls: exactly TWOCompareVersionsPanel (components/item-detail/version-history.tsx) and QARevisionHistory (components/qa/qa-revision-history.tsx). outgoing.calls: RevisionMeta, cn. processes: [] (pure render component, 0 process membership).
  • gitnexus_impact('RevisionDiffView', upstream, summaryOnly)risk: LOW, impactedCount 5, direct 2, processes_affected 0, modules_affected 1 (Item-detail). Extending this engine is a LOW-risk render-layer change — the two callers are the entire blast radius.
  • gitnexus_context('computeDocumentDiff')incoming.calls: exactly ONEapp/api/source-documents/[id]/diff/route.ts:POST. The legacy engine has a single caller, so the retirement deletes a self-contained subtree.
  • gitnexus_context('analyseDocumentImpact')incoming.calls: {} (none in graph) — invoked via a dynamic route path the graph does not trace; couples to source_document_diffs at :67–77 (read) and :140–143 (write affected_content_item_id), and to sb() from @/lib/supabase/safe. This is the impact-matching logic the RE-HOME variant would preserve and the RETIRE path deletes.

Verified current-state facts (file:line, S385, worktree read):

  • RevisionDiffView (revision-diff-view.tsx:228–282) computes a bespoke LCS line-diff (buildLcsTable:70, computeLineDiff:94 — does NOT use the diff package) → unified view with [+]/[-] gutters + [Added]/[Removed] labels, semantic tokens (OP_CLASS:139bg-status-success/10 / bg-status-error/10), formatDateTime:172 (en-GB DD/MM/YYYY HH:mm), RevisionBlob interface @31–46 (version,text,changeType,changeSummary,createdAt, createdByLabel,editIntent). Empty state @274–280 (data-testid="revision-diff-empty").
  • Caller 1 — CompareVersionsPanel (version-history.tsx:92–174): useQueries two fetchItemHistoryVersion calls → builds two RevisionBlobs @130–155 → <RevisionDiffView /> @173. Reads content_history only (header comment :88–90).
  • Caller 2 — QARevisionHistory (qa-revision-history.tsx:64–): toRevisionBlob:45 maps q_a_pair_history rows (text: answer_standard, changeType: origin_kind, changeSummary: null) → <RevisionDiffView /> @149.
  • Legacy retire-list couplings (each verified S385): generate-api-views.ts:118 ('source_document_diffs' in the mirrored-table array); export-user-data.ts type :496, read :647, assign :691; lib/mcp/tools/content.ts get_document_diff def @1962, .from(...) @1994/2118; source-document-history.tsx:256 compare link (/documents/{id}/diff, GitCompareArrows:259); app/api/source-documents/[id]/diff/route.ts GET/POST/PATCH (imports computeDocumentDiff :13); hooks/use-diff-review.ts (useDiffReview:41, status mutation :93 — the review-workflow hook).
  • Binary-leg substrate: source_documents.{storage_path,mime_type,version,parent_id, extracted_text} PRESENT (squash_baseline.sql:7460) — no new column needed. Storage bucket is documents (app/api/upload/route.ts:378, storage_path = ${itemId}/${filename} @366). Signed-URL pattern is established: serviceClient.storage.from(bucket).createSignedUrl( path, ttlSeconds)app/api/procurement/[id]/tender/download/route.ts:66–68 (300s), app/api/items/[id]/images/route.ts:353 (3600s).
  • Installed primitives (pinned, S385): react-pdf ^10.4.1 (working client viewer at components/reader/pdf-viewer.tsx:4 import { Document, Page } from 'react-pdf'), diff ^9.0.0, mammoth ^1.12.0 (server-only), exceljs 4.4.0 (server-only), @tiptap/markdown 3.23.4, radix-ui ^1.4.3. No client DOCX or XLSX viewer — the gap extend-ui-kit fills. React 19.2.6, Next 16.2.6. extend* / @extend ABSENT from package.json; components.json has no registries block (only style/aliases) → extend-hq/ui not yet registered.

Guide refs (not reproduced): .gitnexus/CLAUDE.md, .ast-dataflow/CLAUDE.md, components/CLAUDE.md (semantic tokens), supabase/CLAUDE.md (migration/DDL discipline), reference/test-philosophy.md.

1.1 The common abstraction (the spine of every proposed change)

Section titled “1.1 The common abstraction (the spine of every proposed change)”

A new TYPE + adapter layer (no DB change beyond the drop). Lives in a new module lib/diff/unified-revision.ts (NEW; module Diff), generalising the landed RevisionBlob:

// lib/diff/unified-revision.ts (NEW)
export type RecordKind = 'content_item' | 'qa_pair' | 'source_document';
export type ViewDepth = 'user-edit' | 'canonical-markdown' | 'binary';
export type RenderMode = 'unified-line' | 'side-by-side' | 'word-inline' | 'binary-split';
export interface UnifiedRevision {
recordKind: RecordKind;
recordId: string;
version: number;
text: string; // diffable projection: content | answer_standard | extracted_text
changeType: string; // change_type | origin_kind | synthesised 'reingest' (source_document)
changeSummary: string | null;
createdAt: string; // ISO; rendered DD/MM/YYYY
createdByLabel: string;
editIntent: string | null; // null for source_document (no edit_intent column)
binary?: { // binary leg only
storagePath: string;
mimeType: string;
};
}

RevisionBlob (the landed type) becomes a structural subset of UnifiedRevision — the two existing callers continue to pass blob-shaped objects unchanged (INV-12). binary and recordKind are additive optionals, so this is a non-breaking widen.

2. Proposed changes — one per PRODUCT invariant

Section titled “2. Proposed changes — one per PRODUCT invariant”

Mapping is 1:1 against PRODUCT.md’s 20 numbered invariants. Compound invariants get one Proposed change that names its sub-parts. File:line anchors are from §1.

PRODUCT invProposed change
INV-1 (one surface, two revisions, one record)The UnifiedDiffContainer (NEW, components/diff/unified-diff-container.tsx) and every adapter take exactly { older, newer }: UnifiedRevision for ONE recordId. No code path accepts >2 revisions or mixed recordId. Adapters (§INV-2) enforce same-record by construction (both blobs derived from one record’s history fetch).
INV-2 (three depths over two substrates)New lib/diff/adapters/content-item-revision.ts (content_history → UnifiedRevision; wraps existing fetchItemHistoryVersion), qa-pair-revision.ts (q_a_pair_history → UnifiedRevision; lifts toRevisionBlob logic from qa-revision-history.tsx:45), source-document-revision.ts (source_documents version chain → UnifiedRevision incl. binary). ViewDepthRecordKind resolution: binary→source_document, user-edit→content_item|qa_pair, canonical-markdown→content_item (per INV-9). Canonical-markdown is a renderMode over the content_item substrate, NOT a third adapter.
INV-3 (computed on demand, never stored)No new diff-storage table. Diffs computed client-side in the render engine (INV-10/12 path) exactly as computeLineDiff does today. The ONLY DB change is the source_document_diffs DROP (§INV-14). Inherits ID-59 INV-14.
INV-4 (provenance panel per revision)Extend RevisionMeta (revision-diff-view.tsx:184) to read from UnifiedRevision. For source_document revisions (no change_type/change_summary/edit_intent columns) the source-document-revision.ts adapter synthesises changeType:'reingest' (initial version → 'initial_ingest'), changeSummary:null, editIntent:null, createdByLabel = uploading user resolved via created_by. RevisionMeta already omits editIntent when null (:219) and renders changeSummary ?? 'No description' (:213) — no fabrication. OQ-117-4 RESOLVED (see §4).

Cluster B — binary source diff (extend-ui-kit, LOCKED v1)

Section titled “Cluster B — binary source diff (extend-ui-kit, LOCKED v1)”
PRODUCT invProposed change
INV-5 (renders actual files side-by-side)New components/diff/binary-diff-pane.tsx: composes two extend-ui file-viewer instances (PDF/DOCX/XLSX, chosen by mimeType) side-by-side, each fed a short-lived signed URL (INV-8). Replaces the legacy text-only /documents/[id]/diff page (deleted §INV-14). extend-ui supplies the client DOCX/XLSX rendering the repo lacks; PDF may use either extend-ui’s PDF Viewer or the existing react-pdf viewer (components/reader/pdf-viewer.tsx) — Executor picks the lower-risk path at install time.
INV-6 (format coverage + fallback)binary-diff-pane.tsx switches on mimeType over the v1 set (PDF/DOCX/XLSX). For an unsupported mime_type, a fetch/render failure, or a signed-URL failure (INV-8), it falls back to the canonical-markdown/text comparison (§INV-10 engine) over the two extracted_text projections, with an explicit inline notice (semantic-token Alert). Never a blank panel, never a silent drop.
INV-7 (v1 = visual-compare-only + alongside text summary)v1 binary-diff-pane.tsx renders the two binary viewers for visual eyeballing (synchronised page/scroll where the viewer supports it) with NO inline change-marking on the binary render, and renders the leg-2 text line-diff (RevisionDiffView over the two extracted_text blobs) alongside the viewers. The change-overlay (v1.1) is explicitly NOT built. OQ-117-2′ defaulted in PRODUCT; carried to {117.4} for Liam confirmation.
INV-8 (signed-URL gated, workspace-scoped)New route app/api/source-documents/[id]/binary-url/route.ts (GET): getAuthorisedClient()authFailureResponse(auth) on failure; verify the requesting user can read the source_documents row (RLS-scoped read via sb()); mint a 300s signed URL via serviceClient.storage.from('documents').createSignedUrl(storage_path, 300) (matching the tender-download pattern). MUST be added to proxy.ts publicRoutes? NO — it is auth-gated, so it stays OFF the allowlist (the allowlist is for PUBLIC routes only). A signed-URL failure returns a structured error the pane maps to the INV-6 fallback.

Cluster C — canonical-markdown revision diff

Section titled “Cluster C — canonical-markdown revision diff”
PRODUCT invProposed change
INV-9 (source = content_items.content via content_history)The canonical-markdown depth resolves to the content_item adapter (content-item-revision.ts) reusing fetchItemHistoryVersion (fetchers.ts:118) — content_history of content_items.content. source_documents.extracted_text is used ONLY as the binary-leg text fallback (INV-6), never as the markdown depth. OQ-117-3 RESOLVED (see §4).
INV-10 (side-by-side + inline word-level highlighting)New renderMode: 'side-by-side' and 'word-inline' added to revision-diff-view.tsx. Word-level highlighting reuses the live diffWords primitive from version-diff.tsx:18 (the diff package, already pinned ^9.0.0); line-level reuses the existing LCS (computeLineDiff:94). This is the richer view ID-59 INV-15 deferred — built here by combining the two existing primitives behind a renderMode prop.
INV-11 (shares user-edit engine + presentation invariants)The markdown depth is the SAME RevisionDiffView engine with renderMode='side-by-side'|'word-inline'; it inherits OP_CLASS semantic tokens (:139), the empty state (:274), and formatDateTime (:172). No separate engine.

Cluster D — in-platform user-edit diff (preserved)

Section titled “Cluster D — in-platform user-edit diff (preserved)”
PRODUCT invProposed change
INV-12 (landed engine preserved, not replaced)RevisionDiffView keeps its current default behaviour: renderMode defaults to 'unified-line', so CompareVersionsPanel and QARevisionHistory (which pass no renderMode) render byte-identical output. The widen to UnifiedRevision is structurally compatible with the RevisionBlob props (§1.1). Regression gate: existing revision-diff-view tests must pass unchanged.
INV-13 (shared presentation invariants, all depths)Centralised in revision-diff-view.tsx: OP_CLASS (semantic tokens, :139), OP_PREFIX non-colour gutters (:146), formatDateTime (en-GB, :172), empty state (:274). Binary pane (INV-7) and side-by-side mode (INV-10) reuse these constants — no per-depth re-implementation. Loading/error states explicit in each container (the CompareVersionsPanel isLoading/isError pattern @157–171 is the template).

Cluster E — retirement of source_document_diffs (RETIRE primary; RE-HOME addendum)

Section titled “Cluster E — retirement of source_document_diffs (RETIRE primary; RE-HOME addendum)”
PRODUCT invProposed change
INV-14 (legacy storage + text-only surface retired)RETIRE path (primary, §6). Drop migration removes the table + api.source_document_diffs view + GRANTs. Delete: lib/source-documents/document-diff.ts, app/api/source-documents/[id]/diff/route.ts, app/documents/[id]/diff/page.tsx, components/source-document/{source-document-diff-review,diff-highlighted-text}.tsx, hooks/use-diff-review.ts, the get_document_diff MCP tool (content.ts:1962), and the compare link (source-document-history.tsx:256). HARD GATE: blocked on OQ-117-1 (§6).
INV-15 (no review workflow regresses silently)RETIRE: remove the compare link (source-document-history.tsx:256) and the entire review UI so no dangling apply/dismiss affordance survives. RE-HOME (addendum §6): the apply/dismiss affordance moves onto UnifiedDiffContainer for source_document depth, gated admin/editor via getAuthorisedClient() + authFailureResponse(auth). Either way: no orphaned half-wired control.
INV-16 (cleans every coupling incl. non-UI + re-vendor)RETIRE: remove 'source_document_diffs' from generate-api-views.ts:118 (the mirrored-table array) and remove the GDPR-export coupling (export-user-data.ts :496/:647/:691). Then re-run bun scripts/generate-api-views.ts + regenerate types (supabase/CLAUDE.md) + re-vendor the task-view schema — see §3 migration step 5. The task-view-vendor-drift / schema-parity side-workflows fire on the api. view-set change; the re-vendor step neutralises them. This is an explicit migration step, not an afterthought.

Cluster F — entry points, roles, scoping

Section titled “Cluster F — entry points, roles, scoping”
PRODUCT invProposed change
INV-17 (entry-point / IA — OQ-117-5)TECH default (carried, NOT locked — {117.4} PLAN input): binary leg at a source-document-scoped route app/documents/[id]/diff rebuilt to host UnifiedDiffContainer (binary depth) — same URL the retired page used, so source-document-history.tsx’s compare link survives if RE-HOME, or is removed if RETIRE; markdown/user-edit depths stay inline in version-history.tsx / qa-revision-history.tsx. ALL three depths back onto the one shared engine regardless. OQ-117-5 carried to {117.4}.
INV-18 (roles)Diff surface (all depths) is read-only, visible to any role that can read the record’s version history (inherits ID-59 INV-19; the existing history routes already enforce this). Binary render requires the same read auth as reading the source document (INV-8 verifies the row read). RE-HOME write actions (apply/dismiss) → admin/editor via authFailureResponse(auth).
INV-19 (workspace scoping)Row-layer: the history/version fetchers run under the user’s RLS-scoped client (sb()/tryQuery()), so a user cannot fetch a revision they cannot read. Storage-layer: INV-8’s binary-url route verifies the source_documents row read BEFORE minting the signed URL — a user cannot sign a URL for a doc in a workspace they cannot reach.
INV-20 (AI-invisible-infrastructure)No depth labels any revision as an “AI feature”. Synthesised changeType:'reingest' (INV-4) and any RE-HOME similarity matching surface as ordinary revisions/actions (inherits ID-59 INV-21, ai-visibility-policy.md). No new UI copy frames AI participation.

3. Migration plan (RETIRE path — primary)

Section titled “3. Migration plan (RETIRE path — primary)”

Per supabase/CLAUDE.md: supabase migration new <name> + supabase db push (foreground — interactive CLIs hang background shells). NOT MCP execute_sql. The drop is data-safe (0 rows confirmed dev, § RESEARCH §8) but gated on OQ-117-1 client-DB confirmation (§6).

  1. Migration drop_source_document_diffs (NEW, supabase/migrations/<ts>_drop_source_document_diffs.sql):
    • DROP VIEW IF EXISTS api.source_document_diffs; (drop the view BEFORE the table; it depends on it).
    • DROP TABLE IF EXISTS public.source_document_diffs CASCADE; (CASCADE clears the FK from source_documents/content_items — both are ON DELETE CASCADE/SET NULL, no orphan risk).
    • No new function → no search_path / REVOKE clause needed for the RETIRE path.
  2. Code deletions (INV-14): delete the files listed in INV-14; remove imports. Run gitnexus_detect_changes() before commit to confirm only the expected subtree is touched.
  3. Type regen: after db push, regenerate supabase/types/database.types.ts per supabase/CLAUDE.md (the source_document_diffs row type disappears; export-user-data.ts:496 type ref must already be removed or the regen breaks the build — sequence deletions BEFORE regen).
  4. api-view regen: remove 'source_document_diffs' from generate-api-views.ts:118, then bun scripts/generate-api-views.ts → emits the new api. view-set migration WITHOUT the dropped view.
  5. Re-vendor step (INV-16 — DO NOT SKIP): the api. view-set change trips task-view-vendor-drift
    • schema-parity. Re-vendor the task-view schema per the task-view-vendor-drift runbook reminder (scripts/ re-vendor path) so the side-workflows pass. This is a discrete PLAN slice.
  6. GDPR export: remove source_document_diffs from export-user-data.ts (:496/:647/:691) so a post-Task export neither references nor errors on the dropped table.
flowchart TD
A[OQ-117-1 resolved by Liam] -->|RETIRE| B[drop migration + view drop]
A -->|RE-HOME| C[keep impact engine + add apply/dismiss to UnifiedDiffContainer]
B --> D[delete legacy files INV-14]
C --> D2[delete legacy UI but keep source-document-impact.ts + review route re-homed]
D --> E[regen types]
D2 --> E
E --> F[regen api-views, remove source_document_diffs]
F --> G[RE-VENDOR task-view schema INV-16]
G --> H[remove GDPR export coupling]
  • OQ-117-3 (markdown source) — RESOLVED (TECH). Canonical markdown depth = content_items.content via content_history (the content_item adapter / fetchItemHistoryVersion). extracted_text is legacy, used only as the binary-leg text fallback (INV-6). Rationale: post-ID-45 full-corpus re-ingest makes cocoindex (content_items.content) the dominant markdown path; extracted_text is the upload-path projection cocoindex never writes (RESEARCH §3.2). Matches PRODUCT INV-9 recommendation.
  • OQ-117-4 (binary-revision provenance) — RESOLVED (TECH). The source-document-revision.ts adapter synthesises: changeType:'reingest' for non-initial versions, 'initial_ingest' for version === 1 / no parent_id; editIntent:null; changeSummary:null; createdByLabel = display name resolved from source_documents.created_by (fallback 'System'/'Unknown' per the existing labelFor pattern). RevisionMeta already omits null editIntent (:219) and shows 'No description' for null summary (:213) — no missing-field fabrication. Matches PRODUCT INV-4.
  • OQ-117-5 (entry-point / IA) — CARRIED to {117.4} PLAN. TECH default: binary at the rebuilt /documents/[id]/diff route; markdown/user-edit inline. Flagged as the primary {117.4} decomposition input — it changes whether {117.4} has a “rebuild diff route” slice or a “remove diff route” slice.
  • OQ-117-6 (reference-page exclusion) — CONFIRMED OUT. reference_items / app/reference/[id] are OUT of scope: reference_items is flat, workspace-less, write-once, has NO reference_history, so it has no two-revision pair to diff (RESEARCH §7). Documented future seam only; no ID-117 work.

Behaviour-first per reference/test-philosophy.md; bun run test (never bun test); shared Supabase mock. Each PRODUCT invariant → a concrete check:

  • INV-1/2/3 — unit tests on lib/diff/unified-revision.ts + adapters: each adapter produces a well-formed UnifiedRevision from a fixture row; depth→recordKind resolution is correct; no path accepts >2 revisions. Assert no diff-storage write occurs.
  • INV-4 (+OQ-117-4) — source-document-revision.ts test: a re-ingest fixture → changeType:'reingest', editIntent:null; version===1 fixture → 'initial_ingest'; RevisionMeta renders no edit-intent line and 'No description' for null summary.
  • INV-5/6/7/8binary-diff-pane.tsx component tests: PDF/DOCX/XLSX mime_type → correct viewer; unsupported mime / signed-URL failure → INV-6 fallback notice (not blank); text summary rendered alongside; NO inline change-marking on the binary (INV-7). binary-url route test: unauthorised → authFailureResponse; unreadable row → no signed URL; success → 300s URL on the documents bucket.
  • INV-9/10/11 — content_item adapter reads content_history (not extracted_text); renderMode='side-by-side'/'word-inline' render the richer view; diffWords word-highlighting present; semantic tokens + empty state inherited.
  • INV-12 (regression — MUST PASS UNCHANGED) — the existing revision-diff-view test suite passes with no edits; CompareVersionsPanel/QARevisionHistory render byte-identical default output.
  • INV-13 — assert OP_CLASS uses only bg-status-* semantic tokens (no raw Tailwind colour); formatDateTime emits DD/MM/YYYY; explicit loading/error/empty states.
  • INV-14/15/16 — post-drop: grep corpus for source_document_diffs returns zero non-migration hits (ast-dataflow string-literal-uses + a Python/SQL grep sweep per .ast-dataflow/CLAUDE.md); bun run build passes (no dangling type ref); generate-api-views.ts output excludes the view; GDPR export runs without referencing the table; re-vendor leaves task-view-vendor-drift / schema-parity green (CI gate).
  • INV-17/18/19/20 — entry-point smoke (per {117.4} IA decision); role test (read-only visible to history-readers; RE-HOME writes admin/editor only); workspace-scoping test (cannot fetch/sign a cross-workspace revision); no AI-feature labelling in rendered copy.
  • OQ-117-1 is a HARD PRE-MIGRATION GATE (BLOCKER, escalated to Liam). The drop migration (§3) MUST NOT be authored until Liam (a) confirms source_document_diffs is 0 rows in the CLIENT DB too, and (b) decides RETIRE vs RE-HOME of the re-ingest review workflow. {117.4} PLAN must sequence the OQ-117-1 resolution as a gating prerequisite to the drop slice.
    • RETIRE (primary, designed above): §3 as written + delete source-document-impact.ts (its only purpose is writing affected_content_item_id on source_document_diffs rows). Clean C2 drop.
    • RE-HOME variant (contained addendum — what changes if Liam picks re-home):
      1. KEEP lib/source-documents/source-document-impact.ts (the analyseDocumentImpact / findMatchingContentItem matching logic) but re-target its persistence: instead of writing affected_content_item_id to the dropped table, compute impact on demand OR persist to a minimal new review table (NOT the full source_document_diffs — only {revision_pair, affected_content_item_id, status, reviewed_at, reviewed_by, reviewer_note}). A new review table needs a migration WITH SET search_path = public, extensions on any helper function + explicit REVOKE EXECUTE … FROM anon, plus RLS predicates (workspace-scoped).
      2. ADD an apply/dismiss affordance onto UnifiedDiffContainer for the source_document depth, re-using the use-diff-review.ts mutation shape (status mutation :93) re-pointed at the new route; gate admin/editor via authFailureResponse(auth) (INV-15/18).
      3. The api. view-set then GAINS the new review table (not loses one) → re-vendor still required.
    • Either way the unify legs (clusters A–D) are INDEPENDENT of OQ-117-1 and can proceed in parallel — only cluster E (the drop) is gated. {117.4} should sibling-order so the unify slices do not block on the gate.
  • extend-ui-kit ABSENT-but-registry-installable (NOT a TECH blocker). It is a shadcn-style registry install (npx shadcn@latest add @extend/<component> copies source in; MIT; no compiled peer-dep lock; consumes the present radix-ui ^1.4.3; no hard blocker vs React 19.2.6 / Next 16.2.6). Carried as a {117.4} install slice (register extend-hq/ui in components.json, add PDF/DOCX/XLSX viewers), NOT a TECH dependency. Empirical-verification block §7. Mitigation if a viewer fails to install: PDF falls back to the working react-pdf viewer; DOCX/XLSX without a viewer → INV-6 text fallback (graceful, never blank).
  • RevisionBlobUnifiedRevision widen regression. Mitigated by the INV-12 regression gate (existing tests pass unchanged) + the structural-subset design (§1.1) + LOW gitnexus_impact (2 callers, single module). Run gitnexus_impact('RevisionDiffView', upstream) before the widen.
  • Type-regen ordering trap. Deleting the table before removing export-user-data.ts:496’s type reference breaks bun run build on regen. §3 sequences deletions BEFORE regen — {117.4} must keep that order within the drop slice.
  • proxy.ts allowlist. The new binary-url route is auth-gated → it MUST stay OFF publicRoutes (common inverse mistake: adding an auth-gated route to the public allowlist).

7. Empirical verification (pre-ratification, OQ-3 — external-library symbols)

Section titled “7. Empirical verification (pre-ratification, OQ-3 — external-library symbols)”

Per .claude/agents/references/shared-discipline.md §Empirical verification. Only external-library symbols are in scope (internal Canonical symbols + stdlib excluded).

Symbol / dependencyPinned versionMethodResultDate
extend-hq/ui registry — PDF/DOCX/XLSX viewersn/a (shadcn-registry, source-copy; no version lock)RESEARCH §5/§8 (S384): package.json + node_modules grep + corpus grep + registry WebFetch; re-confirmed absence S385 (extend*/@extend ∉ package.json; no registries in components.json)ABSENT in repo, registry-installable — build prerequisite, {117.4} install slice. NOT spec-blocking ABSENT (S380-locked substrate). Network sandbox here does not whitelist extend.ai → carried forward from the dated RESEARCH/PRODUCT fetch; re-fetch at {117.4} install time.21/06/2026 (RESEARCH/PRODUCT)
react-pdf Document/Page^10.4.1package.json:118 + working import components/reader/pdf-viewer.tsx:4PRESENT (PDF fallback for the binary leg)21/06/2026
diff diffWords^9.0.0package.json:97 + live use version-diff.tsx:18PRESENT (word-inline render mode, INV-10)21/06/2026

No external-library symbol the v1 surface relies on returned ABSENT/SIGNATURE_DRIFT in a spec-blocking way. extend-ui-kit’s absence is an S380-locked build prerequisite, carried as a {117.4} install slice per PRODUCT’s Empirical-verification table.

  • OQ-117-7 (PDF viewer choice): extend-ui PDF Viewer vs the existing react-pdf viewer for the binary leg’s PDF path. Lower-risk default: reuse react-pdf for PDF (already working), use extend-ui only for DOCX/XLSX (the genuine gap). Confirm at {117.4} install slice.
  • OQ-117-8 (RE-HOME review-table shape): IF Liam picks RE-HOME, confirm the minimal review-table columns (§6) vs computing impact purely on demand (no new table). Depends on OQ-117-1 outcome.

End of TECH. Maps 1:1 to PRODUCT.md’s 20 invariants. OQ-117-3/-4 resolved; OQ-117-5/-6 carried/ confirmed; OQ-117-1 remains a hard pre-migration gate escalated to Liam (RETIRE primary, RE-HOME addendum §6). Re-vendor step explicit (§3 step 5, INV-16). No implementation, no DDL executed, no ledger writes, no canonical-code edits. Fresh-Planner cross-review: PRODUCT.md has no TECH-blocking defect.