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 inlib/query/query-keys.ts),bun run test(neverbun test), behaviour-first tests (reference/test-philosophy.md). New PL/pgSQL (if RE-HOME):SET search_path = public, extensions+ explicitREVOKE EXECUTE … FROM anon.
1. Context
Section titled “1. Context”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 enginelib/source-documents/document-diff.ts(computeFullTextDiff@332–483,computeDocumentDiff@506–655, module Source-documents, processesproc_8_post/proc_147_post/proc_148_post/proc_149_post“POST → …”); the legacy diff pageapp/documents/[id]/diff/page.tsx(DocumentDiffPage@141–364); the impact enginelib/source-documents/source-document-impact.ts(analyseDocumentImpact@39–152); the render-engine seamscomponents/item-detail/revision-diff-view.tsx+ the two callers (CompareVersionsPanel,QARevisionHistory); the query seamslib/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 TWO —CompareVersionsPanel(components/item-detail/version-history.tsx) andQARevisionHistory(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 ONE —app/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 tosource_document_diffsat:67–77(read) and:140–143(writeaffected_content_item_id), and tosb()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 thediffpackage) → unified view with[+]/[-]gutters +[Added]/[Removed]labels, semantic tokens (OP_CLASS:139→bg-status-success/10/bg-status-error/10),formatDateTime:172(en-GBDD/MM/YYYY HH:mm),RevisionBlobinterface @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):useQueriestwofetchItemHistoryVersioncalls → builds twoRevisionBlobs @130–155 →<RevisionDiffView />@173. Readscontent_historyonly (header comment :88–90). - Caller 2 —
QARevisionHistory(qa-revision-history.tsx:64–):toRevisionBlob:45mapsq_a_pair_historyrows (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.tstype :496, read :647, assign :691;lib/mcp/tools/content.tsget_document_diffdef @1962,.from(...)@1994/2118;source-document-history.tsx:256compare link (/documents/{id}/diff,GitCompareArrows:259);app/api/source-documents/[id]/diff/route.tsGET/POST/PATCH (importscomputeDocumentDiff: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 isdocuments(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 atcomponents/reader/pdf-viewer.tsx:4import { 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*/@extendABSENT frompackage.json;components.jsonhas noregistriesblock (onlystyle/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.
Cluster A — common revision abstraction
Section titled “Cluster A — common revision abstraction”| PRODUCT inv | Proposed 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). ViewDepth → RecordKind 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 inv | Proposed 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 inv | Proposed 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 inv | Proposed 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 inv | Proposed 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 inv | Proposed 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).
- 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 fromsource_documents/content_items— both areON DELETE CASCADE/SET NULL, no orphan risk).- No new function → no
search_path/REVOKEclause needed for the RETIRE path.
- 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. - Type regen: after
db push, regeneratesupabase/types/database.types.tspersupabase/CLAUDE.md(thesource_document_diffsrow type disappears;export-user-data.ts:496type ref must already be removed or the regen breaks the build — sequence deletions BEFORE regen). - api-view regen: remove
'source_document_diffs'fromgenerate-api-views.ts:118, thenbun scripts/generate-api-views.ts→ emits the newapi.view-set migration WITHOUT the dropped view. - Re-vendor step (INV-16 — DO NOT SKIP): the
api.view-set change tripstask-view-vendor-driftschema-parity. Re-vendor the task-view schema per thetask-view-vendor-driftrunbook reminder (scripts/re-vendor path) so the side-workflows pass. This is a discrete PLAN slice.
- GDPR export: remove
source_document_diffsfromexport-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]4. Open-question dispositions
Section titled “4. Open-question dispositions”- OQ-117-3 (markdown source) — RESOLVED (TECH). Canonical markdown depth =
content_items.contentviacontent_history(the content_item adapter /fetchItemHistoryVersion).extracted_textis 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_textis 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.tsadapter synthesises:changeType:'reingest'for non-initial versions,'initial_ingest'forversion === 1/ noparent_id;editIntent:null;changeSummary:null;createdByLabel= display name resolved fromsource_documents.created_by(fallback'System'/'Unknown'per the existinglabelForpattern).RevisionMetaalready omits nulleditIntent(: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]/diffroute; 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_itemsis flat, workspace-less, write-once, has NOreference_history, so it has no two-revision pair to diff (RESEARCH §7). Documented future seam only; no ID-117 work.
5. Testing and validation
Section titled “5. Testing and validation”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-formedUnifiedRevisionfrom 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.tstest: a re-ingest fixture →changeType:'reingest',editIntent:null;version===1fixture →'initial_ingest';RevisionMetarenders no edit-intent line and'No description'for null summary. - INV-5/6/7/8 —
binary-diff-pane.tsxcomponent tests: PDF/DOCX/XLSXmime_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-urlroute test: unauthorised →authFailureResponse; unreadable row → no signed URL; success → 300s URL on thedocumentsbucket. - INV-9/10/11 — content_item adapter reads
content_history(notextracted_text);renderMode='side-by-side'/'word-inline'render the richer view;diffWordsword-highlighting present; semantic tokens + empty state inherited. - INV-12 (regression — MUST PASS UNCHANGED) — the existing
revision-diff-viewtest suite passes with no edits;CompareVersionsPanel/QARevisionHistoryrender byte-identical default output. - INV-13 — assert
OP_CLASSuses onlybg-status-*semantic tokens (no raw Tailwind colour);formatDateTimeemits DD/MM/YYYY; explicit loading/error/empty states. - INV-14/15/16 — post-drop:
grepcorpus forsource_document_diffsreturns zero non-migration hits (ast-dataflowstring-literal-uses+ a Python/SQL grep sweep per.ast-dataflow/CLAUDE.md);bun run buildpasses (no dangling type ref);generate-api-views.tsoutput excludes the view; GDPR export runs without referencing the table; re-vendor leavestask-view-vendor-drift/schema-paritygreen (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/editoronly); workspace-scoping test (cannot fetch/sign a cross-workspace revision); no AI-feature labelling in rendered copy.
6. Risks and mitigations
Section titled “6. Risks and mitigations”- 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_diffsis 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 writingaffected_content_item_idonsource_document_diffsrows). Clean C2 drop. - RE-HOME variant (contained addendum — what changes if Liam picks re-home):
- KEEP
lib/source-documents/source-document-impact.ts(theanalyseDocumentImpact/findMatchingContentItemmatching logic) but re-target its persistence: instead of writingaffected_content_item_idto the dropped table, compute impact on demand OR persist to a minimal new review table (NOT the fullsource_document_diffs— only{revision_pair, affected_content_item_id, status, reviewed_at, reviewed_by, reviewer_note}). A new review table needs a migration WITHSET search_path = public, extensionson any helper function + explicitREVOKE EXECUTE … FROM anon, plus RLS predicates (workspace-scoped). - ADD an apply/dismiss affordance onto
UnifiedDiffContainerfor the source_document depth, re-using theuse-diff-review.tsmutation shape (statusmutation :93) re-pointed at the new route; gateadmin/editorviaauthFailureResponse(auth)(INV-15/18). - The
api.view-set then GAINS the new review table (not loses one) → re-vendor still required.
- KEEP
- 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.
- RETIRE (primary, designed above): §3 as written + delete
- 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 presentradix-ui ^1.4.3; no hard blocker vs React 19.2.6 / Next 16.2.6). Carried as a {117.4} install slice (registerextend-hq/uiincomponents.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 workingreact-pdfviewer; DOCX/XLSX without a viewer → INV-6 text fallback (graceful, never blank). RevisionBlob→UnifiedRevisionwiden 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). Rungitnexus_impact('RevisionDiffView', upstream)before the widen.- Type-regen ordering trap. Deleting the table before removing
export-user-data.ts:496’s type reference breaksbun run buildon regen. §3 sequences deletions BEFORE regen — {117.4} must keep that order within the drop slice. proxy.tsallowlist. The newbinary-urlroute is auth-gated → it MUST stay OFFpublicRoutes(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 / dependency | Pinned version | Method | Result | Date |
|---|---|---|---|---|
extend-hq/ui registry — PDF/DOCX/XLSX viewers | n/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.1 | package.json:118 + working import components/reader/pdf-viewer.tsx:4 | PRESENT (PDF fallback for the binary leg) | 21/06/2026 |
diff diffWords | ^9.0.0 | package.json:97 + live use version-diff.tsx:18 | PRESENT (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.
8. New OQs raised for {117.4} PLAN
Section titled “8. New OQs raised for {117.4} PLAN”- OQ-117-7 (PDF viewer choice): extend-ui PDF Viewer vs the existing
react-pdfviewer for the binary leg’s PDF path. Lower-risk default: reusereact-pdffor 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.