Skip to content

ID-147 {147.3} TECH — Procurement UI foundation: Extend registry install + component wiring + attachment-store shape + form-first route re-key ruling (v1)

ID-147 {147.3} TECH — Procurement UI foundation

Section titled “ID-147 {147.3} TECH — Procurement UI foundation”

Type: TECH ({N.3}). Implementation plan for the Procurement-domain UI foundation whose behaviour is fixed by {147.2} PRODUCT (§A1–§K3). Fresh Planner context (not the {147.2} author). Authored 15/07/2026. UK English throughout (DD/MM/YYYY, “colour”, “organisation”). Track: /procurement (ca-s435-procurement-baseline); ledger writes on MAIN only, nothing committed here.

Substrate (read in full before this spec): PRODUCT.md (53 numbered invariants, Checker-gated PASS_WITH_NOTES), id-147-empirical-grounding.md (round-2, all 6 OQs resolved, empirical §12), RESEARCH.md ({147.1}). Load-bearing rulings cited, not re-litigated: DR-063 (adoption posture), DR-064 (bbox geometry), DR-065 (Schema Builder → custom), DR-066 (Editor/E-Signature v1), DR-067 (custom stepper), DR-068 (multi-doc hybrid + attachment store), DR-040 (Streamdown), DR-061 (procurement is single-tenant — no cross-tenant scope), DR-014 (manual-upload forms).

What this spec owns. The five ID-147-shipped mechanics (registry install; attachment-store shape; geometry plumbing; citation-coordinate derivation; custom stepper / editors / forked e-signature) plus the binding ruling on the open S470 templates/[templateId] route-family question (§6). What it does not own: the ID-145.19 item-page wiring (this spec is the foundation {145.19} consumes) and the ID-145 W1-gating route re-key landing (this spec RULES the target shape; the ID-145 re-key wave lands it — §6).

{147.2} PRODUCT fixes the user-visible behaviour of the Procurement document + markdown UI foundation across eleven surface groups (§A multi-document item page … §K scope/gating). This TECH translates that behaviour into: (1) the first extend-hq/ui shadcn-registry install and the vendor-in policy (icons, primitives, theming); (2) a new reference/evidence attachment store; (3) the fill-slot geometry plumbing (un-drop commonforms coords + a geometry column); (4) the bounding-box citation coordinate-derivation path; (5) the custom stepper, custom requirement/question editors, DOCX/XLSX editor wiring, and the forked e-signature surface; (6) a ruling on the form-first re-key of the procurement/[id]/* route family; (7) the migration plan + sequencing against the held ID-145 W1 batch.

Current state (verified in-tree, 15/07/2026)

Section titled “Current state (verified in-tree, 15/07/2026)”

Table names are post-W1c; database.types.ts is STALE. The {145.6} W1c rename (supabase/migrations/20260712062000_id145_w1c_rename_reshape.sql, this branch) renamed nearly every form table but the type regen is held behind the W1 staging push; supabase/types/database.types.ts is Read-denied by design and still on the pre-rename names. This spec cites migrations for shape, per the CLAUDE.md stale-types idiom. Authoritative post-W1c map (empirical grounding §1):

ConceptPost-W1c tableWasLoad-bearing columns
The item (item is the form, §A1)form_instancesform_templatesprocessing_status (was status), workflow_state (10-state), reference_number, estimated_value, engagement_group_id (nullable, ON DELETE SET NULL), ingest_source ∈ {app_upload,minted}, mime_type ∈ {docx,xlsx,pdf}; workspace_id DROPPED (W1c STEP 1)
Mechanical fill-slots (§C)form_instance_fieldsform_template_fieldsfield_type, placeholder_text, table_index/row_index/col_index (logical cell coords, not geometry), mapping_status, fill_status, form_instance_id FK (new, ON DELETE CASCADE)
Answerable questions (§H2)form_questionsform_questionsquestion_text, word_limit, evaluation_weight, status, assigned_to, form_instance_id FK (NOT NULL); workspace_id DROPPED (W1c STEP 4)
Answers (§H2)form_responsesresponse_text, review_status, overall_score, version
Requirement catalogue (§H1)form_requirement_templatesform_template_requirementsrequirement_type, requirement_text, matching_keywords[], matching_guidance, is_mandatory, word_limit_guidance, section_ref, sector_applicability[]; embedding in record_embeddings (DR-036)
Engagement link (§A3)engagement_groupsnew (W1c STEP 6)id, name; a LINK, no state, no data scoping (BI-27/28)
Citations (§D)citationscited_text, cited_start/cited_end, cited_location_kind ∈ {block,char,page}text-span, NO bbox column; q_a_pair kind dormant until BI-37
Completion tracking (§C, fill)template_completionstemplate_idform_instance_id (W1c STEP 5)

Extend + Streamdown install state. components.json (verified): style:new-york, baseColor:neutral, cssVariables:true, iconLibrary:lucide, no registries block. package.json (verified): no @extend/hugeicons/@coss/base-ui dependency at all; present and relevant — streamdown ^2.5.0, react-pdf ^10.4.1, remark-gfm ^4.0.1, react-markdown ^10.1.0, lucide-react ^0.575.0, parsers docx ^9.6.1/docx-preview ^0.3.7/mammoth ^1.12.0/xlsx ^0.18.5 (parsers/converters, not viewers), @react-pdf/renderer ^4.5.1 (generation), @tiptap/react 3.23.4.

Code-intelligence orientation (verbatim, 15/07/2026)

Section titled “Code-intelligence orientation (verbatim, 15/07/2026)”

Per .gitnexus/CLAUDE.md / .ast-dataflow/CLAUDE.md. Outputs quoted, not paraphrased, so the Checker can verify the orientation step.

  • Templates route family (the §6 ruling target). gitnexus_query({repo:'canonical', query:'templates templateId route handler form_templates fields fill completions download'})processes:[], definitions only (no indexed execution flow — these are leaf API handlers): templates/route.ts:POST(50-235) + GET(241-326); templates/[templateId]/route.ts:GET(21-233) + DELETE(239-333); templates/[templateId]/fields/[fieldId]/route.ts:PATCH(13-97); templates/[templateId]/fields/bulk-update/route.ts:POST(13-97); templates/[templateId]/completions/[completionId]/download/route.ts:GET(13-100); templates/[templateId]/fill/route.ts:POST(19-236); components/procurement/template-fill-progress.tsx:TemplateFillProgress(24-125). Direct reads confirm every group-B handler gates on form_templates.workspace_id (dropped by W1c) and every child read keys on .eq('template_id', …) (renamed to form_instance_id).
  • PDF viewer (the §C/§D overlay host). gitnexus_context({repo:'canonical', name:'PdfDocument', file_path:'components/reader/pdf-document.tsx'})Function components/reader/pdf-document.tsx:PdfDocument (53-358); incoming:{} (leaf — no indexed callers); outgoing.calls:[goToPage]; processes:[]. It already owns page navigation (goToPage), the hook the §C/§D “scroll-to-and-highlight” correspondence needs.
  • Workflow transition helper (§G3, §6 group-C consolidation). gitnexus_context({repo:'canonical', name:'canTransition'})"Symbol 'canTransition' not found" (not indexed as a standalone symbol). Direct reads locate it at lib/domains/procurement/procurement-workflow.ts, consumed by app/api/procurement/[id]/outcome/route.ts:105 and app/api/procurement/[id]/route.ts:386 — the two duplicate workflow-transition writers §6 consolidates.
  • Group-D requirement-catalogue readers (§6 mechanical rename). gitnexus_query({repo:'canonical', query:'form templating template coverage requirement catalogue readers form_template_requirements'})processes:[]; definitions scripts/calibrate-coverage-thresholds.ts:fetchRequirements(113-188), app/api/cron/content-gaps/route.ts:GET(48-357). Combined with the 145.23 S470/S474 journal: lib/domains/procurement/form-templating/template-coverage.ts, lib/content/content-suggestions.ts, and the S474 delta scripts/export-user-data.ts:695.
  • Spatial citation/fill-slot overlay (greenfield). Per PRODUCT Problem + RESEARCH §3 code-intel: gitnexus_query for citation/bounding-box/document-viewer returned no UI execution flows (processes:[]; pipeline-side Python + definitions only). gitnexus orientation: no existing spatial-overlay symbols match — greenfield surface. The only citation UI today, components/source-document-detail/document-citations-panel.tsx, is a text-only list (0 rows).
  • Streamdown reuse pattern. components/okf/file-render-pane.tsx:37 (import { Streamdown, type Components } from 'streamdown') + lib/okf/prepare-streamdown-content.ts (normaliseInternalMdLinksForStreamdown, INTERNAL_LINK_MARKER shim over rehype-harden).
  • Incumbent react-markdown (§I2 migrate target). components/item-detail/content-renderer.tsx (react-markdown + remark-gfm, hasMarkdown() gate, slugify()/createHeadingComponents() heading-id injection, prose classes, plain-text fallback) — the only react-markdown site in scope.

Per the pre-ratification empirical-verification discipline (external-library symbols only).

Symbol / claimPinned versionMethodResult
streamdown Streamdown + components propstreamdown ^2.5.0in live use at file-render-pane.tsx:37,169-171; import-and-render confirmed in-treePRESENT
react-pdf viewer + TextLayer/getClientRects (§D B1)react-pdf ^10.4.1in live use at components/reader/pdf-document.tsx:PdfDocument; TextLayer CSS presentPRESENT
remark-gfm GFM tables/lists (§I2 no-regression)remark-gfm ^4.0.1in live use; Streamdown bundles GFM nativelyPRESENT
Extend HighlightArea = %-0–100, top-left origin(unshipped)grounding §12 fetched bounding-box-citations.tsx sourceCONFIRMED (grounding)
commonforms geometry = normalised [0,1] top-left; /Rect = points bottom-left(unshipped)grounding §12 + direct read pdf.py:354-366 (ftop/fbottom flip computed, raw bbox stored)CONFIRMED
Extend @extend/* registry + @extend-ai/react-docx/react-xlsx runtime deps(ABSENT by design)package.json grep — no @extend/hugeicons/coss presentABSENT — expected

ABSENT is not a blocker. Extend is an unshipped registry (RESEARCH §8, PRODUCT empirical note, DR-063). Its import-and-call verification is the first executor subtask of the install slice (§1 below), which MUST run an import-and-render smoke check against each vendored component + the two runtime render engines before wiring — recorded there, not here. No SIGNATURE_DRIFT / BEHAVIOUR_DRIFT surfaced against the PRESENT deps.

1. First extend-hq/ui registry install slice (DR-063; §J1/§J2/§J3/§K3)

Section titled “1. First extend-hq/ui registry install slice (DR-063; §J1/§J2/§J3/§K3)”

ID-147 owns the first registry install (DR-063; ID-117 {117.4} diverged and never landed it — verified: no registries block, no @extend dep). The slice, in order:

  1. Register the registry. Add a registries block to components.json mapping the @extend/* namespace to the extend-hq/ui shadcn registry (per grounding §2 distribution model — copy-in source, no npm runtime dependency on Extend itself). Keep iconLibrary:lucide, baseColor:neutral, cssVariables:true unchanged.
  2. Import-and-call verification (executor subtask 1 — the deferred §Empirical check). Before any wiring, shadcn add one viewer and run an import-and-render smoke test; confirm the two runtime render engines (@extend-ai/react-docx, @extend-ai/react-xlsx) resolve at the pinned versions shadcn add pulls. Record PRESENT/ABSENT/SIGNATURE_DRIFT with date + version. On ABSENT/DRIFT → STOP and escalate.
  3. Vendor-in the v1 component set (source lands under a domain subdir, never components/ root — components/CLAUDE.md). v1 set, mapped to the surfaces that consume them:
    • PDF Viewer (§B2, host for §C/§D overlays), DOCX Viewer + XLSX Viewer + CSV Viewer (§B1/§B3/§B4 — the genuine gap; pull @extend-ai/react-docx/react-xlsx runtime deps),
    • Bounding Box Citations / HumanReviewPanel (§C/§D overlay primitive),
    • File Upload + PDF Dropzone shell (§E),
    • Document Viewer Sidebar + File Thumbnail (§A9 navigation; we supply thumbnails),
    • DOCX/Excel Editor (§F1, experimental), E-Signature block (§F3, to be forked — see §5). File System / Finder is NOT installed (DR-068 rejects it as the top-level surface). Record vendor-in provenance per installed component — component name + extend-hq/ui registry version (or commit) + add date — in a checked-in manifest, so later upstream drift against the copy-in source is auditable (copy-in has no npm version to diff against).
  4. Vendor-in policy, applied per component at add time (DR-063, decide before the first add):
    • Icons — re-point Hugeicons → lucide in every vendored component (§J1: one icon family in shared chrome). lucide-react ^0.575.0 is already the tree default.
    • Primitives — re-wire shared chrome to components/ui (Radix-shadcn), our app default (§J2). Self-contained document viewers MAY retain their Coss UI internals (rarely themed, seen only inside the viewer). Do not add a second app-wide primitive layer.
    • Theming — install as-is first (§B5/§J3, DR-063): white document backgrounds are the accepted v1 state, not a defect. Re-point vendored source to bare Warm Meridian semantic tokens (--background, --foreground, …; never raw Tailwind colours — components/CLAUDE.md F3) incrementally, not on day one. The DOCX/XLSX viewers’ required isDark + onIsDarkChange props (§B4) bind to the app theme context regardless.
  5. Backend binding (DR-063 — Extend ships zero backend). No new upload backend. Every callback (onFilesAccepted, editor persist, e-signature persist) binds to our existing hardened path (§E2, §5).

2. Reference/evidence attachment store (DR-068; §A5/§A6/§A7)

Section titled “2. Reference/evidence attachment store (DR-068; §A5/§A6/§A7)”

Residual shape choice, ruled here. The form-owns-its-primary-document half ships zero-schema (§A5): form_instances.filename/storage_path/mime_type + the existing tender-documents/<form_id>/ storage listing (app/api/procurement/[id]/route.ts:229-250). The added labelled reference/evidence attachment (a CV) at form OR engagement level (§A6) needs a new store. New table (migration <ts>_id147_form_attachments.sql, sequenced AFTER W1 push + type-regen — §7):

CREATE TABLE public.form_attachments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
form_instance_id uuid REFERENCES public.form_instances(id) ON DELETE CASCADE,
engagement_group_id uuid REFERENCES public.engagement_groups(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('form_source','reference_evidence')),
filename text NOT NULL,
storage_path text NOT NULL,
mime_type text,
file_size bigint,
created_by uuid REFERENCES public.user_profiles(id),
created_at timestamptz NOT NULL DEFAULT now(),
-- Exactly one scope is set: a form-level OR an engagement-level attachment (§A6).
CONSTRAINT form_attachments_scope_xor CHECK (
(form_instance_id IS NOT NULL)::int + (engagement_group_id IS NOT NULL)::int = 1
),
-- A form_source is always form-scoped: an engagement has no form source of its own,
-- so role='form_source' with only engagement_group_id set is incoherent (§F3's
-- signed-PDF write targets role='form_source' on a form_instance).
CONSTRAINT form_attachments_form_source_scoped CHECK (
role = 'reference_evidence' OR form_instance_id IS NOT NULL
)
);
CREATE INDEX idx_form_attachments_form_instance ON public.form_attachments (form_instance_id);
CREATE INDEX idx_form_attachments_engagement ON public.form_attachments (engagement_group_id);
  • §A7 delete semantics fall out of the FKs. Form-level attachment → ON DELETE CASCADE on form_instance_id (the form owns it). Engagement-level attachment → ON DELETE CASCADE on engagement_group_id (the engagement-level link + its attachment go together). Because form_instances.engagement_group_id is ON DELETE SET NULL (W1c STEP 1), deleting an engagement never touches a form or its own documents — exactly §A7.
  • Storage-object cleanup owner (the FK CASCADE gap). The FK ON DELETE CASCADE removes the form_attachments row but leaves the underlying tender-documents storage object orphaned — a Postgres cascade cannot reach Supabase Storage. Cleanup ownership is explicit: (1) a best-effort storage remove() in the DELETE API path (the group-A DELETE and any attachment-DELETE route) is the primary mechanism, and (2) a periodic orphan-sweep (reconcile form_attachments rows against the bucket listing) is the backstop for rows removed by CASCADE rather than through the API. Both are part of the {145.19}-wave implementation contract — this spec fixes the ownership; {145.19} lands it.
  • role distinguishes FORM SOURCE vs REFERENCE / EVIDENCE for the §A5 two-group split (a text label, never colour alone — §J4). The primary form document stays zero-schema; the store carries only added documents. The form_attachments_form_source_scoped CHECK guards role/scope coherence: role='form_source' can exist only form-scoped (with a form_instance_id set) because an engagement has no form source of its own; only a role='reference_evidence' attachment may be engagement-scoped.
  • RLS mirrors the W1c engagement_groups posture (verified in the migration): SELECT TO authenticated USING (true) (form-first “any authenticated member may read”); INSERT/UPDATE/DELETE gated on get_user_role() = ANY('admin','editor') (§F4/§H3 admin/editor-gated mutation, BI-47); no anon table grant (the stricter posture engagement_groups uses). No new functions → no search_path/anon-EXECUTE work.
  • Storage reuses the hardened tender-documents bucket (§E2): form-level → tender-documents/<form_id>/attachments/<uuid>-<filename>; engagement-level → tender-documents/engagement/<engagement_group_id>/<uuid>-<filename>.
  • API surface. Write: POST /api/procurement/[id]/attachments (form-level; role in body) binds onFilesAccepted to the hardened BI-9 backend (magic-byte sniff, 50 MB cap, rate-limit — unchanged). Engagement-level attach carries an engagement_group_id body param. Read: folded into the group-A GET (§6) — form_attachments WHERE form_instance_id = :id OR engagement_group_id = :form.engagement_group_id, split by role for §A5. TanStack Query keys in lib/query/query-keys.ts; sb()/tryQuery() only.

3. Fill-slot geometry plumbing — Option A (DR-064; §C1/§C2/§C3/§C4)

Section titled “3. Fill-slot geometry plumbing — Option A (DR-064; §C1/§C2/§C3/§C4)”

The un-drop is smaller than grounded: pdf.py already computes the top-left flip (the residual work is rotation/MediaBox normalisation + carrying the field through, below).

  • scripts/cocoindex_pipeline/form_extractors/pdf.py (verified :299,330-373): page_heights is in hand (:299,334); the detection loop already computes ftop = page_height - y1, fbottom = page_height - y0 (:355-356) but stores only the raw bottom-left bbox=(x0,y0,x1,y1) (:366). The current height/mediabox read (:334-335) assumes an unrotated page at a MediaBox origin of (0,0) — a /Rotate 90|180|270 page, or a non-zero MediaBox origin (llx/lly), yields a misplaced box, violating §C4’s “never a misaligned box”. Change: capture page_width alongside page_height, the MediaBox origin, and page.rotation, then normalise into displayed (post-rotation) top-left page-fraction space — offset the bbox by the MediaBox origin, apply the page-rotation transform, then divide by the displayed page dimensions — so the persisted fractions are already what the UI consumes (no rotation/flip math owed downstream). Add a geometry: dict | None field to PdfDetectedField: {left, top, width, height, page: page_number, rotation: page_rotation}left/top/width/height the displayed-space fractions, rotation ∈ {0,90,180,270} recorded for cross-check/audit. No y-flip or rotation owed downstream.
  • scripts/cocoindex_pipeline/form_extractors/orchestrator.py (verified :111-162): _pdf_result_to_extracted_form deliberately drops the richer geometry (:118-131), repurposing table_index ← page_number, row_index ← sequence (:151-152). Carry the new geometry through into ExtractedField (a new nullable field on the reader-package ExtractedField type) rather than dropping it; keep the table_index/row_index reading-order behaviour for the DOCX/XLSX path (no geometry there → §C4 degrade).
  • Migration <ts>_id147_form_instance_fields_geometry.sql (sequenced AFTER W1 push — §7): add geometry jsonb (nullable) to form_instance_fields. Nullable is load-bearing: DOCX/XLSX forms and any un-resolvable PDF field carry NULL → §C4 “list without spatial overlay, never a misaligned box”. jsonb carries no column CHECK, so shape integrity is enforced by a zod schema on read (see Writer + UI): a malformed or legacy geometry blob is treated as absent and degrades to §C4, never a box.
  • Writer + UI. The form-analyse worker (the analyse_form lane, W1c STEP 7 job_type) persists geometry on each form_instance_fields row. On read the UI validates the jsonb through a geometrySchema zod shape (left/top/width/height ∈ [0,1], page int ≥ 1, rotation ∈ {0,90,180,270}); a parse failure is treated as absent geometry → §C4 degrade, never a box. UI consumption: left/top/width/height × 100 = Extend HighlightArea (%-0–100, top-left, no flip — grounding §4a); render over PdfDocument’s page. Slot-list ↔ overlay linkage (§C2) uses PdfDocument.goToPage + a shared selection id; label/icon per slot (§C2/§C3, never colour-only). fill_status drives the per-slot status label (§C3).

4. Bounding-box citations — Option B (DR-064; §D1/§D2/§D3/§D4/§D5)

Section titled “4. Bounding-box citations — Option B (DR-064; §D1/§D2/§D3/§D4/§D5)”

citations has no geometry (text-span only), so coordinates are derived, not stored:

  • B1 — text-layer getClientRects (primary; §D2 exact path). Resolve the citation span (cited_text + cited_start/cited_end) to on-page rectangles against react-pdf’s rendered TextLayer inside PdfDocument; normalise rects to page-% → HighlightArea. Deterministic, exact, no API call; covers text-based PDFs (most UK procurement forms).
  • B2 — Claude vision coordinates (fallback; §D2 approximate path, scanned/flat/no-text-layer PDFs). Ask Claude for absolute pixel [x1,y1,x2,y2] top-left (never normalised); divide by the resized image dims → [0,1] → ×100 = HighlightArea (origin matches, no flip). MUST self-rasterise the pages client/worker-side and pre-resize — native PDF support rasterises server-side at dims we do not control, making those coords unmappable (grounding §4c; DR-064; platform.claude.com/docs/en/build-with-claude/vision-coordinates). Approximate — spot-checkable, hence fallback.
  • §D3 degrade / §D5 empty. Neither B1 nor B2 yielding a mappable region → render the citation as a text-anchored entry in the existing document-citations-panel.tsx, never a misplaced box. No citations → today’s honest 0-row empty state.
  • §D4 DOCX/XLSX citations stay text-anchored in document-citations-panel.tsx (grouped by target kind, text+icon, never colour-only) — out of spatial-overlay v1.

5. DOCX/Excel editor fill mechanism + forked e-signature (DR-066; §F1–§F5)

Section titled “5. DOCX/Excel editor fill mechanism + forked e-signature (DR-066; §F1–§F5)”
  • §F2 fill mechanism (the R5 residual, ruled here). Reuse the existing Claude-side draft/fill backend as the write path for a missing answer; the Extend DOCX/Excel Editor is the edit affordance over the rendered document, and “fill a missing answer” dispatches the existing draft lane (SSE draft-stream, form_responses write), not a bespoke in-editor persistence engine. Rationale: the answer-quality backend already exists and is where review/versioning lives; a second in-editor write path would fork answer state. Manual in-editor edits persist via the editor’s own save bound to the document’s storage_path.
  • §F3 e-signature fork. Extend’s E-Signature block exposes only file?:string (no callbacks/persistence/network — grounding §3/§12, DR-066). The fork adds: (a) drive signature-field placement from our data; (b) an onSigned persistence callback writing the completed signed PDF to storage (a form_attachments row, role='form_source', or the completion store as appropriate). Budget the fork explicitly — not a drop-in.
  • §F4 gating — edit/sign affordances admin/editor-only (getAuthorisedClient(['admin','editor']), BI-47); reviewer/viewer see read-only. §F5 — an editor that fails to initialise falls back to the read-only viewer for that document (soft error), never a blank pane.

6. Form-first re-key of the procurement/[id]/* route family — RULING (open S470 question)

Section titled “6. Form-first re-key of the procurement/[id]/* route family — RULING (open S470 question)”

The forcing architecture. W1c severs the form→workspace link: form_instances.workspace_id is DROPPED (STEP 1), form_questions.workspace_id DROPPED (STEP 4), template_idform_instance_id across children (STEP 2/5), q_a_pairs.source_workspace_id DROPPED (STEP 5). The workspace-holds-many-form_templates container is exactly the model ID-145 BI-1/BI-4 retires (“the item IS the form; no separate bid container”). Every procurement/[id]/* handler currently gates on form_templates.workspace_id = :id — the dropped column — which is precisely why the W1 push is gated (145.23 S470 journal, groups A–D). DR-061 confirms these routes are single-tenant (no cross-tenant scope) — the only scoping question is intra-DB form-instance keying, which form_instances_select RLS (USING(true)) + PK-scoped child queries make safe.

Canonical target: [id] = form_instances.id (the item IS the form). The templates/[templateId] nesting collapses because [id] now is the form, making [templateId] redundant. The v1 “one-form-per-workspace” invariant made [id]=workspace tolerable; form-first makes [id]=form_instance canonical. The existing code already anticipates this — [id]/route.ts:355-357: “Multi-form umbrellas gain a form id in the path in a later Subtask; v1 has exactly one form.” (Whether the URL param literally carries the form_instance UUID or a transitional workspace-alias is the ID-145 re-key executor’s mechanical cutover call; the RULING is that the routes’ reads/writes become form_instance-keyed and the container retires.)

Per-route ruling + ownership (ID-147 rules the shape; the ID-145 W1-gating re-key wave lands it):

RouteVerdictTarget shapeOwner
(A) procurement/[id]/route.ts GETRE-KEY + RE-SHAPERead form_instances by PK (not workspaces+application_types). DROP get_procurement_rollup RPC (:163) + child-forms list (:186-205) — no container, owner S470 = NO stored roll-up. KEEP tender-documents/<form_id>/ listing (:229-250, §A5 FORM SOURCE) + get_form_question_stats (already body-fixed to form_instance_id, W1c STEP 9). ADD form_attachments read (§A5 REFERENCE/EVIDENCE) + engagement sibling-rail read (§A3, when engagement_group_id set).{145.19} (the §A item-detail data surface; resolves the “UNOWNED group-A orphan” of 145.23)
(A) procurement/[id]/route.ts PATCH / DELETERE-KEYPATCH writes form_instances.workflow_state/outcome/deadline/issuing_organisation directly on the PK — drop the “locate the workspace’s single form” indirection (:354-373). DELETE removes the form_instances row + its storage.{145.19}
(B) templates/route.ts GET+POSTRETIREThe workspace-holds-many-form_templates collection is the container BI-1/BI-4 kills. Form creation = the BI-9 upload path (§E3); template listing = group-A GET’s docs+attachments (§A5). The templates storage bucket + form_templates-as-sub-template writes retire with it.ID-145 re-key wave (item-2)
(B) templates/[templateId]/route.ts GETRE-KEY + RE-PATHprocurement/[id]/fieldsThe §C fill-slot-review data surface: form_instance_fields + matched form_questions + template_completions + get_template_summary (re-key p_template_id → form_instance). Fold into group-A detail or a dedicated [id]/fields read. DELETE half subsumed by group-A DELETE.{145.19}
(B) fields/[fieldId] PATCH + fields/bulk-update POSTRE-KEY + RE-PATHprocurement/[id]/fields/*.eq('template_id', templateId).eq('form_instance_id', :id); drop the workspace-belongs verify. The §C mapping-review writes (mapping_status).{145.19} (split option: a dedicated ID-145 field-CRUD re-key subtask if {145.19} over budget)
(B) completions/[completionId]/download GETRE-KEY + RE-PATH → form_instance-keyedtemplate_completions.template_idform_instance_id; drop the workspace-belongs verify; keep the 5-min signed-URL.{145.15} (the fill-writer lane owns completions production + download)
(B) templates/[templateId]/fill POST{145.15}, in flight — DO NOT RE-SCOPEAlready re-keyed by the fill-writer lane this session.{145.15}
(C) forms/route.ts, outcome/route.ts, outcome/integrate/route.tsRE-SHAPE (not mechanical)forms/route.ts writes form_templates.workspace_id (:94,:193 — dropped): re-shape “add-a-sibling-form” to engagement_group_id-keyed, or retire if sibling creation is not v1 (§A3 rail is read-only lineage; sibling creation is a separate affordance, absent from §A). outcome/route.ts re-keys [id]→form_instance PK + drops the workspace→single-form indirection + form_questions.form_template_idform_instance_id (:221); it duplicates the [id] PATCH transition logic — consolidate into one form_instance transition writer (the §G3 stepper transition backend). outcome/integrate/route.ts re-points form_questions.workspace_idform_instance_id (:108, dropped) + q_a_pairs.source_workspace_idsource_form_instance_id (:227, dropped).ID-145 re-key wave (item-2, groups C)
(D) form_requirement_templates readersMECHANICAL RENAMEform_template_requirementsform_requirement_templates: app/api/cron/content-gaps/route.ts, lib/domains/procurement/form-templating/template-coverage.ts, lib/content/content-suggestions.ts, scripts/calibrate-coverage-thresholds.ts, + S474 delta scripts/export-user-data.ts:695.ID-145 re-key wave (item-2, group D)

This ruling settles the S470 open question and is proposed as a DR-intent (§Ledger intents) so a future session does not re-litigate it. Scope boundary: ID-147 does not land the group A–D re-key; it provides the ruling that unblocks the W1 push and the target shape the {145.19}/{145.15}/ID-145 re-key executors implement. The pre-push grep-gate (145.23: form_templates/form_template_fields/form_template_requirements/get_procurement_rollup == 0 live hits) remains the authoritative close-gate; scripts/ is tsconfig-EXCLUDED so the grep (not ast-dataflow) is authoritative there (S474 caveat).

7. Custom stepper + custom editors + Streamdown migration (DR-067/DR-065/DR-040)

Section titled “7. Custom stepper + custom editors + Streamdown migration (DR-067/DR-065/DR-040)”
  • §G custom stepper. A small custom Warm Meridian component (lucide + semantic tokens) over the 10-state workflow_state machine — NOT AI-SDK Elements workflow (DR-067). Transition control offers only canTransition-valid next states (§G3); invalid jumps refused with a surfaced reason. State + deadline + outcome each carry a text label/icon, never colour-only (§G2/§G4, WCAG 2.1 AA). It reads the {145.18} shape via the procurement context provider (145.19 details).
  • §H custom editors. Custom requirement-catalogue editor over form_requirement_templates’s domain fields (§H1) and a custom question/answer-slot editor over form_questions/form_responses (§H2) — Schema Builder is not used (DR-065; no domain metadata). Catalogue writes admin/editor-gated (§H3); Schema Builder reserved, never offered (§H4).
  • §I Streamdown. New Procurement markdown surfaces render via Streamdown natively (§I1); streamed answers use the streaming caret + partial-parse (§I4 — DR-040 “new surface”, no migration). The §I2 incumbent migration of content-renderer.tsx (react-markdown → Streamdown) folds into {145.19} (owner ruling): preserve heading-id injection (Streamdown components prop, as file-render-pane.tsx uses it), GFM parity (Streamdown bundles GFM — no remark-gfm regression), the prose styling, and the plain-text fallback; reuse normaliseInternalMdLinksForStreamdown so internal relative links survive rehype-harden (§I3). Rendered output must not visibly regress (§I2).

Behaviour-first (test-philosophy.md); bun run test (never bun test); shared Supabase mock. Each PRODUCT invariant maps to a concrete check.

One-to-one invariant → proposed-change mapping (Checker contract, all 53):

InvariantProposed change (§)Verification
§A1 item-is-form§6 group-A GET reads form_instances by PKGET returns the form-as-page header fields; no bid container
§A2 custom frame not Finder§1 (Finder NOT installed)no File System/Finder import in tree
§A3 engagement rail when grouped§6 group-A GET sibling-rail readrail rendered iff engagement_group_id set; ungrouped → none
§A4 grouping is a link§6 (no roll-up, read-only lineage)grouped vs ungrouped form URL/state/questions identical; no engagement roll-up
§A5 Documents tab split by role§2 role + §6 group-A docs+attachments readtwo labelled groups (FORM SOURCE / REFERENCE-EVIDENCE), text label not colour
§A6 labelled attachment v1§2 form_attachments + POST attachattach→label→appears in correct group at form/engagement level
§A7 delete engagement ≠ delete form docs§2 FK ON DELETE semanticsdelete engagement leaves forms + form docs intact
§A8 progressive disclosure§6 group-A GET (conditional rail/split)ungrouped, no attachments → simple list
§A9 select document previews§1 Sidebar + Thumbnail (we supply thumbnails)selecting a doc opens the viewer; thumbnails present
§B1 viewer matches type§1 PDF/DOCX/XLSX/CSV viewerseach type renders in its viewer; consistent per type
§B2 PDF standard rendered viewer§1 Extend PDF Viewer (host PdfDocument)PDF renders inline with zoom/toolbar
§B3 DOCX/XLSX real viewers§1 (@extend-ai/react-docx/react-xlsx).docx/.xlsx render formatted, not raw/download-only
§B4 viewers respect theme§1 isDark+onIsDarkChange bound to app themeviewer light/dark follows app theme
§B5 backgrounds as-is first§1 install-as-is themingwhite doc backgrounds accepted; no day-one re-theme
§B6 explicit loading/error§1 viewer states (UK English, non-colour)loading + soft error + retry, never blank
§B7 unsupported → download fallback§1 fallback affordanceunknown/corrupt type → “cannot preview” + download
§C1 fill-slots overlay PDF§3 geometry column + HighlightAreadetected slots box-overlaid page-accurately on PDF
§C2 slot list ↔ overlay linked§3 goToPage + shared selection idselect slot→scroll/highlight box; select box→select slot; label/icon
§C3 fill status per slot§3 fill_status → status labelper-slot status text/icon, not colour alone
§C4 PDF-only, DOCX/XLSX degrade§3 nullable geometry (rotation-normalised; zod-on-read)DOCX/XLSX → list without overlay + note; /Rotate page + malformed geometry → degrade, never a misaligned box
§D1 PDF citations overlay§4 B1/B2 → HighlightAreacitation renders as highlighted region; bidirectional select
§D2 exact primary, approx fallback§4 B1 text-layer / B2 visiontext PDF exact; scanned approximate, not surfaced as error
§D3 unresolvable → text entry§4 degradeunmappable coords → text-anchored entry, never misplaced box
§D4 DOCX/XLSX text-anchored§4 (existing panel)DOCX/XLSX citations in document-citations-panel.tsx, grouped, text+icon
§D5 honest empty state§4 (0-row)no citations → honest empty state
§E1 upload client validation§1 File Upload/Dropzone shelltype/size/count/dup validated inline pre-submit
§E2 hardened backend unchanged§1/§2 backend bindingonFilesAccepted → BI-9 backend (magic-byte, 50 MB, rate-limit); no new backend
§E3 upload creates form-first item§6 (BI-9 upload path)uploading a form creates the item; no regression
§E4 progress/success/honest reject§1 upload statesprogress+success; rejection shows backend’s actual reason (UK English)
§F1 in-place edit/fill v1§5 editor + Claude-side fillDOCX/XLSX edit + fill-missing-answer affordance; persists
§F2 fill mechanism ruled§5 (reuse draft backend)filling a missing answer dispatches the draft lane; form_responses write
§F3 e-signature drives+persists§5 fork (onSigned)signature fields appear, sign, signed PDF persisted
§F4 edit/sign admin/editor-gated§5 getAuthorisedClient(['admin','editor'])reviewer/viewer read-only
§F5 editor degrades read-only§5 soft error fallbackfailed editor → read-only viewer, never blank
§G1 linear 10-state custom stepper§7 custom Warm Meridianstepper over workflow_state; not AI-SDK workflow
§G2 never colour-only§7 label/icon per statecompleted/current/upcoming carry text/icon
§G3 only valid transitions§7 canTransitioninvalid jump refused with surfaced reason
§G4 deadline/outcome non-colour§7 label/iconoverdue/terminal never colour-alone
§H1 custom requirement editor§7 over form_requirement_templatesdomain fields exposed; Schema Builder not used
§H2 custom question editor§7 over form_questions/form_responsesslot fields exposed; Schema Builder not used
§H3 catalogue writes gated§7 admin/editorreviewer/viewer read-only
§H4 Schema Builder reserved§7 (not offered)not present in requirement/question surfaces
§I1 all markdown via Streamdown§7 Streamdownnew surfaces render via Streamdown
§I2 incumbent migrates (in {145.19})§7 content-renderer.tsx→Streamdownnamed rendered-output snapshot over the markdown corpus: headings/links/lists/bold/GFM/blockquote/code no visible regression
§I3 internal links survive hardening§7 normaliseInternalMdLinksForStreamdownrelative links resolve; external links hardened + full URL shown
§I4 streamed answers native§7 (DR-040 new surface)streaming caret; partial/unterminated markdown renders clean
§J1 one icon family (lucide)§1 Hugeicons→lucide re-pointno mixed icon families in shared chrome
§J2 shared chrome matches app§1 re-wire shared chrome to components/uiviewers may keep Coss UI internals
§J3 semantic tokens only§1 bare semantic tokensno raw Tailwind colours (components/CLAUDE.md F3)
§J4 WCAG 2.1 AA all surfacesall §§ non-colour-only + UK Englishno meaning by colour alone across surfaces
§K1 procurement baseline onlyscope (no wider-platform)no Intelligence/Guides/Proposals surfaces designed
§K2 chain gates {145.19}§6/§7 (foundation {145.19} consumes)§A frame/§B-D/§G/§I2 land in {145.19} on this foundation
§K3 ID-147 owns first install§1 registry installregistries block added; copy-in source vendored

Test lanes. (1) Route re-key (§6) — behaviour tests that group-A GET returns the form-first shape (no roll-up, no child-forms), asserted against the shared Supabase mock post-rename; the pre-push grep-gate == 0 live hits is the ID-145 close-gate, not an ID-147 test. (2) Attachment store (§2) — RLS + XOR-constraint + ON DELETE behaviour (§A6/§A7). (3) Geometry (§3) — pdf.py normalise unit test (known /Rect + page dims → expected normalised-top-left), plus a rotated-page fixture (a /Rotate 90 page at a non-zero MediaBox origin → expected displayed-space top-left, guarding §C4’s “never a misaligned box”), + orchestrator carry-through; python3 -m pytest scripts/tests/ from the worktree CWD (namespace-package hazard — scripts/CLAUDE.md). A geometrySchema zod read-validation test asserts a malformed blob degrades to §C4 (no box). (4) Citation derivation (§4) — B1 rect-normalisation unit test; B2 self-rasterise + resize-divide unit test. (5) Stepper/editors/Streamdown (§7) — behaviour + non-colour-only assertions; §I2 verified by a named stored rendered-output snapshot fixture over a representative markdown corpus (§I2’s enumerated categories: headings, links, lists, bold, GFM tables, blockquotes, code) — the corpus is rendered through the incumbent react-markdown path and stored as the baseline, and the Streamdown output asserted against it. The exact corpus-fixture name is fixed in the {145.19} dispatch brief (where the §I2 migration lands).

  • STALE database.types.ts (Read-denied). All post-W1c code guidance cites migrations, not types; the geometry + attachment migrations sequence AFTER the W1 push + type-regen so they are typed against regenerated types (§7 sequencing). Do not hand-edit types (supabase/CLAUDE.md).
  • Route re-key gates the W1 push. §6 is the ID-145 item-2 gating surface (groups A–D). If ID-147 ships UI against form-first shapes before the re-key lands, the surfaces 42P01/42703. Mitigation: ID-147’s UI foundation is built but {145.19} wiring (which consumes it) lands after the re-key wave clears the grep-gate — the DR-intent + this ruling exist precisely to unblock that ordering.
  • Extend experimental editors (§5). DOCX/Excel Editor + E-Signature are experimental; the import-and-call gate (§1 step 2) de-risks early, and §F5 degrades to read-only.
  • B2 self-rasterisation (§4). Skipping self-raster → unmappable coords (native PDF server-side raster). Mitigation: the spec mandates self-raster + pre-resize; B2 is fallback-only, spot-checkable.
  • New API routes + publicRoutes. POST /api/procurement/[id]/attachments is authenticated — ensure it is NOT added to proxy.ts publicRoutes (it needs auth), and that it uses authFailureResponse(auth) on the discriminated union.
  • Migration DDL via CLI only (supabase migration new + db push, foreground; verify supabase/.temp/project-ref first — supabase/CLAUDE.md), never MCP apply_migration.
  • §6 group-C consolidation: outcome/route.ts duplicates the [id] PATCH transition logic — consolidate into one form_instance transition writer during the ID-145 re-key wave.
  • forms/route.ts sibling-creation: decide at re-key time whether “add a sibling form” is v1 (engagement_group_id-keyed) or retires — §A3 rail is read-only lineage; sibling creation is not in §A.
  • bl-459 (DOCX/XLSX spatial fill, PDF-convert-then-revert) and bl-460 (Anthropic caching + structured-output) remain out of scope — do not pull in.

End of TECH — ID-147 {147.3}. Output: specs/id-147-extend-streamdown-ui-foundation/TECH.md. Not committed here. Maps 1:1 to PRODUCT §A1–§K3; carries the install slice, attachment-store DDL, geometry plumbing, citation derivation, and the S470 route-family ruling. Gates ID-145.19.