Form-Extraction Subsystem — TECH
Form-Extraction Subsystem — TECH
Section titled “Form-Extraction Subsystem — TECH”Type: TECH (implementation plan). Companion to
PRODUCT.md(26 numbered behaviour invariants, ratified S273 by Liam) andRESEARCH.md(empirical grounding) in this same directory. This document translates PRODUCT intent into a concrete implementation plan against the live KH codebase + Supabase schema.Task: ID-52 (form-extraction). Subtask: {52.3}. Authored S274 by a fresh Planner context (distinct from {52.1} RESEARCH and {52.2} PRODUCT authors). UK English throughout.
Inherited hard gates (from
docs/research/s273-canonical-pipeline-finals/id52-final.yamltech_phase_flags):
cocoindex.ExtractByLlm+cocoindex.LlmSpecABSENT incocoindex==1.0.3(RESEARCH §8). All form-classification work uses custom@coco.fndecorators; the S234 feedback-findings direction is SUPERSEDED.- Per-format reader pins:
pdfplumber==0.11.9(Python),exceljs@4.4.0(TS),mammoth@^1.12.0+turndown@^7.2.4+docx@^9.6.1(TS);pdf-parse+xlsxABSENT frompackage.json; legacy.xlshas NO installed reader (out of automated scope per Inv-3).- TS-vs-Python placement, the folder→workspace convention mechanism, the CV-loader fix direction, the Inv-16 idempotency mechanism, and the Inv-21 Path-C surface are TECH decisions — committed below, not left open.
Schema canonical source:
supabase/types/database.types.ts(auto-generated, never hand-edited). Row shapes viaTables<'x'>/QueryData<>; JSONB column domain types viasupabase/types/database-overrides.ts.
1. Context
Section titled “1. Context”1.1 What’s being built
Section titled “1.1 What’s being built”Path B (deterministic per-format readers, Python-side, fully pipeline-owned) + Path C
(human-confirmed cataloguing skill, TS-side Claude Plugin Skill) for the form-extraction
subsystem (Mode-3). The pipeline ingests blank {PDF, XLSX, DOCX} forms from a
folder→workspace-mapped localfs source, extracts every question as a form_template_fields
row (with row_index / col_index / table_index / word_limit / section_name /
field_type / fill_status / placeholder marking preserved), and writes the
workspace-scoped form_templates instance row. Path C promotes confirmed instance fields
into the global form_template_requirements catalogue, generating
requirement_embedding (vector(1024)) for T10 matching to read.
Behaviour is owned by PRODUCT.md Inv-1 through Inv-26 — referenced by number, not
restated.
1.2 Current-state file inventory (line anchors)
Section titled “1.2 Current-state file inventory (line anchors)”Live, retained:
scripts/cocoindex_pipeline/flow.py:791-934—ingest_file@coco.fn(memo=True)component (Stages 2→6 per source item). Today mounts three targets only:content_items,q_a_extractions,source_documents(lines 1012-1029). NOform_templates/form_template_fieldsmount → Mode-3 orphan.scripts/cocoindex_pipeline/flow.py:919-934— current lossyq_a_extractionswrite (dropsexpected_response_kind/evaluation_criteria/evidence_requirements/scope_tagsfrom eachQAPair). Out of scope for ID-52 per OQ-52-LOSSY ratification → tracked as separate Task ID-54.scripts/cocoindex_pipeline/extraction.py:121-177—FormMetadata+QAPair+QAFormExtractionPydantic models.form_metadatais built but unwired inflow.py(Mode-3 orphan, half of which this TECH fixes).scripts/cocoindex_pipeline/extraction.py:121-142— hard-coded 11-valueform_type: Literal[...]inFormMetadata(one of the three drift-prone sources; Inv-1 lockstep).scripts/cocoindex_pipeline/adapters.py:39-79—convert_binary_to_markdownouter fn + Docling/pullmd/passthrough inner tier. Path A markdown-conversion surface — retained; Path B reads the raw bytes alongside, NOT the markdown (PRODUCT R2 ratification).scripts/extract_tender_questions.py(507 lines) — DOCX table extractor; canonical_QUESTION_HEADERSset;_classify_headerheuristic. Reused under a new wrapper module (§2.4 Path B DOCX).scripts/analyse_template.py(363 lines) — DOCX merged-cell + placeholder + word-limit- section-heading + tracked-changes detector. Reused under the new wrapper.
scripts/extract_pdf_text.py(67 lines) — minimal helper; superseded by the new pdfplumber-based reader (§2.4 Path B PDF).supabase/types/database.types.ts:1519form_template_fields,1597form_template_requirements,1686form_templates,1755form_types— all live; see RESEARCH §3.supabase/migrations/20260416102457_pre_squash_reconciliation.sql:4133-4135— current CHECK constraints:field_type ∈ {empty_cell, placeholder, highlighted},fill_status ∈ {pending, filled, skipped, failed},mapping_status ∈ {unreviewed, confirmed, rejected, manual, unmapped}.supabase/migrations/20260416102457_pre_squash_reconciliation.sql:4192—form_templates.mime_typeCHECK is currently DOCX-only (application/vnd.openxmlformats-officedocument.wordprocessingml.document). Must be widened to accept PDF + XLSX MIME types (§2.5 Migration M1).lib/ontology/schemas.ts:23—STATUS_VALUES = ['active','planned','needed']— blocks 4APPLIED-S{NNN}files. Fix direction in §2.6.lib/ontology/loader.ts:43-76—loadOntologyCVs()throws fatally on first invalid file. Inv-1 gate hinges on this loading successfully.scripts/tests/fixtures/taxonomy_snapshot.json— dual-source canonical for Python-side CV reads (per CLAUDE.md “Taxonomy dual-source”). Hascontent_types; does NOT yet haveform_types— added by §2.6 Migration M2.
Live, retired by this build:
app/api/procurement/[id]/templates/[templateId]/analyse/route.ts(143 lines) — the app-side enqueue route. Retired per PRODUCT R3 / Inv-6 (Option A: fully pipeline-owned write). The processing-queuejob_type: 'template_analyse'consumer (if any built one) is also retired. Retirement Subtask scoped separately (the sub-orchestrator opens it under ID-52 after PLAN authoring; this TECH names the surface- the test deletions so the retirement is well-bounded).
__tests__/api/procurement/[id]/templates/[templateId]/analyse/route.test.ts(if present) — deleted with the route.- Inv-24 still gates app-side mutating actions on
getAuthorisedClient(['admin','editor'])authFailureResponse(auth). The Path-C confirmation step retains that gate (§2.7); the ingest write runs under the pipeline service identity (nogetAuthorisedClient— pipeline binds via service-account UUIDa0000000-0000-4000-8000-000000000001, the CLAUDE.md-mandated value).
1.3 Ownership boundaries
Section titled “1.3 Ownership boundaries”| Concern | Side | Module |
|---|---|---|
| Folder→workspace resolution | Python pipeline | scripts/cocoindex_pipeline/workspace_resolver.py (new) |
| Path B raw extractors (PDF, XLSX, DOCX) | Python pipeline | scripts/cocoindex_pipeline/form_extractors/ (new package) |
form_templates + form_template_fields write | Python pipeline | scripts/cocoindex_pipeline/flow.py (extended) |
| Path C cataloguing skill (human-confirmed) | TS / Claude Plugin Skill | .claude/skills/catalogue-form-requirements/ (new) |
form_template_requirements write + embedding | TS (Path-C-driven script) | scripts/catalogue-from-instance.ts (new) |
| CV-loader gate fix | TS | lib/ontology/schemas.ts + 4 .md edits + Python snapshot |
| App-side analyse route | RETIRED | n/a |
2. Proposed changes
Section titled “2. Proposed changes”2.1 Folder→workspace resolution mechanism (PRODUCT Inv-4 / Inv-5)
Section titled “2.1 Folder→workspace resolution mechanism (PRODUCT Inv-4 / Inv-5)”Mechanism: a single JSON manifest at the root of the ingest source folder.
The pipeline reads <COCOINDEX_SOURCE_PATH>/.kh-workspace-map.json once at flow start
(extends app_main in scripts/cocoindex_pipeline/flow.py):
{ "schema_version": 1, "mappings": [ { "path_prefix": "phew-procurement/", "workspace_id": "<uuid>" }, { "path_prefix": "acme-bids/2026/", "workspace_id": "<uuid>" } ]}Resolution rule (deterministic per Inv-4):
- For each ingested file’s
rel_path(the localfs path relative toCOCOINDEX_SOURCE_PATH, already computed viafile.file_path.path.as_posix()—flow.py:853), find the longest matchingpath_prefix. The associatedworkspace_idis the resolved workspace. - Ambiguous prefixes (two mappings of equal length) → resolution failure (Inv-5: never silent default).
- No matching prefix → resolution failure.
- Missing or unparseable manifest at flow start → flow aborts with a structured
manifest_missing/manifest_invaliderror (surfaced through the existing flow-end webhook +_emit_stage_error_log); the form path does not run.
Why a manifest, not a path-pattern convention: the alternative (mapping each top-level folder name 1-to-1 to a workspace) couples the ingest filesystem to workspace UUIDs and would silently degrade if a folder were renamed. The manifest is explicit, version- controlled inside the ingest source, and supports nested-prefix mappings (one client may maintain multiple workspaces under one folder hierarchy).
Why JSON, not a per-folder workspace.json sidecar: one read at flow start vs N reads
per ingest is materially simpler and lets the resolver behave deterministically without
filesystem-walk-order dependencies.
Validation: the manifest is parsed against a Zod-equivalent Pydantic model
(WorkspaceManifest) on load. The workspace UUIDs are NOT verified against the live
workspaces table at manifest-load time (FK enforcement at INSERT time gives a
canonical error if a UUID is stale; verifying on load adds DB round-trips for no
additional safety).
File added: scripts/cocoindex_pipeline/workspace_resolver.py (~80 LOC). Exports
load_workspace_manifest(path) -> WorkspaceManifest and
resolve_workspace(manifest, rel_path) -> WorkspaceId | ResolutionFailure.
Maps to PRODUCT: Inv-4, Inv-5, Inv-6 (resolution is the precondition for the pipeline-owned write).
2.2 Per-format reader assignment (PRODUCT Inv-2, Inv-3)
Section titled “2.2 Per-format reader assignment (PRODUCT Inv-2, Inv-3)”Path B lives entirely in Python, alongside the existing cocoindex pipeline. Rationale:
- All three readers (
pdfplumber,openpyxlfor XLSX,python-docxfor DOCX) are Python; the prior art (extract_tender_questions.py,analyse_template.py) is Python; cocoindex Stage-6 row declarations are Python. - Crossing the TS/Python boundary mid-flow (e.g. spawning Node from Python, or moving the
write to TS while keeping extraction Python) introduces orchestration overhead and a
second auth surface for no benefit — the
form_templateswrite target is a plain Supabase table any side can write to, but the pipeline already holds anasyncpgpool (DB_CTX,flow.py:1012-1029) undermanaged_by=ManagedBy.USER. - The TS-side libraries (
exceljs,mammoth,turndown) remain available for Path C (TS-side, app-context) and any UI-side preview — they are not used in Path B itself.
Reader matrix:
| Format | Reader | Pin | Module |
|---|---|---|---|
pdfplumber | ==0.11.9 (already in requirements.txt) | scripts/cocoindex_pipeline/form_extractors/pdf.py (new) | |
| XLSX | openpyxl | (available in the Python environment; pin explicitly as a direct dependency in requirements.txt for Path B) | scripts/cocoindex_pipeline/form_extractors/xlsx.py (new) |
| DOCX | python-docx | (already in requirements.txt) | scripts/cocoindex_pipeline/form_extractors/docx.py (new) |
.xls (legacy) | — | — | NOT IMPLEMENTED (Inv-3, manual pre-convert) |
Why openpyxl, not exceljs: the corpus inspection (RESEARCH §2.2, §2.3) was done
with openpyxl and reads the EFA merged cells (42 ranges) + CSP merged cells (23 ranges)
cleanly. Adding exceljs would require either a Python→Node bridge (rejected for
complexity) or moving the entire XLSX extractor TS-side (rejected for split ownership).
The exceljs@4.4.0 pin in package.json remains available for any TS-side need (Path C
preview UI; not used by Path B).
Why pdfplumber, not pdf-parse: pdf-parse is ABSENT from package.json (RESEARCH
§8) and would require adding a JS dep for a Python-side pipeline. pdfplumber==0.11.9 is
already pinned, and it is what read the SQ PDF as 57 pages (the 8-page container
artefact) cleanly in RESEARCH §2.1.
Why python-docx, not mammoth: the prior-art extract_tender_questions.py already
uses python-docx; reusing avoids re-deriving the _QUESTION_HEADERS heuristic and the
merged-cell / placeholder detection in TS.
Per-reader shape (each form_extractors/<format>.py module exports):
class ExtractedField(BaseModel): """One field row destined for form_template_fields.""" model_config = ConfigDict(strict=True, extra="forbid")
question_text: str | None # placeholder → None; authored → text placeholder_text: str | None # placeholder → text; authored → None field_type: Literal["empty_cell", "placeholder", "highlighted"] fill_status: Literal["pending", "filled", "skipped", "failed"] row_index: int | None # None for non-tabular regions col_index: int | None table_index: int | None section_name: str | None sequence: int # reading-order position within the form word_limit: int | None reference_urls: list[str] # preserved per Inv-14
class ExtractedForm(BaseModel): form_metadata: FormMetadata # reuses extraction.py:121 — extended fields tolerated fields: list[ExtractedField]
async def extract(raw_bytes: bytes, filename: str) -> ExtractedForm: ...Reuse of existing DOCX prior art: form_extractors/docx.py imports
_classify_header, _QUESTION_HEADERS, _detect_merged_cells,
_is_empty_or_placeholder, _extract_word_limit, _extract_section_headings,
_has_tracked_changes from the existing scripts under a thin compatibility wrapper. The
existing scripts/analyse_template.py:1-50 placeholder regex set is folded into the new
module as-is (it already covers [Insert…], {{…}}, <<…>>, n/a, dash/ellipsis
sentinels — verified RESEARCH §2.4).
Maps to PRODUCT: Inv-2 (formats), Inv-3 (.xls/HTML out of scope), Inv-7
(form-level metadata via reused FormMetadata), Inv-8 (coordinates), Inv-9 (placeholder
vs authored), Inv-10 (mandatory flag), Inv-11 (word limit), Inv-12 (section + sequence),
Inv-14 (reference URLs), Inv-15 (full content extent — pdfplumber.open(...).pages
honours the true 57-page count).
2.3 Repeated-block dedup (PRODUCT Inv-13)
Section titled “2.3 Repeated-block dedup (PRODUCT Inv-13)”Mechanism: stable content-hash dedup keyed on (section_name, question_text_normalised)
within a single extracted form.
Implementation in form_extractors/xlsx.py:
- After table-walking all sheets, group extracted candidate fields by
(section_name, normalise(question_text))wherenormaliselowercases, strips whitespace runs, and removes terminal punctuation. - For each group, keep the first occurrence in reading order; discard subsequent copies.
- Preserve the original coordinates (
row_index/col_index/table_index) from the first occurrence; dropped duplicates surface as a count in the extractor’s structured log (not as a field row).
Why per-form dedup, not pipeline-wide dedup: instance fields are workspace-scoped to one form (Inv-25). Two different forms in the same workspace may legitimately share question text (e.g. boilerplate SQ questions across multiple ITTs); dedup must not collapse those into one. Catalogue-level reuse is Path-C’s concern (§2.7).
Verifiable via: the EFA fixture (Bidder 1 ≡ Bidder 2 sheets) produces N fields,
not 2N (Inv-13 acceptance fixture). The Charnwood .xls is out of scope, but the
companion ITT Services.docx has no such duplication so the heuristic does not over-
dedup.
Maps to PRODUCT: Inv-13.
2.4 Path B custom @coco.fn shape (replacing the absent ExtractByLlm)
Section titled “2.4 Path B custom @coco.fn shape (replacing the absent ExtractByLlm)”No LLM call in Path B. Path B is deterministic — the format-specific readers are the
extractors. The custom @coco.fn wrapping them is purely orchestration:
@coco.fn(memo=True)async def extract_form_structure( file: "coco.resources.file.FileLike", # type: ignore[name-defined]) -> ExtractedForm | None: """Path B Stage-3a — raw-format extraction of blank-form structure.
Returns ExtractedForm when the file is a recognised form-bearing format AND the workspace resolves AND the reader succeeds; returns None when the file is not form-relevant (e.g. .md content). Resolution and extraction failures raise — Inv-17 (failure isolation) catches them at the ingest_file boundary so one form's failure does not block the batch.
@coco.fn(memo=True): unchanged file bytes (cocoindex content_fingerprint) skip extraction on the next run — Inv-16 idempotency substrate. """ suffix = file.file_path.path.suffix.lower() if suffix == ".pdf": return await extract_pdf_form(await file.read(), file.file_path.path.name) if suffix == ".xlsx": return await extract_xlsx_form(await file.read(), file.file_path.path.name) if suffix == ".docx": return await extract_docx_form(await file.read(), file.file_path.path.name) if suffix == ".xls": # Inv-3: legacy .xls out of scope. Log + return None (NOT an error). _logger.info( json.dumps({"event": "form_extractor.skip", "reason": "xls_out_of_scope", "rel_path": file.file_path.path.as_posix()})) return None return NoneWhy no LLM: Path B’s contract is deterministic structural extraction — every cell, row,
and merged-block decision is reproducible from the raw bytes. Adding an LLM would
introduce nondeterminism for no signal a deterministic reader cannot recover. Form-type
classification (FormMetadata.form_type) is filled by the existing Path A
extract_qa_form LLM call against markdown, which already infers form type — Path B
adopts that value through extraction.py’s FormMetadata (it does not re-classify).
Why @coco.fn(memo=True) instead of a plain async def: memoisation lets cocoindex’s
content-fingerprint cache skip re-extraction when a file is unchanged across runs (the
substrate for Inv-16 idempotency below).
Error surfacing per Inv-17: the extractor functions raise typed exceptions
(FormExtractionError(reason, rel_path, …)) that the caller in ingest_file catches in
a try/except block scoped per-file (not flow-wide). On catch: emit
_emit_stage_error_log(stage="form_extraction", …) (the existing helper), record a
form_template_fields-less form_templates row with status='analysis_failed' IF
workspace resolution succeeded (so the failure is visible) — or skip the write entirely
if workspace resolution itself failed (Inv-5). Other forms in the same flow continue.
Maps to PRODUCT: Inv-2, Inv-6, Inv-17.
2.5 Pipeline write into form_templates + form_template_fields (PRODUCT Inv-6, Inv-7, Inv-8 et al.)
Section titled “2.5 Pipeline write into form_templates + form_template_fields (PRODUCT Inv-6, Inv-7, Inv-8 et al.)”Two new mount_table_target calls in app_main (scripts/cocoindex_pipeline/flow.py
after line 1029, alongside ci_target / qa_target / sd_target):
ft_target = await mount_table_target( DB_CTX, "form_templates", FORM_TEMPLATES_SCHEMA, managed_by=ManagedBy.USER,)ftf_target = await mount_table_target( DB_CTX, "form_template_fields", FORM_TEMPLATE_FIELDS_SCHEMA, managed_by=ManagedBy.USER,)Targets are passed positionally to coco.mount_each(ingest_file, source.items(), ci_target, qa_target, sd_target, ft_target, ftf_target) and ingest_file’s signature
extended to accept the two new targets. managed_by=ManagedBy.USER preserves the
“DDL via Supabase CLI only” rule (the migration in §2.6 owns all DDL; cocoindex writes
rows only).
ingest_file extension (one new block after the existing Stage-6 declares):
- Compute
workspace_id = resolve_workspace(MANIFEST, rel_path). OnResolutionFailure, emit_emit_stage_error_log(stage='workspace_resolution', …)and return (NOform_templateswrite, NOform_template_fieldswrite — Inv-5). - Call
await extract_form_structure(file). On returnNone, the file is not a form; exit the form-write block (other Stages-2/3 already ran). OnFormExtractionError, declare aform_templatesrow withstatus='analysis_failed'and exit (no fields — Inv-17). - On success, declare the
form_templatesrow:
form_template_id = uuid.uuid5(_KH_PIPELINE_DOC_NS, f"ft:{rel_path}")ft_target.declare_row(row={ "id": form_template_id, "workspace_id": workspace_id, "created_by": SERVICE_ACCOUNT_UUID, # a0000000-0000-4000-8000-000000000001 "name": extracted.form_metadata.form_title or file.file_path.path.stem, "filename": file.file_path.path.name, "file_size": file.size, # cocoindex File.size "mime_type": MIME_BY_SUFFIX[file.file_path.path.suffix.lower()], "storage_path": rel_path, # SAME stable string used elsewhere "structure_path": None, # populated by Path C later "description": extracted.form_metadata.evaluation_methodology, "field_count": len(extracted.fields), "mapped_count": 0, "status": "analysed", # M1b dedicated columns (per §2.6d) — written from day one; no packing. "form_type": extracted.form_metadata.form_type, "deadline": extracted.form_metadata.deadline, "issuing_organisation": extracted.form_metadata.issuing_organisation, "evaluation_methodology": extracted.form_metadata.evaluation_methodology,})- Iterate the deduped fields (§2.3) and declare each as
form_template_fields:
for field in extracted.fields: ftf_target.declare_row(row={ "id": uuid.uuid5(_KH_PIPELINE_DOC_NS, f"ftf:{rel_path}:{field.sequence}"), # see §2.6 idempotency "template_id": form_template_id, "question_text": field.question_text, "placeholder_text": field.placeholder_text, "field_type": field.field_type, "fill_status": field.fill_status, "row_index": field.row_index, "col_index": field.col_index, "table_index": field.table_index, "section_name": field.section_name, "sequence": field.sequence, "word_limit": field.word_limit, "reference_urls": field.reference_urls, # mapping_status defaults 'unreviewed' per CHECK default # question_id is filled later by Path C link-back, NOT here })op_id / extractor-version stamping: form_templates and form_template_fields
do NOT currently have an op_id column (verified §3.2 / §3.3 in RESEARCH). The
pipeline’s per-flow op_id is recorded against pipeline_runs.op_id and the
items_created array (existing webhook plumbing in flow.py:1132+). The form-write
slice does not introduce a new op_id column in v1 — see Follow-ups.
Maps to PRODUCT: Inv-6 (entire write), Inv-7 (form-level metadata), Inv-8
(coordinates), Inv-9 (placeholder vs authored, via field_type + placeholder_text),
Inv-10 (mandatory flag — field_type='highlighted' for the SQ M/O case is captured as
the EFA-style flag column; ALSO see §2.5a below for the schema gap), Inv-11 (word
limit), Inv-12 (section + sequence), Inv-15 (true content extent).
2.5a Schema gap: mandatory flag column on form_template_fields
Section titled “2.5a Schema gap: mandatory flag column on form_template_fields”The live form_template_fields schema has NO column for the mandatory/optional flag.
RESEARCH §3.3 enumerated the columns: field_type ∈ {empty_cell, placeholder, highlighted}, fill_status ∈ {pending, filled, skipped, failed} — neither is a
mandatory flag.
Fix (Migration M1, §2.6): add is_mandatory boolean | null to
form_template_fields. Null = the form expressed no such status (Inv-10’s “where the form
expresses no such status, the field records no mandatory flag rather than defaulting to
one”). True/false = explicit value extracted from the form (SQ Annex B M/O).
2.6 Migrations (TWO migrations, one each for schema + ontology snapshot)
Section titled “2.6 Migrations (TWO migrations, one each for schema + ontology snapshot)”Migration M1 — <timestamp>_id52_form_extraction_schema.sql (created via
supabase migration new id52_form_extraction_schema):
-- 1. Widen form_templates.mime_type CHECK to include PDF + XLSX (was DOCX-only).ALTER TABLE public.form_templates DROP CONSTRAINT form_templates_mime_type_check;ALTER TABLE public.form_templates ADD CONSTRAINT form_templates_mime_type_check CHECK ( mime_type IN ( 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', -- DOCX 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- XLSX 'application/pdf' -- PDF ) );
-- 2. Add is_mandatory to form_template_fields (Inv-10 substrate).ALTER TABLE public.form_template_fields ADD COLUMN is_mandatory boolean NULL;COMMENT ON COLUMN public.form_template_fields.is_mandatory IS 'Explicit mandatory/optional flag from the source form (Inv-10). ' 'NULL = form expressed no such status (NOT defaulted to optional).';
-- 3. Add reference_urls to form_template_fields (Inv-14 substrate).ALTER TABLE public.form_template_fields ADD COLUMN reference_urls text[] NULL;COMMENT ON COLUMN public.form_template_fields.reference_urls IS 'External URLs preserved from the source form question / section (Inv-14). ' 'NULL or [] = no reference URLs on this field.';
-- 4. Add ingest_source to form_templates (provenance: pipeline vs app).ALTER TABLE public.form_templates ADD COLUMN ingest_source text NOT NULL DEFAULT 'pipeline' CHECK ( ingest_source IN ('pipeline', 'app_upload') );COMMENT ON COLUMN public.form_templates.ingest_source IS 'Provenance of this template row. v1 = pipeline (folder→workspace). ' 'app_upload reserved for the thin UI front-end per OQ-52-UI-UPLOAD-TENSION.';
-- Inv-5 (workspace-resolution failure) surfacing is handled in code via-- _emit_stage_error_log + zero form_template_fields + zero form_templates rows-- (see §2.5 step 1). No new form_templates.status CHECK value is added — a-- 'failed_workspace_resolution' row is schema-impossible because form_templates.workspace_id-- is NOT NULL and no workspace_id is resolved on this failure path.No new PL/pgSQL functions, no anon EXECUTE grants needed (this migration is purely
ALTER TABLE + CHECK adjustments; no new public.*() functions). RLS predicates are
unchanged — the role-based gate on form_templates / form_template_fields remains as
set in the pre-squash migration. The pipeline writes via the asyncpg pool under the
service-account role (RLS-bypassing where the role grants), per existing precedent
(flow.py:1012-1029).
Migration M2 — taxonomy snapshot regen. Not strictly a DDL migration but a fixture update:
- Run
bun run sync:taxonomy(per CLAUDE.md taxonomy dual-source) after extending the snapshot to include theform_typestable contents (currently missing fromscripts/tests/fixtures/taxonomy_snapshot.jsonper §1.2 audit). - Manually patch
scripts/tests/fixtures/taxonomy_snapshot.jsonto add aform_typesarray of{key, label}objects matching the liveform_typestable rows. scripts/cocoindex_pipeline/extraction.py:130-142FormMetadata.form_type: Literal[...]is replaced byFormMetadata.form_type: strwith afield_validatorthat asserts the value is in the snapshot’sform_typesarray (matches the existing_load_canonical_content_typespattern atextraction.py:76-86). The PythonLiteralis no longer the source of truth.
Maps to PRODUCT: Inv-1 (CV lockstep), Inv-7 (form-level metadata persistence), Inv-10 (mandatory column), Inv-14 (reference URLs column), Inv-5 (resolution-failure status).
2.6d Migration M1b — Dedicated form_templates metadata columns
Section titled “2.6d Migration M1b — Dedicated form_templates metadata columns”S275 amendment (Liam ratification): v1 lands the schema-clean breakout in ID-52, NOT
as a follow-up. Rationale: v1.1 needs the columns regardless; deferring forces a
later migration + backfill + consumer rewrite, all avoidable if the columns land before
the pipeline writer ever ships. M1 is already committed in {52.7}; M1b is sequenced as a
sibling migration in a NEW Subtask {52.M1b} (deps [7]) BEFORE Phase-5 writer impl
({52.12+}) so the writer code targets the proper columns from day one.
Migration M1b — <timestamp>_id52_form_templates_dedicated_metadata_columns.sql
(created via supabase migration new id52_form_templates_dedicated_metadata_columns):
-- Add dedicated form-level metadata columns to form_templates.-- v1.1 promotes facets currently packed into description / structure_path into-- first-class columns. v1 writes to these from day one (no packing prose).
ALTER TABLE public.form_templates ADD COLUMN form_type text NULL REFERENCES public.form_types(key);COMMENT ON COLUMN public.form_templates.form_type IS 'FK to form_types.key — the form-type CV value ' '(matches FormMetadata.form_type per CV-lockstep, TECH §2.6b). ' 'NULL permitted for app_upload rows pre-classification.';
ALTER TABLE public.form_templates ADD COLUMN deadline timestamptz NULL;COMMENT ON COLUMN public.form_templates.deadline IS 'Form submission deadline parsed from the source form (Inv-7 substrate). ' 'NULL = no deadline expressed.';
ALTER TABLE public.form_templates ADD COLUMN issuing_organisation text NULL;COMMENT ON COLUMN public.form_templates.issuing_organisation IS 'Issuing-organisation string parsed from the source form (Inv-7 substrate). ' 'NULL = no issuer expressed.';
ALTER TABLE public.form_templates ADD COLUMN evaluation_methodology text NULL;COMMENT ON COLUMN public.form_templates.evaluation_methodology IS 'Evaluation-methodology string parsed from the source form (Inv-7 substrate). ' 'Replaces the v1-deferred description-packing scheme. ' 'NULL = no methodology expressed.';
-- Partial index on form_type for downstream filters (T10 / observability).CREATE INDEX IF NOT EXISTS idx_form_templates_form_type ON public.form_templates (form_type) WHERE form_type IS NOT NULL;No backfill needed — v1 writer ({52.12}) ships these columns populated from day one; pre-v1 rows (none in staging beyond fixture data) carry NULL safely.
Writer adaptation: §2.5 step 3 already writes to all four columns directly (no packing prose). The pre-amendment “form_type / deadline / issuing_organisation go into description+structure_path” code comment is REMOVED by this amendment.
Maps to PRODUCT: Inv-7 (form-level metadata persistence — first-class columns
instead of packed description). Inv-1 (CV lockstep — form_type FK gives DB-level
referential integrity to form_types.key).
Sequencing within ID-52: {52.M1b} = id: 18, deps [7]. MUST land before Phase-5
({52.12+}) writer dispatch so writer code targets the dedicated columns from day one.
2.6a CV-loader fix direction (Option (b) — re-baseline)
Section titled “2.6a CV-loader fix direction (Option (b) — re-baseline)”Decision: re-baseline the 4 APPLIED-S{NNN} files to status: active, NOT extend
STATUS_VALUES.
Why re-baseline (Option b) over extending the enum (Option a):
STATUS_VALUESis the drafting/editing lifecycle for the ontology (RESEARCH §4.2, perwp6-ontology-harness/TECH.md):planned→needed→active.APPLIED-S{NNN}was a Drafter-wave provenance marker that conflates “this CV has shipped a migration” (a historical fact) with “what stage is the CV in” (the enum’s purpose). Once shipped, the CV’s status ISactive— the migration reference belongs incore_seed_path(which it already does in all four files, e.g.26-form-type.md:8).- Extending the enum to
appliedwould propagate a marker that has no remaining lifecycle meaning (the CV is shipped — what doesapplieddifferentiate fromactive?). It would also propagate a session-ID-suffixed convention (APPLIED-S246,APPLIED-S249) that the enum cannot capture without further fragmentation. - The two stale claims inside
26-form-type.md(RESEARCH §4.2 bullets) are corrected in the same edit pass (deleting the “table not yet in production schema” + the 10-valuetemplate_requirementsCHECK references).
The four files re-baselined to status: active:
docs/ontology/26-form-type.md(wasAPPLIED-S246)docs/ontology/30-procurement-vehicle.md(wasAPPLIED-S246)docs/ontology/31-procurement-vehicle-instance.md(wasAPPLIED-S246)docs/ontology/32-q-a-pair.md(wasAPPLIED-S249)
The session-ID provenance is preserved in core_seed_path (already pointing at the
migration filename in each file). No data is lost.
Maps to PRODUCT: Inv-1 (CV gate — the loader loads successfully after this edit, so the form path can run).
2.6b form_type triple-source lockstep
Section titled “2.6b form_type triple-source lockstep”Decision: drive the Python side from the taxonomy snapshot fixture (the mechanism recommended in RESEARCH §5 item #6 / §4.2).
- Source of truth: the live
form_typesPostgres table. - TS consumer:
loadOntologyCVs()reads26-form-type.md(the CV markdown). A new test (__tests__/lib/ontology/form-type-parity.test.ts) asserts the 11 baseline values in26-form-type.mdexactly match the liveform_types.keyrows (mirrors the existingmarkdown-parity.test.tspattern; uses the snapshot fixture). - Python consumer:
FormMetadata.form_type(and any futureform_template_fieldsconsumer Python-side) reads fromscripts/tests/fixtures/taxonomy_snapshot.json:form_types, matching the existing_load_canonical_content_typespattern. - Snapshot fixture refresh:
bun run sync:taxonomy(per CLAUDE.md) extended to includeform_types(a one-line additionalSELECT key, label FROM form_typesfold-in). Tracked as a separate Subtask in PLAN.
Maps to PRODUCT: Inv-1.
2.6c Layer-5 KG-entity CV schema shape (PRODUCT Inv-1a)
Section titled “2.6c Layer-5 KG-entity CV schema shape (PRODUCT Inv-1a)”Cross-Task touch ratified S275 (OQ-52-WAVE-1-A, Option 3).
lib/ontology/schemas.ts is shared infrastructure owned by wp6-ontology-harness
(lib/ontology/schemas.ts:11 ownership comment; wp6 TECH.md §6 + E1 frontmatter ratifier).
This TECH amendment authorises a forward-compatible schema relaxation explicitly scoped to
the Layer-5 KG-entity case; the wp6-ontology-harness B2 + E1 verifier reports surveyed the
29 Layer-1 corpus pre-32-q-a-pair.md and recorded no Layer-5 KG-entity treatment
because no Layer-5 file existed at C2 time (32-q-a-pair.md added S249 in fa0f4710
AFTER the wp6 D1 schema .strict() + baseline_values.min(1) landed in a258d144).
The amendment below honours the wp6 D1 invariants for Layer-1 CVs unchanged while
admitting Layer-5 KG-entity files.
Decision: relax OntologyCVSchema per-layer (NOT blanket).
Two changes to lib/ontology/schemas.ts:
-
baseline_valuesbecomes conditionally required. Forlayer ∈ {1, 2, 3, 4, 6},baseline_valuesremains required +.min(1)(Layer-1 CV invariant unchanged). Forlayer === 5(KG-entity),baseline_valuesis OPTIONAL — its absence is well-formed, its presence (if a future Layer-5 file enumerated rows) is still validated byBaselineValueSchema. -
.strict()is replaced by a per-layer extra-key whitelist. Layer-1 files remain.strict()(zero tolerance for unknown keys — Drafter-wave additions still fail loudly, preserving wp6 D1 R-A invariant). Layer-5 files admit three additional OPTIONAL keys:related_ontology: z.array(z.string()).optional(),source_of_truth: z.array(z.string()).optional(),last_updated: z.string().optional(). Any OTHER unknown key on a Layer-5 file still fails (the relaxation is enumerated, not blanket).
Implementation pattern (Zod base + .superRefine per-layer refinement):
const OntologyCVBaseSchema = z.object({ cv_name: z.string().min(1).regex(/^[A-Za-z][A-Za-z0-9_]*$/), layer: z.union([ z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6), ]), provenance_model: z.enum(PROVENANCE_MODEL_VALUES), client_extensible: z.boolean(), editable_via: z.enum(EDITABLE_VIA_VALUES), core_seed_path: z.string().min(1).nullable(), status: z.enum(STATUS_VALUES), baseline_values: z.array(BaselineValueSchema).optional(), // was .min(1) related_layers: z.array(/* … */).default([]), related_ontology: z.array(z.string()).optional(), source_of_truth: z.array(z.string()).optional(), last_updated: z.string().optional(),}).strict();
export const OntologyCVSchema = OntologyCVBaseSchema.superRefine((data, ctx) => { if (data.layer === 5) return; if (!data.baseline_values || data.baseline_values.length < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['baseline_values'], message: 'baseline_values required for non-Layer-5 CVs (must have ≥1 entry)', }); } for (const key of ['related_ontology', 'source_of_truth', 'last_updated'] as const) { if (data[key] !== undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: `${key} is Layer-5-only; not permitted on layer ${data.layer}`, }); } }});Why per-layer refinement, NOT a Zod discriminated union on layer: the discriminated
union would require declaring two parallel object shapes diverging on one column
(baseline_values required vs optional) and re-validating all shared columns twice. The
single base schema + superRefine is leaner, keeps the consumer type
(type OntologyCV = z.infer<typeof OntologyCVSchema>) cleanly inferable, and surfaces
per-layer violations with the exact layer value in the error message.
Verification: post-amendment loadOntologyCVs() behaviour. 32-q-a-pair.md loads
successfully (layer:5, no baseline_values, three Layer-5 keys present). All 28
pre-existing Layer-1 files load unchanged. A hypothetical malformed Layer-1 file omitting
baseline_values still fails with a clear “baseline_values required for non-Layer-5 CVs”
error. A hypothetical Layer-1 file adding source_of_truth still fails with
“source_of_truth is Layer-5-only; not permitted on layer 1”.
Test additions: extend __tests__/lib/ontology/markdown-parity.test.ts with three
cases: (a) 32-q-a-pair.md (Layer-5, no baseline_values) parses successfully;
(b) constructed Layer-1 fixture missing baseline_values fails with expected message;
(c) constructed Layer-1 fixture with stray source_of_truth key fails “Layer-5-only”.
Maps to PRODUCT: Inv-1a (Layer-5 KG-entity CVs load alongside Layer-1); Inv-1 (register loads heterogeneously — both kinds together).
Coordination with §2.6a re-baseline (NO conflict): §2.6a re-baselines
26-form-type.md, 30-procurement-vehicle.md, 31-procurement-vehicle-instance.md, AND
32-q-a-pair.md from APPLIED-S{NNN} to status: active. That edit clears the
status-enum throw. §2.6c (this) clears the four structural-shape violations beneath the
status throw on 32-q-a-pair.md. Both are needed; both run under separate Subtasks
({52.5} = §2.6a status flip; {52.5a} = §2.6c schema relaxation). Post-both,
loadOntologyCVs() returns 29 records (28 Layer-1 + 1 Layer-5).
2.7 Path C cataloguing skill (PRODUCT Inv-20, Inv-21, Inv-22)
Section titled “2.7 Path C cataloguing skill (PRODUCT Inv-20, Inv-21, Inv-22)”Decision: Path C is a Claude Plugin Skill (TS-side), invoked from the procurement UI
or the task-view editor, that drives a TS catalogue-from-instance script under
human-confirmation.
Why a Claude Plugin Skill, not a CLI / UI dialog:
- Sequencing §2.3 names Path C explicitly: “a Claude Plugin Skill authoring
requirement_type+ taxonomy + matching keywords with human confirmation.” - The existing hand-written catalogue scripts (
scripts/catalogue-standard-sq.ts,scripts/catalogue-charnwood-itt.ts) prove the catalogue WRITE shape. Path C generalises them under skill output. - A pure CLI would lack the human-confirmation surface; a pure UI dialog would couple
the catalogue write to one app page. A skill produces an artefact (the generated
catalogue script) that is reviewed in PR /
task-view, runs deterministically once confirmed, and is auditable in commit history. - A Plugin Skill can call the Anthropic API for
requirement_typeclassification +matching_keywordsgeneration (sameclaude-opus-4-6model the pipeline uses) and pass the result through a structured confirmation step.
Skill location: .claude/skills/catalogue-form-requirements/SKILL.md (new). Skill
inputs:
- A
form_templates.idUUID (the instance to catalogue from). - An optional
template_typeoverride (the form-type CV key). - Human-confirmation flag (no default).
Skill output: an executable TS file at
scripts/catalogue-from-instance-<form-template-id>.ts that, when run:
- Reads the
form_template_fieldsrows for the instance via Supabase RPC (read-only). - For each field, calls Anthropic with the
Q_A_FORM_PROMPTpattern (already inscripts/cocoindex_pipeline/prompts.py) extended with arequirement_type+matching_keywords+matching_guidanceclassification, returning aform_template_requirements-shaped row. - Generates the
requirement_embeddingvia the sameLiteLLMEmbedder/text-embedding-3-largeconfig the pipeline Stage-4 uses (consistency with T10 matching’s read shape). Embedding serialisation viaJSON.stringify(embedding)per CLAUDE.md. - Presents the candidate catalogue rows for human confirmation in stdout (or via
task-viewif invoked from there). The script halts pending explicity/nper row. - On confirmation, writes each row to
form_template_requirementsvia the existing safe Supabase pattern (tryQuery()from@/lib/supabase/safe; per-row insert in a single transaction wrapper).
Auth gate (Inv-24): the skill runs locally / in-PR review, but the human-confirmation
write step uses getAuthorisedClient(['admin','editor']) against the actual database
session — unauthorised callers receive authFailureResponse(auth). The skill SHALL refuse
to call the WRITE step if the auth check fails; the script logs and exits.
File added:
.claude/skills/catalogue-form-requirements/SKILL.md(the skill definition).scripts/catalogue-from-instance.ts(the generic template; the skill emits per-form copies).lib/catalogue/from-instance.ts(the read + LLM + embed + write helpers, imported by the generated scripts; uses direct file imports, not barrels — per CLAUDE.md).
No mempalace_* / gitnexus_* MCP calls in Path C — the skill is a pure
read→classify→embed→confirm→write flow; impact analysis on the catalogue (Inv-22’s T10
read boundary) is a separate matching spec.
Maps to PRODUCT: Inv-20 (instance ≠ catalogue), Inv-21 (human-confirmed), Inv-22 (T10 read boundary — Path C writes the catalogue rows T10 reads), Inv-23 (catalogue rows have no workspace FK), Inv-24 (auth gate retained).
2.8 Re-ingest idempotency mechanism (PRODUCT Inv-16)
Section titled “2.8 Re-ingest idempotency mechanism (PRODUCT Inv-16)”Mechanism: deterministic per-document UUIDs + cocoindex @coco.fn(memo=True) +
declare_row UPSERT.
This is the SAME pattern proven by the existing Stage-6 declares (RESEARCH §4 +
flow.py:778-788 _KH_PIPELINE_DOC_NS rationale comment). Specifically:
form_templates.id=uuid5(_KH_PIPELINE_DOC_NS, f"ft:{rel_path}")— stable across runs, so the second ingest mints the SAME UUID anddeclare_rowUPSERTs (UPDATEs) the existing row.form_template_fields.id=uuid5(_KH_PIPELINE_DOC_NS, f"ftf:{rel_path}:{sequence}")— stable per (form, reading-order position) pair. The second ingest UPSERTs each field row.
Why (rel_path, sequence) and not (rel_path, question_text_hash): the question
text MAY legitimately edit between ingests (typo fix, clarification revision). Using
sequence as the secondary key means an edit to question 7’s text updates question 7’s
row in-place; using a hash would orphan the old row and insert a new one.
Edge case: field-count shrink (form revised; question 8 removed). A second ingest
that produces only 7 fields would leave the original row 8 stranded with stale data.
Fix: before declaring the new field rows, delete form_template_fields rows
WHERE template_id = <id> AND sequence > <new_max_sequence>. Implemented as a one-line
asyncpg call inside ingest_file’s form-write block, gated on the per-flow op_id (so a
mid-flow failure doesn’t truncate state):
# Inv-16: trim stale field rows from a previous larger ingest.async with DB_CTX.get().acquire() as conn: await conn.execute( "DELETE FROM public.form_template_fields " "WHERE template_id = $1 AND sequence > $2", form_template_id, max(f.sequence for f in extracted.fields) if extracted.fields else -1, )@coco.fn(memo=True) cooperation: extract_form_structure is memoised on the
cocoindex content-fingerprint. An unchanged file skips re-extraction; declare_row is
not re-invoked; the existing rows retain their state. A changed file (different
fingerprint) re-extracts, re-declares (UPSERT), and the trim above removes stragglers.
Why NOT a content-hash-based UPSERT key: content-hashing the question text would fight the legitimate-edit case above. Cocoindex’s content-fingerprint already covers the “unchanged file” path; the deterministic-UUID-on-rel_path covers the “changed file” path without requiring per-field hashing.
Why NOT clear-and-rewrite (the app-side route’s approach in analyse/route.ts:81-89):
clear-and-rewrite invalidates any downstream FKs (e.g. q_a_pairs.question_match_id
references that may form later via T10). The UPSERT pattern preserves row identity across
ingests, which T10’s question_matches will depend on (sequencing §5a).
Maps to PRODUCT: Inv-16.
2.9 OQ-52-UI-UPLOAD-TENSION resolution (PRODUCT recorded ratification)
Section titled “2.9 OQ-52-UI-UPLOAD-TENSION resolution (PRODUCT recorded ratification)”PRODUCT ratified: “Thin front-end drops file into resolved workspace folder.”
Implementation: the existing app-side upload UI is retained in v1 BUT its server-side
handler stops writing form_templates directly. Instead:
- The UI upload writes the file bytes to
<COCOINDEX_SOURCE_PATH>/<workspace-folder>/(using the same folder→workspace mapping the pipeline reads). - The cocoindex live-fs-watch (
localfs.walk_dir(..., live=True, recursive=True)—flow.py:1034-1037) picks up the new file on the next walk tick. - The pipeline runs the full Path B write under its service identity.
- Inv-6 holds (no app-side instance-row write); Inv-16 holds (the same UPSERT pattern
applies whether the file arrived by manual drop or UI upload); Inv-24 holds (the UI
upload still requires
getAuthorisedClient(['admin','editor'])to write into the folder; the file-system write needs noform_templatesrow of its own).
Scope note: the UI-handler rewire is out of scope for ID-52 — it touches the
app/api/procurement/... upload routes that ID-50 (OPS-T1 route rollout) is migrating to
defineRoute. Cross-link to ID-50’s per-route-group wave; flag as a follow-up Subtask in
PLAN.md ({52.4}).
Maps to PRODUCT: OQ-52-UI-UPLOAD-TENSION (ratified record).
2.10 OQ-52-LOSSY cross-link (NOT in this Task)
Section titled “2.10 OQ-52-LOSSY cross-link (NOT in this Task)”PRODUCT ratified: “Fold to a Path-A follow-up, as a sub-task / new task (NOT a backlog
item).” The existing flow.py:919-934 lossy q_a_extractions write IS in the same file
this TECH extends. To avoid scope creep, this TECH does NOT modify that block.
ID-54 (already opened S273 per id52-final.yaml) carries the fix:
expected_response_kind / evaluation_criteria / evidence_requirements / scope_tags
need either (a) dedicated columns on q_a_extractions or (b) a structured
extraction_metadata JSONB shape. That’s ID-54’s call.
Maps to PRODUCT: Inv-18 (no silent loss — the form-side fields are handled by §2.5), Inv-19 (Mode-1 unchanged in scope).
2.11 Pre-ratification empirical verification (Q-EX2 forcing function)
Section titled “2.11 Pre-ratification empirical verification (Q-EX2 forcing function)”Per the Planner brief and the OQ-3 / Q-EX2 forcing function, the external-library symbols cited above were verified against the installed pins.
| Date | Pin | Symbol | Result | Note |
|---|---|---|---|---|
| 28/05/2026 | cocoindex==1.0.3 | coco.fn (decorator) | PRESENT | Used at flow.py:791, extraction.py (multiple); proven shape. |
| 28/05/2026 | cocoindex==1.0.3 | coco.mount_each | PRESENT | Used at flow.py:1060; reactive per-item shape. |
| 28/05/2026 | cocoindex==1.0.3 | mount_table_target | PRESENT | Used at flow.py:1012-1029; managed_by=ManagedBy.USER. |
| 28/05/2026 | cocoindex==1.0.3 | coco.resources.file.FileLike | PRESENT | Type-hint target at flow.py:793. |
| 28/05/2026 | cocoindex==1.0.3 | cocoindex.ExtractByLlm | ABSENT (RESEARCH §8 re-verified) | NOT cited in this TECH; Path B is deterministic. |
| 28/05/2026 | pdfplumber==0.11.9 | pdfplumber.open | PRESENT | Read 57-page SQ PDF cleanly (RESEARCH §2.1). |
| 28/05/2026 | python-docx | docx.Document | PRESENT | Used at scripts/extract_tender_questions.py:27. |
| 28/05/2026 | (Python env; pin direct in requirements.txt for Path B) | openpyxl | PRESENT | Read EFA / CSP merged cells (RESEARCH §2.2-2.3). |
| 28/05/2026 | package.json | exceljs@4.4.0 | PRESENT but NOT USED by Path B (Python-side decision §2.2). | |
| 28/05/2026 | database.types.ts | form_templates, form_template_fields, form_template_requirements, form_types | PRESENT (lines 1686 / 1519 / 1597 / 1755) | Schema canonical. |
No ABSENT / SIGNATURE_DRIFT blocks this TECH from ratification.
3. Testing and validation
Section titled “3. Testing and validation”Validation owns proving the build meets every PRODUCT invariant. Each row below names a concrete test or verification step.
3.1 Per-invariant validation matrix
Section titled “3.1 Per-invariant validation matrix”| PRODUCT Inv | Test / verification | File |
|---|---|---|
| Inv-1 (CV gate) | loadOntologyCVs() returns 29 records; bun run test __tests__/lib/ontology/markdown-parity.test.ts PASSES post §2.6a edit. | __tests__/lib/ontology/markdown-parity.test.ts |
| Inv-1 (triple-source lockstep) | New __tests__/lib/ontology/form-type-parity.test.ts asserts 26-form-type.md baseline_values keys == form_types table keys via snapshot fixture. | new |
| Inv-2 (formats) | extract_form_structure(.pdf/.xlsx/.docx) returns ExtractedForm; .txt/.md returns None. | scripts/tests/test_form_extractors.py (new) |
Inv-3 (.xls) | extract_form_structure(.xls) returns None and logs form_extractor.skip. | same |
| Inv-4 (workspace resolution) | resolve_workspace(manifest, "phew-procurement/SQ.pdf") returns the mapped workspace UUID; same call returns the SAME UUID on repeated invocation. | scripts/tests/test_workspace_resolver.py (new) |
| Inv-5 (loud failure) | resolve_workspace(manifest, "unmapped/X.pdf") returns ResolutionFailure (not a default); ingest_file records zero form_template_fields rows. | same + integration test |
| Inv-6 (pipeline-owned write) | Integration test: place a .pdf in a workspace-mapped folder; run app_main once; assert one form_templates row + N form_template_fields rows exist. NO app interaction. | __tests__/integration/form-extraction.integration.test.ts (new) |
| Inv-7 (form-level metadata) | The SQ PDF ingest produces a form_templates row with name, filename, mime_type='application/pdf', file_size>0, description populated from FormMetadata.evaluation_methodology. | same integration test |
| Inv-8 (coordinates) | EFA XLSX scoring-matrix question row has row_index, col_index, table_index populated; SQ PDF prose row has col_index=NULL. | per-format reader unit tests + corpus fixture assertions |
| Inv-9 (placeholder vs authored) | Charnwood DOCX Insert question title grid rows have field_type='placeholder', placeholder_text populated, question_text=NULL. CSP TYPE RESPONSE HERE>>>> cells → field_type='placeholder'. | DOCX + XLSX reader unit tests |
| Inv-10 (mandatory flag) | SQ Annex B question 6.2 row has is_mandatory=true (M flag); CSP free-text preamble item has is_mandatory=NULL. | PDF reader unit test |
| Inv-11 (word limit) | SQ Annex B 6.2 row has word_limit=500; EFA “Page Limit” row has the integer limit; CSP rows with no stated limit have word_limit=NULL. | PDF + XLSX reader unit tests |
| Inv-12 (section + sequence) | EFA fields ordered by sequence reproduce the form’s reading order; section_name carries “Part 2 — OVERALL APPROACH” etc. | XLSX reader unit test |
| Inv-13 (dedup) | EFA fixture produces N fields, NOT 2N (Bidder 1 + Bidder 2 deduped). | XLSX reader unit test + corpus fixture assertion |
| Inv-14 (reference URLs) | CSP fixture row carrying NCSC URL has reference_urls=['https://www.ncsc.gov.uk/...']. | XLSX reader unit test |
| Inv-15 (full content extent) | SQ PDF reader sees 57 pages, NOT 8; Annex B/C questions extracted from pp.17-46. | PDF reader unit test |
| Inv-16 (idempotency) | Re-ingest same file: integration test ingests once (N fields), then again (still N fields, same UUIDs); assert no duplicate rows; assert row IDs stable. Then mutate the source (field 7 removed): re-ingest yields N-1 fields, the stranded row 8 trimmed. | __tests__/integration/form-extraction.integration.test.ts (idempotency case) |
| Inv-17 (failure isolation) | Integration test ingests a batch of [corrupt_file.pdf, sq.pdf, efa.xlsx, charnwood.docx]; assert 3 successful instances + 1 surfaced failure (status='analysis_failed'), batch not halted. | same |
| Inv-18 (no silent loss) | For each corpus fixture, assert every metadata facet the form expresses on a sampled question appears on that question’s form_template_fields row (M/O, limit, URL, section, coordinate, placeholder). | corpus fixture assertions |
| Inv-19 (Mode-1 unchanged) | Existing __tests__/scripts/cocoindex_pipeline/* tests asserting Path A’s q_a_extractions behaviour continue to PASS. | existing tests unchanged |
| Inv-20 (instance ≠ catalogue) | Pipeline ingest writes only form_templates + form_template_fields; assert form_template_requirements row count is unchanged. | integration test assertion |
| Inv-21 (human-confirmed) | Path C skill output halts pending confirmation; the generated script SHALL NOT write form_template_requirements without an explicit y per row. | __tests__/lib/catalogue/from-instance.test.ts (new) |
| Inv-22 (T10 boundary) | Manual: Path C output includes requirement_embedding (vector(1024)), matching_keywords, requirement_type — the read shape T10 will consume. (T10’s match-scoring tests live in procurement-question-matching.) | manual + schema assertion |
| Inv-23 (no workspace FK) | Schema assertion: form_template_requirements Relationships array contains only template_type → form_types.key — verified via supabase/types/database.types.ts. | structural test |
| Inv-24 (auth gate) | Path C confirmation step gates on getAuthorisedClient(['admin','editor']) + authFailureResponse. Test: a viewer-role caller is refused. | __tests__/lib/catalogue/from-instance.test.ts |
| Inv-25 (workspace scoping) | RLS-level test: a viewer of workspace A cannot SELECT form_templates rows from workspace B; catalogue rows are visible regardless of workspace. | __tests__/integration/form-extraction-rls.integration.test.ts (new) |
| Inv-26 (AI-invisible) | Manual: UI surfacing of form_template_fields reads as structured records, not “AI-extracted”. (Lib-level test: no copy strings in lib/catalogue/from-instance.ts reference “AI”/“extraction” to end users.) | manual review |
3.2 Acceptance fixtures (PRODUCT § “Acceptance fixtures”)
Section titled “3.2 Acceptance fixtures (PRODUCT § “Acceptance fixtures”)”The four real-corpus fixtures in docs/testing/test-data/templates/ drive the
integration tests in §3.1. Each is added as a fixture under
__tests__/fixtures/form-extraction/ (or referenced by path; the integration test
copies them into a temporary COCOINDEX_SOURCE_PATH for the test run).
Real-behaviour discipline (per docs/reference/test-philosophy.md): the per-format
reader unit tests use the actual corpus files and the actual pdfplumber / openpyxl /
python-docx libraries — NO mocks of the reader internals. The integration test uses a
real Supabase staging branch (matching __tests__/integration/**.integration.test.ts
convention per CLAUDE.md bun run test:integration). Only the Anthropic client (for the
Path-C requirement_type classification under Inv-21 / Inv-22) is mocked at the SDK
boundary in the Path-C unit test — the integration test for Path C is gated on a real
Anthropic key in CI.
3.3 Migration validation
Section titled “3.3 Migration validation”- Run
supabase migration new id52_form_extraction_schema, paste §2.6 M1, runsupabase db push --env=stagingagainst the staging branch (turayklvaunphgbgscat). - Verify via
supabase gen types typescript --project-id turayklvaunphgbgscat --schema public > supabase/types/database.types.tsthat the diff matches the expected shape (newis_mandatory,reference_urls,ingest_sourcecolumns; widenedmime_typeCHECK). - Confirm no anon EXECUTE grant warnings via
migration-revoke-guard.ymlCI workflow. - Confirm the
schema-parity.ymlworkflow against prod has the new shape staged for prod cutover.
3.4 Verification commands
Section titled “3.4 Verification commands”bun run test __tests__/lib/ontology/markdown-parity.test.tsbun run test __tests__/lib/ontology/form-type-parity.test.tsbun run test __tests__/lib/catalogue/from-instance.test.tspython3 -m pytest scripts/tests/test_form_extractors.py scripts/tests/test_workspace_resolver.py -vbun run test:integration __tests__/integration/form-extraction.integration.test.tsbun run test:integration __tests__/integration/form-extraction-rls.integration.test.tsCI: the existing ci.yml quality-test shards pick up the new Vitest tests; the
integration job picks up the new .integration.test.ts files; the cloud-run-deploy
workflow picks up the new Python modules under scripts/cocoindex_pipeline/. No new CI
job needed.
4. Follow-ups
Section titled “4. Follow-ups”These are deferred cleanups / future work named so the build does not silently accrete them.
op_idcolumn onform_templates+form_template_fields— v1 records op_id onpipeline_runs.items_created[]only. A dedicated column would let T10 / observability filter by ingest run. Tracked separately.- UI upload re-wire (OQ-52-UI-UPLOAD-TENSION rewire) — the app-side upload UI’s
server-side handler change (writes file into folder, not
form_templates) is out of scope for ID-52 and cross-links to ID-50 (OPS-T1 route rollout). Add a Subtask under the appropriate ID-50 route-group wave. - App-side
analyse/route.tsretirement — separate Subtask scoped by the sub-orchestrator after PLAN authoring; the retirement deletes the route file + its unit tests + theprocessing_queueconsumer forjob_type: 'template_analyse'(if present). - ID-54 Path-A lossy q_a_extractions fix — already opened; see §2.10.
- Mandatory-flag inference for non-flag forms — Inv-10 currently records NULL when the form expresses no flag. Future heuristic could infer from section context (e.g. EFA weighting > 0 ⇒ mandatory); deferred until corpus evidence shows the heuristic is worth the false-positive cost.
- Path C re-run idempotency — re-running Path C against an already-catalogued
instance currently appends new
form_template_requirementsrows. A confirmation-step “skip already catalogued” flag is a follow-up. T10’s read should already be tolerant viais_current.
5. Risks and mitigations
Section titled “5. Risks and mitigations”- Risk:
form_template_fields.is_mandatorycolumn addition is non-destructive but a CHECK widening onmime_typeandstatusis. Supabase migration order matters — apply DROP CONSTRAINT before ADD CONSTRAINT to avoid name collision. Migration validated against staging first via--env=staging(per CLAUDE.md staging-first discipline). Mitigation: the migration script in §2.6 uses explicitDROP CONSTRAINT … ADD CONSTRAINT …pairs (notALTER … RENAME TO …). - Risk: silent Supabase failures. The pipeline-side declares use
mount_table_targetwhich has its own error surface; the Path-C TS-side writes go throughtryQuery()from@/lib/supabase/safe(per CLAUDE.md). Mitigation: code review gate (code-review-and-qualityskill) before merge. - Risk: schema parity prod ↔ staging. The
schema-parity.ymlworkflow guards this; the M1 migration must land on staging first, then prod, with the workflow PASS in between. Mitigation: existing CI gate. - Risk: CV-loader re-baseline (§2.6a) is a breaking change to any consumer that reads
status: APPLIED-S{NNN}literally. Grep verifies no such consumer exists (the loader is the only reader, and it rejects the marker). Mitigation: a pre-commit grep overlib/**+scripts/**confirms zero literalAPPLIED-Smatches. - Risk: workspace manifest drift (manifest UUID stale, workspace deleted). v1 relies on the asyncpg INSERT-time FK to surface a canonical error. A future workspace-existence pre-check at manifest-load time is a follow-up if the FK error proves too noisy.
- Risk: Anthropic 503 / rate limit in Path C. The skill reuses the existing
_anthropic_retrytenacity wrapper pattern fromextraction.py:9-11. Mitigation: bounded retries + structured error surfacing.
6. End-to-end flow
Section titled “6. End-to-end flow”(One Mermaid sequence diagram, because Path B + Path C cross three layers and the data ownership boundary matters.)
sequenceDiagram participant User as Authorised user (admin/editor) participant UI as UI upload (optional) participant FS as <COCOINDEX_SOURCE_PATH>/<workspace>/ participant Flow as cocoindex flow.py:app_main participant Resolver as workspace_resolver.py participant Extractor as form_extractors/{pdf,xlsx,docx}.py participant DB as Supabase (form_templates / form_template_fields) participant Skill as .claude/skills/catalogue-form-requirements participant Catalogue as form_template_requirements (global)
User->>UI: Upload blank-form file (optional path) UI->>FS: Drop file in workspace-mapped folder Note over FS: OR: file added directly to folder (manual)
Flow->>Resolver: load_workspace_manifest() Resolver-->>Flow: WorkspaceManifest Flow->>FS: localfs.walk_dir(live=True, recursive=True) FS-->>Flow: (rel_path, FileLike)* loop per file (mount_each) Flow->>Resolver: resolve_workspace(manifest, rel_path) alt resolution failure Resolver-->>Flow: ResolutionFailure Flow->>Flow: _emit_stage_error_log(workspace_resolution); skip else success Resolver-->>Flow: workspace_id Flow->>Extractor: extract_form_structure(file) alt extraction failure Extractor-->>Flow: FormExtractionError Flow->>DB: declare_row form_templates(status='analysis_failed') else success Extractor-->>Flow: ExtractedForm Flow->>DB: declare_row form_templates(status='analysed') Flow->>DB: DELETE stale field rows (sequence > new_max) loop per field Flow->>DB: declare_row form_template_fields end end end end
Note over User,Catalogue: Path C — human-confirmed cataloguing (separate run) User->>Skill: invoke catalogue-form-requirements <form_template_id> Skill->>DB: SELECT form_template_fields WHERE template_id = ? DB-->>Skill: fields Skill->>Skill: Anthropic classify (requirement_type, matching_keywords) Skill->>Skill: embed (text-embedding-3-large → vector(1024)) Skill->>User: present candidates for confirmation User-->>Skill: y/n per row Skill->>Catalogue: INSERT confirmed rows (via getAuthorisedClient gate) Note over Catalogue: T10 (procurement-question-matching) reads from hereEnd of TECH — ID-52.3. Output: docs/specs/id-52-form-extraction/TECH.md. Authored by a
fresh Planner context; not committed (the sub-orchestrator commits after the Checker
gate).