Skip to content

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 ratified PRODUCT-qa-sidecar-canonical.md in 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.md per-UC write-back + edit_intent; PRODUCT-qa-corpus-promotion.md/TECH-qa-corpus-promotion.md route-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 THIRD resolve_route branch; q_a_pairs.source_document_id uuid uuid5-derived FK-LESS linkage (INV-8); the DB-only not-round-tripped column set (INV-9); sidecar emit for extracted_from_corpus + curated_explicit ONLY (derived_from_form_response deferred 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).


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_idstorage_path resolution (Proposed change R2.1) and call the same readFilewriteFileapplyDbLeg→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.

  • The fork the gate slots into. scripts/cocoindex_pipeline/flow.py:1902-1975_ingest_file_body computes 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:55RouteKind = Literal["content", "forms"]; :116-129WorkspaceMapping carries path_prefix + workspace_id + route (defaults "content", :128); :217-268resolve_route longest-prefix-wins, returns Resolution(workspace_id, route) (:169-178). Adding "qa_sidecar" to RouteKind + a manifest mapping with path_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 .md suffix matches → convert_binary_to_markdown → full content branch. An UN-gated __qa__/*.md would mint junk content_items (S297 BUG-B). The reserved-DIR gate routes at resolve_route BEFORE the suffix is consulted, so adapters.py:62 is 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:2003 convert_binary_to_markdown; :2017 extract_qa_form(content_text); :2039-2040 source_document_id = uuid5(_KH_PIPELINE_DOC_NS, "sd:"+rel_path) / content_item_id = uuid5(..., "ci:"+rel_path); :2066-2088 sd_target.declare_row (source_documents UPSERT, PK = sd:-seeded); :2102-2125 ci_target.declare_row (content_items, INCLUDES the embedding); :2196-2228 the qa_target.declare_row loop (q_a_extractions, PK = uuid5("qa:"+rel_path+":"+idx), source_content_item_id = content_item_id). The sidecar branch runs extract_qa_form + the sd_target declare + the qa_target loop, and OMITS ci_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_branch touches none of ci/sd/cc/qa targets and computes content_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 a source_documents row (the linkage-anchor target) + q_a_extractions rows.
  • The inner two-tier extractor (COCO.10-safe). extraction.py:1062 async def extract_qa_form(content_text: str) -> QAFormExtraction — content-hash-keyed (@coco.fn memo on content_text, not FileLike), 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-296 inserts the draft pair; :337-351 is the CAS-won branch (promoted++ then embedAndPublish). The emit + linkage write attach here. The PromotionSummary interface (:83-114) gains a sidecar_failed count.
  • 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-164 the tryQuery UPDATE the file leg wraps.
  • Schema (squash baseline 20260617130000). q_a_pairs (:7128-7156) has NO source_document_id / NO sidecar_storage_path — INV-8 column is greenfield; origin_kind CHECK admits all four kinds (:7152). q_a_extractions (:931-950): source_content_item_id uuid is 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_hash are 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)”
DatePinnedSymbolResult
20/06/2026node built-in (node:fs/promises)readFile / writeFilePRESENT — 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.createPRESENT — 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.


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.

  1. 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-time ValidationError (the existing :53-55 guarantee extends free). resolve_route (:217-268) needs NO change — it already returns winner.route verbatim; a "qa_sidecar" mapping resolves by longest-prefix exactly like "forms".
  2. flow.py:1949-1975 — add the third dispatch arm. Between the forms arm (:1949) and the content fall-through (:1964):
    if route == "qa_sidecar":
    assert resolution is not None # qa_sidecar only reachable via successful resolve
    await _ingest_qa_sidecar_branch(
    file, rel_path, sd_target, qa_target,
    op_id=op_id, _bump=_bump, resolution=resolution,
    )
    return
    This branch receives ONLY sd_target + qa_target (NOT ci/cc/em/er) — the type system makes the INV-5 “no content rows” guarantee structural, mirroring how _ingest_form_branch receives only ft/ftf targets.
  3. 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) (:2003 analogue) — 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) (:2039 analogue) — 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-2088 analogue) — mint the source_documents row (storage_path = rel_path, filename/mime_type="text/markdown"/file_size/ content_hash from the File). This row is the write-back path-resolution target (R2.1) and the source_document_id linkage value. It is NOT a content_items row.
    • qa_form = await extract_qa_form(content_text) (:2017, UNCHANGED extractor) → qa_target.declare_row(...) loop (:2196-2228 analogue) with one critical difference: source_content_item_id = None (no content_item minted; the column is nullable, :933). PK still uuid5("qa:"+rel_path+":"+idx). _bump("postgres_upsert") per row.
    • OMITS ci_target / cc_target / em_target / er_target declares + 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 ZERO content_items.
    • COCO.10 (INV-6): the inner extract_qa_form is content-hash-keyed, so a metadata-only touch hits memo and skips the LLM. The outer-tier byte-change is the @coco.fn file-tier memo on convert_binary_to_markdown(file) (the FileLike handle), exactly as the content branch’s outer tier works today.
  4. AI-invisibility (INV-22). No new user-facing surface; the branch is pipeline infrastructure. No proxy.ts change (no HTTP route added by P1).

Linkage closure across the gate. A re-walk mints the source_documents row + the q_a_extractions rows, but does NOT itself write q_a_pairs — promotion (R1) is what creates/updates the pair and sets source_document_id. The re-walk → re-extract → re-promote chain is the round-trip (T1); the promoted_to_pair_id idempotency anchor (existing) + the new source_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 in 20260602073942 (BUG-E). The cocoindex branch (P1.3) declares the source_documents row in a separate autocommit target from the q_a_pairs UPDATE the promotion leg makes, so a real FK would fail the same way. PostgREST embeds through this relationship would also PGRST200 — R2.1 resolves storage_path via two plain reads (the writeBackFileFirst BUG-E pattern), never an embed.
  • Zero-backfill. q_a_pairs live count = 0 (the zero-row window, PRODUCT Context). The ADD COLUMN is instant; no UPDATE pass.
  • No new function / no REVOKE / no search_path — M1 adds no PL/pgSQL. (The existing q_a_extractions_promotion_candidates() RPC is unchanged.)
  • No new index needed for v1. The promotion leg writes source_document_id by pair PK; the re-walk reconciles by promoted_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) — adds source_document_id: string | null to q_a_pairs Row/Insert/Update. Run schema-parity before 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):

  1. Emit site. After promoted++ and the embedAndPublish call succeeds (the pair is published with a real embedding), call a new emitCorpusSidecar(client, newPairId, extraction) helper. Order: the pair must exist + be linked first (the existing CAS guarantees that), then the file is written, then source_document_id is set on the pair.
  2. emitCorpusSidecar — file-first + compensating-restore (reuse the writeBackFileFirst pattern, NOT necessarily the function). Because writeBackFileFirst is keyed on contentItemId and resolves via content_items.source_document_id, the corpus leg — which has NO content_item and is MINTING the source_documents row — uses the SAME ordering primitive but supplies its own target. Cleanest: factor the ordering core (snapshot→write→applyDbLeg→restore) into a shared writeFileFirstWithRestore({absPath, newContent, applyDbLeg}) in lib/edit-intent/write-back.ts and have BOTH the content adapter and the two Q&A legs call it. The corpus leg’s absPath derives from the sidecar’s rel_path under __qa__/ joined with COCOINDEX_SOURCE_PATH; its applyDbLeg is the UPDATE 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_id value. uuid5(_KH_PIPELINE_DOC_NS, "sd:"+relPath) computed in TS to match the Python derivation (P1.3) bit-for-bit — a shared qaSidecarRelPath(pairId | extractionId) + sdUuid5(relPath) helper keeps the two sides in lock-step. The re-walk (P1.3) re-mints the SAME source_documents.id, so the linkage is stable (INV-20).
  3. 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: number to PromotionSummary (: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, counted sidecar_failed, self-heals next run like embed_failed), or (b) the emit runs after publish and a failure triggers the compensating restore + a sidecar_failed count. Recommend (a) — emit-then-publish, so a sidecar failure leaves the pair in the SAME retryable draft state 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 carries extractionId + newPairId + the failure reason (embed_failed | sidecar_failed).
  4. 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).

  1. Path resolution (FK-less, BUG-E pattern). After resolving the pair, read q_a_pairs.source_document_id (one read), then source_documents.storage_path by 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 of writeBackFileFirst:147-204.
  2. source_document_id IS NOT NULL (existing sidecar) — write-back (INV-12). Wrap the existing DB UPDATE as the applyDbLeg and call the shared writeFileFirstWithRestore({absPath, newContent: serialiseCarriedSet(updatedPair), applyDbLeg}). The file write + the q_a_pairs UPDATE (with edit_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.
  3. source_document_id IS NULL (source-less curated) — materialise-on-first-edit (INV-13, OQ-25-4 resolution). When a curated_explicit pair has no sidecar, the route MINTS one on this edit: compute relPath = qaSidecarRelPath(pairId) under __qa__/, absPath under COCOINDEX_SOURCE_PATH, write the new .md, and the applyDbLeg UPDATEs BOTH the carried fields AND source_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 a content_items identity (INV-5) — the source-layout-freeze hazard the content guard protects does not apply. A corresponding source_documents row is NOT minted by the route (the cocoindex re-walk mints it on the next ingest, re-keying the SAME sd:-seeded uuid5); the linkage value is written now so the round-trip closes.
    • Caveat to surface to the Checker: R2.3 writes source_document_id before any source_documents row 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 on storage_path resolution returning null (no row yet) → falls through to DB-only for that one edit, exactly like writeBackFileFirst’s idle-mode fall-through (:240-243).
  4. COCOINDEX_SOURCE_PATH unset (idle mode). Both R2.2 and R2.3 fall through to DB-only when the source-binding folder is unset (write-back.ts:117 precedent) — no file leg, the save still lands, self-heals on the next bound walk.
  5. Auth (INV-18). Unchanged — the existing getAuthorisedClient(['admin','editor']) guard (:120-121) + authFailureResponse. No proxy.ts change.

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.

  1. Carried-only UPDATE on re-promotion. When promoteCorpusExtractions re-promotes an extraction whose pair already exists (via promoted_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 typed Pick<…, CARRIED_FIELDS> payload makes the exclusion compile-checked (mirrors the existing typed Pick payloads at promote-corpus.ts:596-602 / :682-688).
  2. MARK-STALE on question_text change (INV-10, OQ-25-2 resolution). When a re-walk changes question_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 setting question_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 (embedAndPublish decouple). The q_a_search predicate (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_text against the stored pair’s question_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).
  3. Re-walk never duplicates (INV-3). The promoted_to_pair_id anchor (existing idempotency) + source_document_id jointly key the pair; a re-walk reconstructs the SAME projection. The existing UNIQUE partial index uq_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).

  • lib/q-a-pairs/sidecar-path.ts (new) — qaSidecarRelPath(seed) (the __qa__/-prefixed rel_path), sdUuid5(relPath) (the TS mirror of the Python uuid5("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 into writeFileFirstWithRestore({absPath, newContent, applyDbLeg}) (called by the existing writeBackFileFirst AND both Q&A legs). This is a pure extraction — writeBackFileFirst’s observable behaviour is unchanged (its tests stay green); run gitnexus_impact on writeBackFileFirst before the refactor (3 callers: runSweep/rollbackSweep/ patchHandler).

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.md minted a content_items row; __qa__/foo.md minted ZERO content_items/content_chunks/entity_mentions but DID mint one source_documents row (PK = sd:__qa__/foo.md uuid5) + N q_a_extractions rows (source_content_item_id IS NULL). Metadata-only re-touch of foo.mdextract_qa_form memo hit (LLM not re-called) — assert via a call-count spy on the extractor (INV-6). RouteKind typo in a manifest → load-time ValidationError (INV-4 frozen-string guard).
  • INV-8 (linkage) — migration + unit. Post-db push, q_a_pairs.source_document_id exists, nullable, no FK constraint (assert via information_schema that no FK references source_documents from this column). A promoted corpus pair has source_document_id = sdUuid5(relPath) matching the Python-minted source_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 changed answer_standard → assert ONLY answer_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 → assert question_embedding is NULL’d (not re-embedded in-walk) AND the pair drops out of q_a_search until 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) + sets source_document_id. Stub the file write to throw → pair stays draft, sidecar_failed++ in the summary, NOT published-without-a-file; next run self-heals (the bl-323 per-extraction failure record carries extractionId).
  • INV-12 (user-direct write-back) — unit. A PATCH on a pair with source_document_id IS NOT NULL writes the carried bytes to the sidecar AND UPDATEs the DB with edit_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_explicit pair with source_document_id IS NULL mints a sidecar, sets source_document_id, and the pair is file-canonical thereafter; a derived_from_form_response pair under the same PATCH does NOT materialise (INV-7 scope). COCOINDEX_SOURCE_PATH unset → 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_pairs oscillation, 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 on q_a_pairs (COCO.10 memo short-circuit).
  • INV-18/19 (auth, workspace scoping) — unit. Viewer → 403 on both legs; neither route added to proxy.ts publicRoutes (assert the allowlist); a sidecar emit does not leak a pair across a workspace boundary (existing q_a_pairs RLS + q_a_search scope-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.md within the reserved prefix → the re-walk re-keys on the new uuid5 but the promoted_to_pair_id linkage reconciles rather than minting a duplicate pair (the 05-qa-flow.md §5.5 caveat).

  • Cross-language uuid5 drift (HIGH). The linkage anchor (INV-8) requires the TS sdUuid5("sd:"+relPath) and the Python uuid5(_KH_PIPELINE_DOC_NS, "sd:"+rel_path) (flow.py:2039) to produce the IDENTICAL uuid. Mitigation: a dedicated parity test seeds the same relPath both sides and asserts equality; the namespace constant (fbfaf1ff-1ee4-583c-9757-1674465b2ec1, flow.py:1612) is duplicated in lib/q-a-pairs/sidecar-path.ts with a comment pointing at the Python source-of-truth.
  • writeBackFileFirst refactor regression. Extracting writeFileFirstWithRestore must not change the content-leg behaviour (3 callers). Mitigation: gitnexus_impact + gitnexus_detect_changes before commit; the existing writeBackFileFirst tests 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_id written before its source_documents row 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 null storage_path resolution.
  • Manifest mis-declaration of __qa__/. If an operator forgets the qa_sidecar mapping, __qa__/*.md falls to the content default → 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.warn when a __qa__/-prefixed path resolves to content (a defensive belt-and-braces sentinel the implementer adds at the fork).
  • CREATE INDEX not needed (no CONCURRENTLY-in-txn risk). M1 adds only an ADD COLUMN on a zero-row table — instant, no index, no txn-block hazard.
  • Schema parity prod↔staging. Push M1 to staging, cat supabase/.temp/project-ref before push, run schema-parity before any prod cutover (CLAUDE.md migration discipline). Index build moot (no index).

  • NEW-OQ-26-1 (shared-core refactor scope). R1/R2 share writeFileFirstWithRestore. The cleanest factoring extracts it from writeBackFileFirst; 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/parseCarriedSet need a concrete frontmatter schema (YAML keys for scope_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).