ID-59 {59.26} TECH — Q&A markdown-sidecar canonical model (sidecar-emit v1)
ID-59 {59.26} TECH — Q&A markdown-sidecar canonical model
Section titled “ID-59 {59.26} TECH — Q&A markdown-sidecar canonical model”Status: TECH draft — authored 20/06/2026 by a FRESH planner instance (Q-PLANNER-2; NOT the
{59.25}PRODUCT author). Companion to the ratifiedPRODUCT-qa-sidecar-canonical.mdin this dir (INV-1..INV-22, read in full). This is a SEPARATE feature from the two sibling specs in this dir (PRODUCT.md/TECH.mdper-UC write-back +edit_intent;PRODUCT-qa-corpus-promotion.md/TECH-qa-corpus-promotion.mdroute-i promotion); it does NOT overwrite any of them. It builds ON the as-built route-i promotion (lib/q-a-pairs/promote-corpus.ts) + the content-leg write-back adapter (lib/edit-intent/write-back.ts).Frozen S380 inputs encoded by this TECH (NOT reopened):
__qa__/reserved sub-directory (OQ-25-1 RATIFIED) routed as a THIRDresolve_routebranch;q_a_pairs.source_document_id uuiduuid5-derived FK-LESS linkage (INV-8); the DB-only not-round-tripped column set (INV-9); sidecar emit forextracted_from_corpus+curated_explicitONLY (derived_from_form_responsedeferred v1.1); re-embed-on-question-change = MARK-STALE (OQ-25-2); corpus emit = IN-LOOP (OQ-25-3); source-less curated = MATERIALISE-ON-FIRST-EDIT (OQ-25-4). The UC8 file-merge direction is delete-on-merge (OQ-25-5 recommended) but the subtask stays GATED on UC8 ratification + Spike#10 (INV-21).
Context
Section titled “Context”The Q&A two-tier model is shipped DB-side (route-i promotion + UC6 user-direct revision
both land), but the canonical sidecar .md file — the form the model was designed
around — was deferred (DEFERRED-v1.1). {59.25} PRODUCT reverses that: the sidecar file
becomes canonical, with q_a_pairs + q_a_extractions as derived projections. This TECH
maps each PRODUCT invariant to a concrete change across three subsystems: the cocoindex
walk (a third route), one new q_a_pairs column, and two emit legs (one extends the
landed promotion loop, one replaces the UC6 route’s DB-only behaviour).
Code-intelligence orientation (cited verbatim)
Section titled “Code-intelligence orientation (cited verbatim)”gitnexus_context({repo:'canonical', name:'promoteCorpusExtractions'}) (20/06/2026) —
symbol at lib/q-a-pairs/promote-corpus.ts:170-372. Incoming calls: ONE — POST
(app/api/q-a-pairs/promote-corpus/route.ts). Outgoing calls: retireSupersededPairs,
embedAndPublish (both lib/q-a-pairs/promote-corpus.ts), tryQuery
(lib/supabase/safe.ts), safeErrorMessage (lib/error.ts). No filesystem write in
the call graph today — the emit leg (INV-11) is a NEW outgoing edge to the
writeBackFileFirst-pattern. The as-built shape differs from the {59.20} TECH draft:
the loop now delegates embed to embedAndPublish (:663-716) and retirement to
retireSupersededPairs (:413-644), so the INV-11 emit slots into the CAS-won branch at
:337-351 (after promoted++, alongside the embedAndPublish call).
gitnexus_context({repo:'canonical', name:'writeBackFileFirst'}) (20/06/2026) — symbol at
lib/edit-intent/write-back.ts:132-283. Incoming calls: THREE — runSweep,
rollbackSweep (lib/edit-intent/sweep.ts), patchHandler
(app/api/items/[id]/route.ts). Participates in flows proc_85_post, proc_86_post.
Outgoing calls: resolveAbsolutePath, tryQuery, isOk. No Q&A caller today — both
emit legs (INV-11, INV-12) add Q&A callers. The adapter is parameterised by
contentItemId + an injected applyDbLeg and resolves the on-disk path via
source_documents.storage_path joined under COCOINDEX_SOURCE_PATH (:115-120); it
already carries the source-less guard (:178) and the FK-less two-read resolution
(:147-204, BUG-E precedent). The Q&A legs REUSE the file-first + compensating-restore
ordering; the path-resolution shape is content_item-keyed, so the Q&A legs supply their
own pair→source_document_id→storage_path resolution (Proposed change R2.1) and call
the same readFile→writeFile→applyDbLeg→restore ordering.
gitnexus/ast-dataflow are TS-only; the cocoindex Python flow + the SQL schema are
grounded by direct file reads (cited below). ccc not needed — the symbols resolved
directly.
Relevant files (grounded by direct read)
Section titled “Relevant files (grounded by direct read)”- The fork the gate slots into.
scripts/cocoindex_pipeline/flow.py:1902-1975—_ingest_file_bodycomputes the route ONCE (resolve_route(manifest, rel_path)at:1921), then dispatches:route == "forms"→_ingest_form_branch(:1949-1962), else →_ingest_content_branch(:1964-1975). The default is"content"(:1917). A Q&A sidecar is a natural THIRD branch here. - The route discriminator.
scripts/cocoindex_pipeline/workspace_resolver.py:55—RouteKind = Literal["content", "forms"];:116-129—WorkspaceMappingcarriespath_prefix+workspace_id+route(defaults"content",:128);:217-268—resolve_routelongest-prefix-wins, returnsResolution(workspace_id, route)(:169-178). Adding"qa_sidecar"toRouteKind+ a manifest mapping withpath_prefix="__qa__/",route="qa_sidecar"routes the prefix with ZERO new interception point (INV-4). - The reserved-file skip precedent.
flow.py:1751-1760—_WORKSPACE_MANIFEST_FILENAME(.kh-workspace-map.json,:1619) is skipped before any conversion. The__qa__/route is the analogue, but routes-to-a-branch rather than skips-entirely (it still extracts Q&A). - The pre-walk hazard the gate defends.
scripts/cocoindex_pipeline/adapters.py:62—_TEXT_EXTENSIONS = frozenset({".md", ".markdown", ".txt"});:109— a.mdsuffix matches →convert_binary_to_markdown→ full content branch. An UN-gated__qa__/*.mdwould mint junkcontent_items(S297 BUG-B). The reserved-DIR gate routes atresolve_routeBEFORE the suffix is consulted, soadapters.py:62is untouched (the reserved-extension alternative was rejected in PRODUCT precisely because it would force a change here). - The content branch’s Q&A leg (what the sidecar branch reuses + what it omits).
flow.py:2003convert_binary_to_markdown;:2017extract_qa_form(content_text);:2039-2040source_document_id = uuid5(_KH_PIPELINE_DOC_NS, "sd:"+rel_path)/content_item_id = uuid5(..., "ci:"+rel_path);:2066-2088sd_target.declare_row(source_documents UPSERT, PK =sd:-seeded);:2102-2125ci_target.declare_row(content_items, INCLUDES the embedding);:2196-2228theqa_target.declare_rowloop (q_a_extractions, PK =uuid5("qa:"+rel_path+":"+idx),source_content_item_id = content_item_id). The sidecar branch runsextract_qa_form+ thesd_targetdeclare + theqa_targetloop, and OMITSci_target/cc_target/em_target/er_target+ the content embedding (INV-5). - The form-branch precedent for “no content rows”.
flow.py:2868+—_ingest_form_branchtouches none of ci/sd/cc/qa targets and computescontent_item_id(:2921) ONLY for error-log attribution. The Q&A-sidecar branch follows the same “compute id for attribution, mint nothing junk” discipline — but unlike forms it DOES mint asource_documentsrow (the linkage-anchor target) +q_a_extractionsrows. - The inner two-tier extractor (COCO.10-safe).
extraction.py:1062async def extract_qa_form(content_text: str) -> QAFormExtraction— content-hash-keyed (@coco.fnmemo oncontent_text, notFileLike), so a metadata-only sidecar touch hits memo and skips the LLM (INV-6). UNCHANGED — the sidecar branch calls it as-is. - The promotion loop emit site (INV-11).
lib/q-a-pairs/promote-corpus.ts:281-296inserts the draft pair;:337-351is the CAS-won branch (promoted++thenembedAndPublish). The emit + linkage write attach here. ThePromotionSummaryinterface (:83-114) gains asidecar_failedcount. - The UC6 route (INV-12/INV-13 emit site).
app/api/q-a-pairs/[id]/route.ts:11-13— the explicit “KH-DB-ONLY … file-sidecar materialisation … DEFERRED-v1.1 — there is no file write here, by design” comment + behaviour this spec REVERSES;:152-164thetryQueryUPDATE the file leg wraps. - Schema (squash baseline
20260617130000).q_a_pairs(:7128-7156) has NOsource_document_id/ NOsidecar_storage_path— INV-8 column is greenfield;origin_kindCHECK admits all four kinds (:7152).q_a_extractions(:931-950):source_content_item_id uuidis NULLABLE (:933) — so a sidecar’s extraction rows with NO content_item set it NULL cleanly.source_documents(:7460-7483):storage_path text NOT NULL(:7468) is the path the write-back leg reads;filename/mime_type/file_size/content_hashare NOT NULL (the sidecar branch must populate them).
Empirical verification (Q-EX2 / OQ-3 — external-library symbols only)
Section titled “Empirical verification (Q-EX2 / OQ-3 — external-library symbols only)”| Date | Pinned | Symbol | Result |
|---|---|---|---|
| 20/06/2026 | node built-in (node:fs/promises) | readFile / writeFile | PRESENT — already imported + called in writeBackFileFirst (write-back.ts:46); the Q&A legs reuse the identical call shape. Node built-in, not a third-party lib. |
| 14/06/2026 (inherited) | openai@^6.37.0 (installed 6.37.0) | OpenAI.embeddings.create | PRESENT — verified in TECH-qa-corpus-promotion.md §Empirical; the generateEmbedding (lib/ai/embed.ts:97) path is unchanged here. |
No NEW external-library symbol is asserted by this TECH. generateEmbedding,
getAuthorisedClient, tryQuery, writeBackFileFirst, extract_qa_form,
resolve_route are internal KH/Canonical symbols (not externally verified per scope). No
ABSENT/SIGNATURE_DRIFT — TECH is clear for ratification.
Proposed changes
Section titled “Proposed changes”Five artefacts, mapped one-to-one against the PRODUCT invariants:
(P1) the walk skip/route gate (a third resolve_route branch + the __qa__/ routing) —
INV-4/5/6/22; (M1) the q_a_pairs.source_document_id migration — INV-8; (R1) the
corpus-promotion emit leg (folds bl-323) — INV-9/10/11; (R2) the user-direct emit leg +
first-edit materialisation — INV-12/13; (R3) the not-round-tripped enforcement on
re-walk — INV-1/2/3/9; (T1) the round-trip golden-fixture + N-time fixpoint proof (folds
bl-324) — INV-16/17. The UC8 file-merge (R4, INV-14/15) is specified but GATED.
P1 — The walk skip/route gate (maps INV-4, INV-5, INV-6, INV-9 re-walk side, INV-22)
Section titled “P1 — The walk skip/route gate (maps INV-4, INV-5, INV-6, INV-9 re-walk side, INV-22)”The reserved __qa__/ prefix becomes a third RouteKind and a third branch at the
existing fork. Manifest-declared, not hard-coded (OQ-25-1 resolution): an operator adds a
WorkspaceMapping with path_prefix="__qa__/", route="qa_sidecar" to
.kh-workspace-map.json — consistent with the existing manifest-prefix routing and the
zero-behaviour-change default (route defaults "content", so existing manifests parse
unchanged). The string "__qa__/" is FROZEN — ID-45 {45.3} freezes against it.
workspace_resolver.py:55— extend the discriminator.RouteKind = Literal["content", "forms", "qa_sidecar"].Literal+extra="forbid"on the manifest model make any typo a load-timeValidationError(the existing:53-55guarantee extends free).resolve_route(:217-268) needs NO change — it already returnswinner.routeverbatim; a"qa_sidecar"mapping resolves by longest-prefix exactly like"forms".flow.py:1949-1975— add the third dispatch arm. Between theformsarm (:1949) and the content fall-through (:1964):This branch receives ONLYif route == "qa_sidecar":assert resolution is not None # qa_sidecar only reachable via successful resolveawait _ingest_qa_sidecar_branch(file, rel_path, sd_target, qa_target,op_id=op_id, _bump=_bump, resolution=resolution,)returnsd_target+qa_target(NOT ci/cc/em/er) — the type system makes the INV-5 “no content rows” guarantee structural, mirroring how_ingest_form_branchreceives only ft/ftf targets.flow.py— new_ingest_qa_sidecar_branch. Extracted from the content branch’s Q&A-relevant statements ONLY:content_text = await convert_binary_to_markdown(file)(:2003analogue) — the sidecar IS markdown, so the conversion is the identity-ish text read;_bump("binary_conversion").source_document_id = uuid5(_KH_PIPELINE_DOC_NS, "sd:"+rel_path)(:2039analogue) — the SAME derivation the content path uses, so the linkage anchor (INV-8) is stable across re-walks and reorgs (INV-20).sd_target.declare_row(...)(:2066-2088analogue) — mint thesource_documentsrow (storage_path = rel_path,filename/mime_type="text/markdown"/file_size/content_hashfrom the File). This row is the write-back path-resolution target (R2.1) and thesource_document_idlinkage value. It is NOT acontent_itemsrow.qa_form = await extract_qa_form(content_text)(:2017, UNCHANGED extractor) →qa_target.declare_row(...)loop (:2196-2228analogue) with one critical difference:source_content_item_id = None(no content_item minted; the column is nullable,:933). PK stilluuid5("qa:"+rel_path+":"+idx)._bump("postgres_upsert")per row.- OMITS
ci_target/cc_target/em_target/er_targetdeclares + the content embedding (:2095,:2102-2130, chunking:2144+, entity loop:2280+, relationships). This is the load-bearing INV-5 skip: a re-ingest over a folder with N__qa__/sidecars adds ZEROcontent_items. - COCO.10 (INV-6): the inner
extract_qa_formis content-hash-keyed, so a metadata-only touch hits memo and skips the LLM. The outer-tier byte-change is the@coco.fnfile-tier memo onconvert_binary_to_markdown(file)(theFileLikehandle), exactly as the content branch’s outer tier works today.
- AI-invisibility (INV-22). No new user-facing surface; the branch is pipeline
infrastructure. No
proxy.tschange (no HTTP route added by P1).
Linkage closure across the gate. A re-walk mints the
source_documentsrow + theq_a_extractionsrows, but does NOT itself writeq_a_pairs— promotion (R1) is what creates/updates the pair and setssource_document_id. The re-walk → re-extract → re-promote chain is the round-trip (T1); thepromoted_to_pair_ididempotency anchor (existing) + the newsource_document_id(INV-8) jointly prevent duplicate pairs (INV-3).
M1 — Migration: q_a_pairs.source_document_id (maps INV-8)
Section titled “M1 — Migration: q_a_pairs.source_document_id (maps INV-8)”supabase migration new id59_qa_pairs_source_document_id (CLI, never MCP execute_sql per
supabase/CLAUDE.md). Timestamp must sort AFTER 20260619130100 (the current latest;
the recent ID-112 rebase-past-116-collision in git history is the precedent for
timestamp-collision discipline). Contents:
ALTER TABLE public.q_a_pairs ADD COLUMN source_document_id uuid; -- nullable, FK-LESS (BUG-E / R1 precedent)
COMMENT ON COLUMN public.q_a_pairs.source_document_id IS 'ID-59 {59.26}: uuid5-derived (sd:<rel_path>) link to the Q&A sidecar''s ' 'source_documents row. FK-LESS — the cocoindex autocommit write model cannot ' 'satisfy cross-target FKs (migration 20260602073942 / BUG-E). NULL = no sidecar ' 'yet (pre-emit, or a derived_from_form_response pair). NOT round-tripped (INV-9).';- FK-LESS by design (INV-8). No
REFERENCES source_documents(id)— identical to the content_items→source_documents FK that was DROPPED in20260602073942(BUG-E). The cocoindex branch (P1.3) declares thesource_documentsrow in a separate autocommit target from theq_a_pairsUPDATE the promotion leg makes, so a real FK would fail the same way. PostgREST embeds through this relationship would also PGRST200 — R2.1 resolvesstorage_pathvia two plain reads (thewriteBackFileFirstBUG-E pattern), never an embed. - Zero-backfill.
q_a_pairslive count = 0 (the zero-row window, PRODUCT Context). TheADD COLUMNis instant; noUPDATEpass. - No new function / no
REVOKE/ nosearch_path— M1 adds no PL/pgSQL. (The existingq_a_extractions_promotion_candidates()RPC is unchanged.) - No new index needed for v1. The promotion leg writes
source_document_idby pair PK; the re-walk reconciles bypromoted_to_pair_id(existing). Add a partial index ONLY if a future “find pair by sidecar path” read path appears (flag, not v1). - Type regen after push:
supabase gen types …(no hand-edit) — addssource_document_id: string | nulltoq_a_pairsRow/Insert/Update. Runschema-paritybefore any prod cutover.
R1 — Corpus-promotion emit leg (maps INV-9, INV-10, INV-11; folds bl-323)
Section titled “R1 — Corpus-promotion emit leg (maps INV-9, INV-10, INV-11; folds bl-323)”Extend promoteCorpusExtractions (lib/q-a-pairs/promote-corpus.ts) so a successful
promotion ALSO emits the sidecar file + sets the linkage. In-loop, per-pair (OQ-25-3
resolution), in the CAS-won branch (:337-351):
- Emit site. After
promoted++and theembedAndPublishcall succeeds (the pair is published with a real embedding), call a newemitCorpusSidecar(client, newPairId, extraction)helper. Order: the pair must exist + be linked first (the existing CAS guarantees that), then the file is written, thensource_document_idis set on the pair. emitCorpusSidecar— file-first + compensating-restore (reuse thewriteBackFileFirstpattern, NOT necessarily the function). BecausewriteBackFileFirstis keyed oncontentItemIdand resolves viacontent_items.source_document_id, the corpus leg — which has NO content_item and is MINTING thesource_documentsrow — uses the SAME ordering primitive but supplies its own target. Cleanest: factor the ordering core (snapshot→write→applyDbLeg→restore) into a sharedwriteFileFirstWithRestore({absPath, newContent, applyDbLeg})inlib/edit-intent/write-back.tsand have BOTH the content adapter and the two Q&A legs call it. The corpus leg’sabsPathderives from the sidecar’srel_pathunder__qa__/joined withCOCOINDEX_SOURCE_PATH; itsapplyDbLegis theUPDATE q_a_pairs SET source_document_id = <sd_uuid5> WHERE id = newPairId.- Carried set only (INV-2). The file body is the serialised carried set:
question_text,answer_standard,answer_advanced(when present),alternate_question_phrasings,scope_tag,anti_scope_tag— markdown with a small frontmatter block. NO lifecycle state (INV-9 not-carried set) is written. source_document_idvalue.uuid5(_KH_PIPELINE_DOC_NS, "sd:"+relPath)computed in TS to match the Python derivation (P1.3) bit-for-bit — a sharedqaSidecarRelPath(pairId | extractionId)+sdUuid5(relPath)helper keeps the two sides in lock-step. The re-walk (P1.3) re-mints the SAMEsource_documents.id, so the linkage is stable (INV-20).
- Carried set only (INV-2). The file body is the serialised carried set:
- Failure accounting (INV-11, folds bl-323). A promotion that cannot write its sidecar
is a recorded failure, NOT a silent DB-only pair. Add
sidecar_failed: numbertoPromotionSummary(:83-114). On file-write failure the pair is NOT left published-without-a-file: either (a) the emit runs BEFORE publish so a file failure aborts the publish (pair stays draft, countedsidecar_failed, self-heals next run likeembed_failed), or (b) the emit runs after publish and a failure triggers the compensating restore + asidecar_failedcount. Recommend (a) — emit-then-publish, so a sidecar failure leaves the pair in the SAME retryabledraftstate the embed-decouple already produces, and the eligibility RPC re-selects it. bl-323’s per-extraction embed-failure log folds in here: the structured failure record carriesextractionId+newPairId+ the failure reason (embed_failed|sidecar_failed). - INV-10 (embedding is derived, mark-stale not eager). Unchanged from the as-built embed
path — the corpus leg already embeds via
embedAndPublish. The re-walk side (R3) is where MARK-STALE applies.
R2 — User-direct emit leg + first-edit materialisation (maps INV-12, INV-13; reverses DEFERRED-v1.1)
Section titled “R2 — User-direct emit leg + first-edit materialisation (maps INV-12, INV-13; reverses DEFERRED-v1.1)”Replace the app/api/q-a-pairs/[id]/route.ts “NO file write (DEFERRED-v1.1)” behaviour
(:11-13) with a file leg around the existing DB UPDATE (:152-164), gated on the pair’s
sidecar scope (curated_explicit + origin_kind in the INV-7 set).
- Path resolution (FK-less, BUG-E pattern). After resolving the pair, read
q_a_pairs.source_document_id(one read), thensource_documents.storage_pathby PK (a second plain read — NEVER a PostgREST embed, M1 FK-less).absPath = join( COCOINDEX_SOURCE_PATH, storage_path). This is the Q&A analogue ofwriteBackFileFirst:147-204. source_document_id IS NOT NULL(existing sidecar) — write-back (INV-12). Wrap the existing DB UPDATE as theapplyDbLegand call the sharedwriteFileFirstWithRestore({absPath, newContent: serialiseCarriedSet(updatedPair), applyDbLeg}). The file write + theq_a_pairsUPDATE (withedit_intent, via the existing arbitration path at:145-148) are ONE atomic save: file-first, DB-leg-injected, restore-on-DB-failure — identical ordering to the content route. The user sees one save outcome.source_document_id IS NULL(source-less curated) — materialise-on-first-edit (INV-13, OQ-25-4 resolution). When acurated_explicitpair has no sidecar, the route MINTS one on this edit: computerelPath = qaSidecarRelPath(pairId)under__qa__/,absPathunderCOCOINDEX_SOURCE_PATH, write the new.md, and theapplyDbLegUPDATEs BOTH the carried fields ANDsource_document_id = sdUuid5(relPath). The pair becomes file-canonical from this edit forward. This is DISTINCT from the content_item source-less guard (write-back.ts:178, which REFUSES to auto-create) because a__qa__/sidecar does NOT mint acontent_itemsidentity (INV-5) — the source-layout-freeze hazard the content guard protects does not apply. A correspondingsource_documentsrow is NOT minted by the route (the cocoindex re-walk mints it on the next ingest, re-keying the SAMEsd:-seeded uuid5); the linkage value is written now so the round-trip closes.- Caveat to surface to the Checker: R2.3 writes
source_document_idbefore anysource_documentsrow exists (it appears on the next walk). This is intentional and mirrors the FK-LESS contract — there is no FK to violate. The write-back path (R2.1) guards onstorage_pathresolution returning null (no row yet) → falls through to DB-only for that one edit, exactly likewriteBackFileFirst’s idle-mode fall-through (:240-243).
- Caveat to surface to the Checker: R2.3 writes
COCOINDEX_SOURCE_PATHunset (idle mode). Both R2.2 and R2.3 fall through to DB-only when the source-binding folder is unset (write-back.ts:117precedent) — no file leg, the save still lands, self-heals on the next bound walk.- Auth (INV-18). Unchanged — the existing
getAuthorisedClient(['admin','editor'])guard (:120-121) +authFailureResponse. Noproxy.tschange.
R3 — Not-round-tripped enforcement on re-walk (maps INV-1, INV-2, INV-3, INV-9, INV-10)
Section titled “R3 — Not-round-tripped enforcement on re-walk (maps INV-1, INV-2, INV-3, INV-9, INV-10)”The re-walk (P1.3) re-extracts the carried set; promotion (R1) reconverges the pair. The enforcement is that promotion updates ONLY the carried fields and NEVER the not-carried set.
- Carried-only UPDATE on re-promotion. When
promoteCorpusExtractionsre-promotes an extraction whose pair already exists (viapromoted_to_pair_id/source_document_id, INV-3), the UPDATE payload is restricted to the carried set (INV-2). The not-carried set —publication_status,superseded_by,source_workspace_id,question_embedding,edit_intent,valid_from/valid_to,created_at/updated_at,source_document_id— is NEVER in the re-walk UPDATE payload (INV-9). A typedPick<…, CARRIED_FIELDS>payload makes the exclusion compile-checked (mirrors the existing typedPickpayloads atpromote-corpus.ts:596-602/:682-688). - MARK-STALE on
question_textchange (INV-10, OQ-25-2 resolution). When a re-walk changesquestion_text, the existing embedding is stale-but-non-NULL. Rather than re-embed eagerly in-walk (expensive), the re-promotion marks the pair embed-eligible by settingquestion_embedding = NULL(which the existing eligibility RPC re-selects as linked-but-unembedded → the existing self-heal embeds it on the next promotion pass). This keeps the walk cheap and reuses the as-built self-heal (embedAndPublishdecouple). Theq_a_searchpredicate (question_embedding IS NOT NULL AND published) correctly excludes a NULL-embedding pair, so there is NO wrong-vector match window (INV-10’s hard requirement).- Detection. The re-promotion compares the re-extracted
question_textagainst the stored pair’squestion_text; on inequality, NULL the embedding in the SAME carried-set UPDATE (it is a carried field changing, so it is already in the payload; the embedding-NULL rider is the only not-carried touch, and it is the explicit INV-10 exception — documented as such).
- Detection. The re-promotion compares the re-extracted
- Re-walk never duplicates (INV-3). The
promoted_to_pair_idanchor (existing idempotency) +source_document_idjointly key the pair; a re-walk reconstructs the SAME projection. The existing UNIQUE partial indexuq_q_a_extractions_promoted_to_pair_id(from the route-i M1) is the DB backstop.
R4 — UC8 file-side merge (maps INV-14, INV-15) — SPECIFIED BUT GATED
Section titled “R4 — UC8 file-side merge (maps INV-14, INV-15) — SPECIFIED BUT GATED”When UC8 cross-workspace dedup merges two pairs (survivor references superseded via
superseded_by, as retireSupersededPairs already does, promote-corpus.ts:413-644), the
merge MUST also reconcile the sidecar FILES. delete-on-merge (OQ-25-5 recommended): the
survivor keeps its sidecar; the superseded pair’s sidecar is DELETED so a subsequent re-walk
does not resurrect the merged-away pair (the q_a_pair_history DB snapshot + superseded_by
lineage are the durable archive, so the file deletion is not data loss). Leave-and-mark is
REJECTED (a left-behind __qa__/ file re-walks into a fresh extraction → a fresh pair,
re-creating the duplicate). This change does NOT start until UC8 ratification + Spike#10
clear (INV-21) — the subtask is deferred/blocked-pending-UC8. Implementation note for
when it unblocks: the delete is a node:fs/promises.unlink(absPath) of the superseded pair’s
resolved sidecar path, ordered AFTER the DB superseded_by write, with the same idle-mode
fall-through (no COCOINDEX_SOURCE_PATH → skip the unlink, the next walk reconciles).
Shared helpers introduced
Section titled “Shared helpers introduced”lib/q-a-pairs/sidecar-path.ts(new) —qaSidecarRelPath(seed)(the__qa__/-prefixed rel_path),sdUuid5(relPath)(the TS mirror of the Pythonuuid5("sd:"+rel_path)),serialiseCarriedSet(pair)/parseCarriedSet(md)(the markdown frontmatter round-trip — the SINGLE serialisation both emit legs + the golden fixture share). Direct imports, no barrel.lib/edit-intent/write-back.ts— refactor the ordering core intowriteFileFirstWithRestore({absPath, newContent, applyDbLeg})(called by the existingwriteBackFileFirstAND both Q&A legs). This is a pure extraction —writeBackFileFirst’s observable behaviour is unchanged (its tests stay green); rungitnexus_impactonwriteBackFileFirstbefore the refactor (3 callers:runSweep/rollbackSweep/patchHandler).
Testing and validation
Section titled “Testing and validation”Vitest (bun run test, never bun test), behaviour-first per test-philosophy.md (test
the round-trip behaviour, not the serialisation internals). Python pipeline tests via
python3 -m pytest scripts/tests/. Each PRODUCT invariant maps to a concrete check:
- INV-4/5/6 (the gate) — Python pipeline test. A fixture folder with one
__qa__/foo.md- one
bar.md(generic content) → run the walk → assert:bar.mdminted acontent_itemsrow;__qa__/foo.mdminted ZEROcontent_items/content_chunks/entity_mentionsbut DID mint onesource_documentsrow (PK =sd:__qa__/foo.mduuid5) + Nq_a_extractionsrows (source_content_item_id IS NULL). Metadata-only re-touch offoo.md→extract_qa_formmemo hit (LLM not re-called) — assert via a call-count spy on the extractor (INV-6).RouteKindtypo in a manifest → load-timeValidationError(INV-4 frozen-string guard).
- one
- INV-8 (linkage) — migration + unit. Post-
db push,q_a_pairs.source_document_idexists, nullable, no FK constraint (assert viainformation_schemathat no FK referencessource_documentsfrom this column). A promoted corpus pair hassource_document_id = sdUuid5(relPath)matching the Python-mintedsource_documents.id(cross-language uuid5 parity assertion — the load-bearing INV-8 check). - INV-9 (not-round-tripped) — unit. Seed a published pair with non-default
publication_status/question_embedding/edit_intent/source_workspace_id→ re-promote the same extraction with a changedanswer_standard→ assert ONLYanswer_standard(carried) changed; every not-carried field is byte-identical (the S233 gate, re-ratified). - INV-10 (mark-stale) — unit. Re-promote with a changed
question_text→ assertquestion_embeddingis NULL’d (not re-embedded in-walk) AND the pair drops out ofq_a_searchuntil the next embed pass re-embeds it; assert NO published+stale-embedding window (the wrong-vector hazard). - INV-11 (corpus emit + bl-323) — unit. A successful promotion writes the sidecar
.md(carried set only — assert NO lifecycle keys in the file) + setssource_document_id. Stub the file write to throw → pair staysdraft,sidecar_failed++in the summary, NOT published-without-a-file; next run self-heals (the bl-323 per-extraction failure record carriesextractionId). - INV-12 (user-direct write-back) — unit. A PATCH on a pair with
source_document_id IS NOT NULLwrites the carried bytes to the sidecar AND UPDATEs the DB withedit_intent; stub the DB leg to throw AFTER the file write → assert the file is restored to prior bytes (compensating restore) and the user sees one failure. The DEFERRED-v1.1 comment (route.ts:11-13) is gone. - INV-13 (first-edit materialisation) — unit. A PATCH on a
curated_explicitpair withsource_document_id IS NULLmints a sidecar, setssource_document_id, and the pair is file-canonical thereafter; aderived_from_form_responsepair under the same PATCH does NOT materialise (INV-7 scope).COCOINDEX_SOURCE_PATHunset → DB-only fall-through, save still lands. - INV-16/17 (round-trip + N-time fixpoint, bl-324) — golden-fixture integration. The
load-bearing proof: in-platform edit (R2) → carried bytes on disk → cocoindex re-walk
(P1) → re-extract → re-promote (R1+R3) → assert the pair’s carried set equals the
in-platform edit byte-for-byte AND every not-carried field is preserved. Run the
write→re-walk loop N times (≥3) over an unchanged pair → assert a stable fixpoint: no
q_a_pairsoscillation, no history-row churn beyond genuine edits, no embedding drift (INV-17). bl-324 folds in as this fixture’s idempotency assertion. A metadata-only-equivalent edit → the re-walk is a no-op onq_a_pairs(COCO.10 memo short-circuit). - INV-18/19 (auth, workspace scoping) — unit. Viewer → 403 on both legs; neither route
added to
proxy.tspublicRoutes(assert the allowlist); a sidecar emit does not leak a pair across a workspace boundary (existingq_a_pairsRLS +q_a_searchscope-tag filter unchanged). - INV-14/15 (UC8 file-merge) — GATED. Tests authored but
.skip-gated pending UC8 ratification + Spike#10 (INV-21): merge two pairs → survivor sidecar kept, superseded sidecar deleted (no re-walk resurrection); leave-and-mark would resurrect (the rejected path’s negative assertion). - INV-20 (path stability) — Python. Rename
__qa__/foo.md→__qa__/sub/foo.mdwithin the reserved prefix → the re-walk re-keys on the newuuid5but thepromoted_to_pair_idlinkage reconciles rather than minting a duplicate pair (the05-qa-flow.md §5.5caveat).
Risks and mitigations
Section titled “Risks and mitigations”- Cross-language uuid5 drift (HIGH). The linkage anchor (INV-8) requires the TS
sdUuid5("sd:"+relPath)and the Pythonuuid5(_KH_PIPELINE_DOC_NS, "sd:"+rel_path)(flow.py:2039) to produce the IDENTICAL uuid. Mitigation: a dedicated parity test seeds the samerelPathboth sides and asserts equality; the namespace constant (fbfaf1ff-1ee4-583c-9757-1674465b2ec1,flow.py:1612) is duplicated inlib/q-a-pairs/sidecar-path.tswith a comment pointing at the Python source-of-truth. writeBackFileFirstrefactor regression. ExtractingwriteFileFirstWithRestoremust not change the content-leg behaviour (3 callers). Mitigation:gitnexus_impact+gitnexus_detect_changesbefore commit; the existingwriteBackFileFirsttests are the regression gate (behaviour unchanged).- REST PATCH silent no-op (CLAUDE.md gotcha). Both emit legs’ DB UPDATEs (set
source_document_id, carried fields) must assert affected-row count = 1 — a 0-row PATCH is a silent failure. Mirrors the as-built CAS affected-row discipline (promote-corpus.ts:328). source_document_idwritten before itssource_documentsrow exists (R2.3). Intentional (FK-LESS), but a naive reader may flag it. Mitigation: documented in the M1 column comment + R2.3 caveat; the write-back path guards on nullstorage_pathresolution.- Manifest mis-declaration of
__qa__/. If an operator forgets theqa_sidecarmapping,__qa__/*.mdfalls to thecontentdefault → junk content_items (the exact S297 hazard). Mitigation: the ID-45{45.3}re-ingest gate freezes against the__qa__/string; the walk should_logger.warnwhen a__qa__/-prefixed path resolves tocontent(a defensive belt-and-braces sentinel the implementer adds at the fork). CREATE INDEXnot needed (noCONCURRENTLY-in-txn risk). M1 adds only anADD COLUMNon a zero-row table — instant, no index, no txn-block hazard.- Schema parity prod↔staging. Push M1 to staging,
cat supabase/.temp/project-refbefore push, runschema-paritybefore any prod cutover (CLAUDE.md migration discipline). Index build moot (no index).
Open questions surfaced by this TECH
Section titled “Open questions surfaced by this TECH”- NEW-OQ-26-1 (shared-core refactor scope). R1/R2 share
writeFileFirstWithRestore. The cleanest factoring extracts it fromwriteBackFileFirst; an acceptable alternative duplicates the small ordering core in a Q&A-local helper to avoid touching the content adapter’s 3 callers. Recommend the extraction (one ordering primitive, DRY) but flag for the Executor/Checker to confirm at impl — does not block ratification. - NEW-OQ-26-2 (markdown frontmatter shape).
serialiseCarriedSet/parseCarriedSetneed a concrete frontmatter schema (YAML keys forscope_tag/anti_scope_tag/alternate_question_phrasings; body for question/answers). Recommend a minimal YAML frontmatter + a## Question/## Answer (standard)/## Answer (advanced)body so the file is human-readable. Implementation detail; the golden fixture (T1) pins it. Not a product gate. - No cross-Task dependency surfaced beyond the EXISTING ID-45
{45.3}ordering (the__qa__/freeze, tracked at Task level per PRODUCT). Sibling-only Subtask-dependency constraint holds within ID-59’s decomposition; no escalation needed.
End of TECH. Proposed changes P1 (walk gate) + M1 (source_document_id migration) + R1
(corpus emit, folds bl-323) + R2 (user-direct emit + first-edit materialisation) + R3
(not-round-tripped enforcement) + R4 (UC8 file-merge, GATED) map 1:1 to PRODUCT invariants
INV-1..INV-22. Next chain step: decompose implementation into Subtasks {59.27+} (the
finalised set is returned to the Orchestrator alongside this TECH).