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).
Context
Section titled “Context”What is being built
Section titled “What is being built”{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):
| Concept | Post-W1c table | Was | Load-bearing columns |
|---|---|---|---|
| The item (item is the form, §A1) | form_instances | form_templates | processing_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_fields | form_template_fields | field_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_questions | form_questions | question_text, word_limit, evaluation_weight, status, assigned_to, form_instance_id FK (NOT NULL); workspace_id DROPPED (W1c STEP 4) |
| Answers (§H2) | form_responses | — | response_text, review_status, overall_score, version |
| Requirement catalogue (§H1) | form_requirement_templates | form_template_requirements | requirement_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_groups | new (W1c STEP 6) | id, name; a LINK, no state, no data scoping (BI-27/28) |
| Citations (§D) | citations | — | cited_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_completions | — | template_id → form_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 onform_templates.workspace_id(dropped by W1c) and every child read keys on.eq('template_id', …)(renamed toform_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 atlib/domains/procurement/procurement-workflow.ts, consumed byapp/api/procurement/[id]/outcome/route.ts:105andapp/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:[]; definitionsscripts/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 deltascripts/export-user-data.ts:695. - Spatial citation/fill-slot overlay (greenfield). Per PRODUCT Problem + RESEARCH §3
code-intel:
gitnexus_queryfor 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 overrehype-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.
Empirical verification block (15/07/2026)
Section titled “Empirical verification block (15/07/2026)”Per the pre-ratification empirical-verification discipline (external-library symbols only).
| Symbol / claim | Pinned version | Method | Result |
|---|---|---|---|
streamdown Streamdown + components prop | streamdown ^2.5.0 | in live use at file-render-pane.tsx:37,169-171; import-and-render confirmed in-tree | PRESENT |
react-pdf viewer + TextLayer/getClientRects (§D B1) | react-pdf ^10.4.1 | in live use at components/reader/pdf-document.tsx:PdfDocument; TextLayer CSS present | PRESENT |
remark-gfm GFM tables/lists (§I2 no-regression) | remark-gfm ^4.0.1 | in live use; Streamdown bundles GFM natively | PRESENT |
Extend HighlightArea = %-0–100, top-left origin | (unshipped) | grounding §12 fetched bounding-box-citations.tsx source | CONFIRMED (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 present | ABSENT — 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.
Proposed changes
Section titled “Proposed changes”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:
- Register the registry. Add a
registriesblock tocomponents.jsonmapping the@extend/*namespace to theextend-hq/uishadcn registry (per grounding §2 distribution model — copy-in source, no npm runtime dependency on Extend itself). KeepiconLibrary:lucide,baseColor:neutral,cssVariables:trueunchanged. - Import-and-call verification (executor subtask 1 — the deferred §Empirical check). Before any
wiring,
shadcn addone 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 versionsshadcn addpulls. Record PRESENT/ABSENT/SIGNATURE_DRIFT with date + version. On ABSENT/DRIFT → STOP and escalate. - 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-xlsxruntime 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/uiregistry version (or commit) +adddate — 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).
- PDF Viewer (§B2, host for §C/§D overlays), DOCX Viewer + XLSX Viewer + CSV Viewer (§B1/§B3/§B4 —
the genuine gap; pull
- Vendor-in policy, applied per component at
addtime (DR-063, decide before the firstadd):- Icons — re-point Hugeicons → lucide in every vendored component (§J1: one icon family in
shared chrome).
lucide-react ^0.575.0is 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.mdF3) incrementally, not on day one. The DOCX/XLSX viewers’ requiredisDark+onIsDarkChangeprops (§B4) bind to the app theme context regardless.
- Icons — re-point Hugeicons → lucide in every vendored component (§J1: one icon family in
shared chrome).
- 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 CASCADEonform_instance_id(the form owns it). Engagement-level attachment →ON DELETE CASCADEonengagement_group_id(the engagement-level link + its attachment go together). Becauseform_instances.engagement_group_idisON 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 CASCADEremoves theform_attachmentsrow but leaves the underlyingtender-documentsstorage object orphaned — a Postgres cascade cannot reach Supabase Storage. Cleanup ownership is explicit: (1) a best-effort storageremove()in the DELETE API path (the group-A DELETE and any attachment-DELETE route) is the primary mechanism, and (2) a periodic orphan-sweep (reconcileform_attachmentsrows 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. roledistinguishes 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. Theform_attachments_form_source_scopedCHECK guards role/scope coherence:role='form_source'can exist only form-scoped (with aform_instance_idset) because an engagement has no form source of its own; only arole='reference_evidence'attachment may be engagement-scoped.- RLS mirrors the W1c
engagement_groupsposture (verified in the migration):SELECT TO authenticated USING (true)(form-first “any authenticated member may read”);INSERT/UPDATE/DELETEgated onget_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 → nosearch_path/anon-EXECUTE work. - Storage reuses the hardened
tender-documentsbucket (§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;rolein body) bindsonFilesAcceptedto the hardened BI-9 backend (magic-byte sniff, 50 MB cap, rate-limit — unchanged). Engagement-level attach carries anengagement_group_idbody 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 byrolefor §A5. TanStack Query keys inlib/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_heightsis in hand (:299,334); the detection loop already computesftop = page_height - y1,fbottom = page_height - y0(:355-356) but stores only the raw bottom-leftbbox=(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|270page, or a non-zero MediaBox origin (llx/lly), yields a misplaced box, violating §C4’s “never a misaligned box”. Change: capturepage_widthalongsidepage_height, the MediaBox origin, andpage.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 ageometry: dict | Nonefield toPdfDetectedField:{left, top, width, height, page: page_number, rotation: page_rotation}—left/top/width/heightthe 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_formdeliberately drops the richer geometry (:118-131), repurposingtable_index ← page_number,row_index ← sequence(:151-152). Carry the newgeometrythrough intoExtractedField(a new nullable field on the reader-packageExtractedFieldtype) rather than dropping it; keep thetable_index/row_indexreading-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): addgeometry jsonb(nullable) toform_instance_fields. Nullable is load-bearing: DOCX/XLSX forms and any un-resolvable PDF field carryNULL→ §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_formlane, W1c STEP 7job_type) persistsgeometryon eachform_instance_fieldsrow. On read the UI validates the jsonb through ageometrySchemazod shape (left/top/width/height∈ [0,1],pageint ≥ 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= ExtendHighlightArea(%-0–100, top-left, no flip — grounding §4a); render overPdfDocument’s page. Slot-list ↔ overlay linkage (§C2) usesPdfDocument.goToPage+ a shared selection id; label/icon per slot (§C2/§C3, never colour-only).fill_statusdrives 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 againstreact-pdf’s rendered TextLayer insidePdfDocument; 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_responseswrite), 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’sstorage_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) anonSignedpersistence callback writing the completed signed PDF to storage (aform_attachmentsrow,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_id→form_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):
| Route | Verdict | Target shape | Owner |
|---|---|---|---|
(A) procurement/[id]/route.ts GET | RE-KEY + RE-SHAPE | Read 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 / DELETE | RE-KEY | PATCH 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+POST | RETIRE | The 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 GET | RE-KEY + RE-PATH → procurement/[id]/fields | The §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 POST | RE-KEY + RE-PATH → procurement/[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 GET | RE-KEY + RE-PATH → form_instance-keyed | template_completions.template_id → form_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-SCOPE | Already re-keyed by the fill-writer lane this session. | {145.15} |
(C) forms/route.ts, outcome/route.ts, outcome/integrate/route.ts | RE-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_id→form_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_id→form_instance_id (:108, dropped) + q_a_pairs.source_workspace_id→source_form_instance_id (:227, dropped). | ID-145 re-key wave (item-2, groups C) |
(D) form_requirement_templates readers | MECHANICAL RENAME | form_template_requirements → form_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_statemachine — NOT AI-SDK Elementsworkflow(DR-067). Transition control offers onlycanTransition-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 overform_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 (Streamdowncomponentsprop, asfile-render-pane.tsxuses it), GFM parity (Streamdown bundles GFM — noremark-gfmregression), the prose styling, and the plain-text fallback; reusenormaliseInternalMdLinksForStreamdownso internal relative links surviverehype-harden(§I3). Rendered output must not visibly regress (§I2).
Testing and validation
Section titled “Testing and validation”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):
| Invariant | Proposed change (§) | Verification |
|---|---|---|
| §A1 item-is-form | §6 group-A GET reads form_instances by PK | GET 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 read | rail 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 read | two labelled groups (FORM SOURCE / REFERENCE-EVIDENCE), text label not colour |
| §A6 labelled attachment v1 | §2 form_attachments + POST attach | attach→label→appears in correct group at form/engagement level |
| §A7 delete engagement ≠ delete form docs | §2 FK ON DELETE semantics | delete 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 viewers | each 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 theme | viewer light/dark follows app theme |
| §B5 backgrounds as-is first | §1 install-as-is theming | white 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 affordance | unknown/corrupt type → “cannot preview” + download |
| §C1 fill-slots overlay PDF | §3 geometry column + HighlightArea | detected slots box-overlaid page-accurately on PDF |
| §C2 slot list ↔ overlay linked | §3 goToPage + shared selection id | select slot→scroll/highlight box; select box→select slot; label/icon |
| §C3 fill status per slot | §3 fill_status → status label | per-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 → HighlightArea | citation renders as highlighted region; bidirectional select |
| §D2 exact primary, approx fallback | §4 B1 text-layer / B2 vision | text PDF exact; scanned approximate, not surfaced as error |
| §D3 unresolvable → text entry | §4 degrade | unmappable 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 shell | type/size/count/dup validated inline pre-submit |
| §E2 hardened backend unchanged | §1/§2 backend binding | onFilesAccepted → 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 states | progress+success; rejection shows backend’s actual reason (UK English) |
| §F1 in-place edit/fill v1 | §5 editor + Claude-side fill | DOCX/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 fallback | failed editor → read-only viewer, never blank |
| §G1 linear 10-state custom stepper | §7 custom Warm Meridian | stepper over workflow_state; not AI-SDK workflow |
| §G2 never colour-only | §7 label/icon per state | completed/current/upcoming carry text/icon |
| §G3 only valid transitions | §7 canTransition | invalid jump refused with surfaced reason |
| §G4 deadline/outcome non-colour | §7 label/icon | overdue/terminal never colour-alone |
| §H1 custom requirement editor | §7 over form_requirement_templates | domain fields exposed; Schema Builder not used |
| §H2 custom question editor | §7 over form_questions/form_responses | slot fields exposed; Schema Builder not used |
| §H3 catalogue writes gated | §7 admin/editor | reviewer/viewer read-only |
| §H4 Schema Builder reserved | §7 (not offered) | not present in requirement/question surfaces |
| §I1 all markdown via Streamdown | §7 Streamdown | new surfaces render via Streamdown |
| §I2 incumbent migrates (in {145.19}) | §7 content-renderer.tsx→Streamdown | named rendered-output snapshot over the markdown corpus: headings/links/lists/bold/GFM/blockquote/code no visible regression |
| §I3 internal links survive hardening | §7 normaliseInternalMdLinksForStreamdown | relative 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-point | no mixed icon families in shared chrome |
| §J2 shared chrome matches app | §1 re-wire shared chrome to components/ui | viewers may keep Coss UI internals |
| §J3 semantic tokens only | §1 bare semantic tokens | no raw Tailwind colours (components/CLAUDE.md F3) |
| §J4 WCAG 2.1 AA all surfaces | all §§ non-colour-only + UK English | no meaning by colour alone across surfaces |
| §K1 procurement baseline only | scope (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 install | registries 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).
Risks and mitigations
Section titled “Risks and mitigations”- 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]/attachmentsis authenticated — ensure it is NOT added toproxy.tspublicRoutes(it needs auth), and that it usesauthFailureResponse(auth)on the discriminated union. - Migration DDL via CLI only (
supabase migration new+db push, foreground; verifysupabase/.temp/project-reffirst —supabase/CLAUDE.md), never MCPapply_migration.
Follow-ups
Section titled “Follow-ups”- §6 group-C consolidation:
outcome/route.tsduplicates the[id]PATCH transition logic — consolidate into one form_instance transition writer during the ID-145 re-key wave. forms/route.tssibling-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.