Skip to content

ID-120 {120.3} TECH — Q&A dedup across one client's workspaces & forms (Stage-5-style proposer post-pass + curator-approved supersession write)

ID-120 {120.3} TECH — intra-tenant Q&A dedup (proposer post-pass + curator-approved merge)

Section titled “ID-120 {120.3} TECH — intra-tenant Q&A dedup (proposer post-pass + curator-approved merge)”

Inputs (read in full): {120.1} RESEARCH.md (Liam-RATIFIED), {120.2} PRODUCT.md (Checker-PASS, Liam-RATIFIED), DECISION-BRIEFING-FOR-LIAM.md (all four decisions CLOSED, S391). This TECH does not re-derive them; it maps an implementation 1:1 against PRODUCT.md’s numbered invariants INV-1..INV-23 and resolves the TECH-delegated open questions (threshold value, index posture, proposal-store substrate, re-propose mechanism).

cross-workspace (per PRODUCT, applies throughout): across the one client’s workspaces and forms (intra-tenant); deployment is one Supabase database per client, so the DB itself is the tenant boundary and RLS USING(true) on q_a_pairs is correct. Cross-TENANT dedup is never-v1.

Spec-chain link 3 of {120.1 RESEARCH → 120.2 PRODUCT → 120.3 TECH → 120.4 PLAN}. A {120.4} PLAN is warranted (chain-dependent slices — see §Parallelization); confirm at dispatch.

ID-120 adds a walk-time proposer post-pass that reads the one client’s whole published q_a_pairs corpus, computes question_embedding cosine similarity to find near-duplicate questions across that client’s workspaces and forms, and writes pending merge proposals to a new lightweight table. A curator reviews each proposal in an admin surface and, on approval, fires the existing supersession write (archive the non-survivor + set superseded_by to the curator-chosen survivor). It is a PROPOSER, never an auto-merge (PRODUCT INV-4, INV-9). Three things are new (a candidate-population read, a proposal record + review surface, an approval→write wiring); the similarity surface, the post-pass scaffold, and the archive primitive already exist.

Code-intelligence orientation (verbatim, repo:'canonical'):

  • gitnexus_context retireSupersededPairsFunction lib/q-a-pairs/promote-corpus.ts:retireSupersededPairs (startLine 826, endLine 1057); incoming calls: exactly ONEpromoteCorpusExtractions; outgoing call safeErrorMessage (lib/error.ts). The archive UPDATE the proposer’s approval reuses lives at lib/q-a-pairs/promote-corpus.ts:1010–1045 (publication_status:'archived', superseded_by:replacementPairId, CAS .eq('publication_status','published'), affected-row guard). Caveat (load-bearing): retireSupersededPairs itself is keyed to q_a_extractions.promoted_to_pair_id lineage + a source_content_item_id auto-replacement lookup (:974–1001) — it picks the survivor automatically from extraction lineage. ID-120’s survivor is curator-chosen and arbitrary (cross-workspace/cross-form), so the approval path reuses the archive primitive (the :1010–1045 UPDATE shape) but does not call retireSupersededPairs — see P-5.
  • gitnexus_context promoteCorpusExtractionsFunction lib/q-a-pairs/promote-corpus.ts:promoteCorpusExtractions (startLine 234, endLine 525); incoming calls: exactly ONEPOST (app/api/q-a-pairs/promote-corpus/route.ts); outgoing calls include tryQuery (lib/supabase/safe.ts) — the safe-access wrapper new TS write paths must use.
  • gitnexus_query 'stage 5 post pass entity resolution attach after handle ready' → returned the Stage-5 Python symbols only (_run_stage_5_resolution scripts/cocoindex_pipeline/stage_5.py:204, _select_run_entity_mentions :96, attach in flow.py); no indexed cross-workspace-dedup flow — confirming this is a new pass on an existing substrate, not an extension of an indexed flow. The Python pipeline + SQL migrations are outside the TS graph (RESEARCH §2.1); those surfaces are grounded by direct migration/code reads below, per the .ast-dataflow/CLAUDE.md “ts-morph covers TS only” note.
  • Precedent surface found (cite, mirror — do NOT invent a new shape): an Admin Near-Duplicate Merge Dashboard already exists for content_items dedup, with the exact list→detail→resolve shape ID-120 needs:
    • routes: app/api/admin/content-dedup/near-duplicates/route.ts (list), …/near-duplicates/[pairId]/route.ts (detail), …/near-duplicates/[pairId]/merge/route.ts (the resolve/merge write), …/near-duplicates/[pairId]/confirm-unique/route.ts (reject);
    • pages: app/admin/content-dedup/near-duplicates/page.tsx + …/[pairId]/page.tsx;
    • components: components/admin/content-dedup/near-duplicates/ (near-duplicates-pair-list.tsx, near-duplicates-pair-detail.tsx, near-duplicates-pair-row-card.tsx, near-duplicates-merge-direction-dialog.tsx = the survivor-override affordance, near-duplicates-empty-state.tsx, near-duplicates-filter-bar.tsx, near-duplicates-action-buttons.tsx);
    • query keys: keys.adminNearDup (lib/query/query-keys.ts:398–404, keyed by threshold).

Live schema (direct migration reads, supabase/migrations/20260617130000_squash_baseline.sql):

  • q_a_pairs CREATE :7128; question_embedding extensions.vector(1024) (NULLABLE) :7144; superseded_by uuid :7138; source_form_response_id/source_question_id :7146/:7147; publication_status CHECK draft|in_review|published|archived; FK q_a_pairs_superseded_by_fkey … REFERENCES q_a_pairs(id) ON DELETE SET NULL :9889; RLS SELECT q_a_pairs_select … USING (true) :10904. source_workspace_id is a real column with a btree index (20260619120100_index_unindexed_fks.sql:30).
  • q_a_search RPC :4268SECURITY DEFINER, returns previews + embedding_score = (1.0 - (qap.question_embedding <=> p_query_embedding))::numeric(5,4), filters WHERE qap.question_embedding IS NOT NULL AND qap.publication_status = 'published' (:4299–4300); COMMENT: “Scope filtering is caller-side” :4322. This is the canonical cosine expression the proposer mirrors. No USING hnsw/USING ivfflat index on question_embedding exists (grep over all migrations, RESEARCH §4(d)) → brute-force today.
  • q_a_pair_history CREATE :7065 — append-only mirror snapshotting source_workspace_id + superseded_by by value at each transition (column comments ID-64.15), written by the existing q_a_pairs_history_trigger(). This is the canonical lineage record; ID-120 adds no new provenance store (INV-16).

Stage-5 substrate (direct reads): scripts/cocoindex_pipeline/stage_5.py:204 (_run_stage_5_resolution) is the post-pass scaffold — reads a population via asyncpg (db_pool.fetch), computes a similarity pass, issues scoped writes. It is attached imperatively in scripts/cocoindex_pipeline/flow.py (the Stage-5 block ~:3690–3712) after await handle.ready() and before the flow-end webhook, wrapped in try/except that re-raises as _EntityResolutionStageError so _classify_stage_exception attributes a stage failure without aborting the walk. ID-120’s proposer adopts the identical attach pattern with its own stage-error class. The asyncpg pool is resolved env-scope via coco.use_context(DB_CTX) (no second pool).

P-1 — New proposal-store table + migration (resolves OQ-120-proposal-store)

Section titled “P-1 — New proposal-store table + migration (resolves OQ-120-proposal-store)”

New table public.q_a_pair_dedup_proposals (a lightweight proposal record — distinct from the canonical lineage record q_a_pair_history, which is NOT new — INV-16). Mirrors the content-dedup near-dup precedent.

  • Migration: supabase migration new qa_pair_dedup_proposals → e.g. supabase/migrations/<ts>_qa_pair_dedup_proposals.sql. Apply via supabase db push foreground (interactive CLI hangs background shells — CLAUDE.md). Regen types: bun run the supabase type regen per supabase/CLAUDE.md; consume via Tables<'q_a_pair_dedup_proposals'> — never hand-edit database.types.ts.
  • Columns: id uuid PK default gen_random_uuid(); pair_a_id uuid NOT NULL REFERENCES q_a_pairs(id) ON DELETE CASCADE; pair_b_id uuid NOT NULL REFERENCES q_a_pairs(id) ON DELETE CASCADE; similarity_score numeric(5,4) NOT NULL; proposed_survivor_id uuid NOT NULL (one of A/B, CHECK proposed_survivor_id IN (pair_a_id, pair_b_id)); survivor_reason text NOT NULL (the displayed selection basis — INV-12); status text NOT NULL DEFAULT 'pending' CHECK status IN ('pending','approved','rejected') (INV-15 terminal states); pair_a_source_workspace_id uuid, pair_b_source_workspace_id uuid, pair_a_source_form_response_id uuid, pair_b_source_form_response_id uuid (denormalised provenance for self-describing review/audit — INV-16; NULLABLE, no FK, snapshot-by-value); pair_a_fingerprint text, pair_b_fingerprint text (re-propose watermark — P-7); resolved_survivor_id uuid (the curator’s actual chosen survivor at approval, may differ from proposed_survivor_id — INV-13); created_at/resolved_at timestamptz; resolved_by uuid (curator). Ordering invariant: enforce pair_a_id < pair_b_id (CHECK) so a pair (A,B) is canonical regardless of discovery order, backing idempotency (INV-4) and “already-actioned not re-proposed” (INV-5).
  • Uniqueness: UNIQUE (pair_a_id, pair_b_id) so a given pair yields at most one proposal row — re-runs UPSERT/skip rather than duplicate (INV-4). A row in approved/rejected is never resurfaced as pending (INV-5) unless P-7’s fingerprint changes.
  • RLS: enable RLS; SELECT/UPDATE policies gated to admin/editor (mirror the role-based posture of the q_a_pairs policies — 20260619120000_rls_initplan_wrap_qa.sql), viewer denied (INV-22). The proposer’s INSERT runs under the service-role client (P-3), which bypasses RLS — keep the policy author-side simple (admin/editor read+update for the curator surface).
  • REVOKE/grants: no new public.*() function is strictly required (the proposer is Python-side); IF a list/aggregate RPC is added (see P-6 note), it MUST include SET search_path = public, extensions and REVOKE EXECUTE … FROM anon;.

P-2 — The proposer post-pass (Python; new module, repointed scaffold) — INV-1..INV-5

Section titled “P-2 — The proposer post-pass (Python; new module, repointed scaffold) — INV-1..INV-5”

New scripts/cocoindex_pipeline/qa_dedup_proposer.py with async def _run_qa_dedup_proposer(*, db_pool, flow_stage_counter) -> int (returns proposals written), adopting the stage_5.py:204 scaffold but with the q_a_pairs population + cosine surface

  • propose action.
  • Candidate read (INV-2, INV-6, INV-7): a single service-role asyncpg read of the whole client published, embedding-bearing populationSELECT id, question_text, question_embedding, source_workspace_id, source_form_response_id, source_question_id, publication_status, updated_at, <confidence/quality cols if present> FROM public.q_a_pairs WHERE publication_status = 'published' AND question_embedding IS NOT NULL AND superseded_by IS NULL — deliberately no source_workspace_id filter (the named, confined widening — INV-6). This is the second deliberate Q&A-read widening after the Stage-5 ID-80.14 op_id exception; it is scoped to this one read and to this named caller only (INV-8).
  • Similarity (INV-3, INV-21): compute pairwise cosine over question_embedding using the SQL <=> operator mirroring q_a_search (1.0 - (a.question_embedding <=> b.question_embedding)), not an in-Python recompute, so the candidate set is identical regardless of index posture (INV-21). Brute-force v1 (P-6): no HNSW migration. The pass issues a self-join candidate query (see P-6) capped per survivor; one proposal pairs exactly two distinct pairs (INV-3); never a pair against itself; chains are emitted as separate pairwise proposals (INV-7).
  • Threshold (INV-19, INV-20 — resolves OQ-120-3-threshold): a single named, tunable constant QA_DEDUP_COSINE_THRESHOLD (env-overridable; one definition, never duplicated across call sites — INV-20). v1 default = 0.92 (precision-first/conservative). This is a starting value: before first production enable, calibrate by sampling the live question_embedding distribution (compute the cosine distribution over a sample of known-duplicate and known-distinct pairs) and adjust so the curator reject-rate target stays low (INV-20). Record the calibrated value in the {120.4} PLAN/journal. The threshold gates which pairs become proposals.
  • No writes to q_a_pairs (INV-4): the pass writes ONLY to q_a_pair_dedup_proposals (UPSERT on (pair_a_id,pair_b_id), ON CONFLICT DO NOTHING for an unchanged pending/terminal row — idempotent; INV-4/INV-5). It never touches publication_status/superseded_by on q_a_pairs.
  • Survivor nomination (INV-12): compute proposed_survivor_id + survivor_reason by the displayed v1 policy in order: (1) publication_status (published beats non-published); (2) confidence/quality signal where a column exists on the pair; (3) recency (later updated_at/created_at survives). Store the human-readable reason (e.g. “survivor: more recent (updated 15/06/2026)”).

P-3 — Attach the proposer to the pipeline walk (Python) — INV-1, INV-7

Section titled “P-3 — Attach the proposer to the pipeline walk (Python) — INV-1, INV-7”

In scripts/cocoindex_pipeline/flow.py, add an attach after the Stage-5 block (after await handle.ready(), before the flow-end webhook), mirroring the Stage-5 imperative-await + re-wrap pattern verbatim:

try:
proposed_count = await _run_qa_dedup_proposer(
db_pool=coco.use_context(DB_CTX),
flow_stage_counter=flow_stage_counter,
)
except Exception as exc: # noqa: BLE001 — re-wrap for classification
raise _QaDedupProposerStageError(str(exc)) from exc

Add _QaDedupProposerStageError (new class beside _EntityResolutionStageError) and extend _classify_stage_exception to map it to a new qa_dedup_proposer_failed stage code, so a proposer failure is contained + attributed without aborting the walk (the existing Stage-5 containment contract). The proposer is the named automated service-role caller (INV-7); no interactive user triggers this read — that IS the operational named-proposer boundary (INV-8).

P-4 — Curator review surface (admin pages + components) — INV-10, INV-12, INV-13, INV-17, INV-18, INV-19

Section titled “P-4 — Curator review surface (admin pages + components) — INV-10, INV-12, INV-13, INV-17, INV-18, INV-19”

Mirror the content-dedup near-dup dashboard one-for-one under a q_a_pairs namespace:

  • Pages: app/admin/q-a-pairs/dedup-proposals/page.tsx (list) + app/admin/q-a-pairs/dedup-proposals/[proposalId]/page.tsx (detail/resolve). Both server pages do the getAuthorisedClient(['admin','editor']) gate; viewer never reaches the surface (INV-22).
  • Components under components/admin/q-a-pairs/dedup-proposals/: proposal-list.tsx, proposal-detail.tsx (both questions + both answers side-by-side — intra-tenant, the client’s own corpus, INV-10), proposal-row-card.tsx, survivor-override-dialog.tsx (the override affordance — INV-13, mirroring near-duplicates-merge-direction-dialog.tsx), empty-state.tsx (“no pending duplicate proposals” — INV-19), filter-bar.tsx, action-buttons.tsx (per-pair approve/reject — INV-17; approve is never pre-selected).
  • Each proposal shows: both question texts + both answers; each pair’s source_workspace_id and source_form_response_id; publication_status; last-updated DD/MM/YYYY; any confidence signal; the nominated survivor + reason; per-pair approve/reject + override (INV-18). A proposal that spans workspaces/forms is badged with a non-colour-only label (WCAG 2.1 AA) reading “spans workspaces/forms” — NOT “cross-tenant” (INV-11/INV-18); the badge signals canonical ownership moves within the client.
  • UI discipline (quality bars): Warm Meridian semantic tokens only (no raw Tailwind colours — components/CLAUDE.md); UK English; DD/MM/YYYY; explicit empty/loading/error states (skeleton while loading, clear error affordance on fetch/merge failure — INV-19). The similarity score is at most a subordinate “match strength” affordance, never an AI-confidence headline (INV-23, ai-visibility-policy.md).
  • Data fetching: TanStack Query exclusively. Add keys.adminQaDedup to lib/query/query-keys.ts mirroring keys.adminNearDup (:398): all, queue(filters?), proposal(id). Fetchers in lib/query/fetchers.ts.

P-5 — Approval/reject API + the reused supersession write — INV-9, INV-13, INV-14, INV-15

Section titled “P-5 — Approval/reject API + the reused supersession write — INV-9, INV-13, INV-14, INV-15”

Two routes via defineRoute (mirror app/api/q-a-pairs/promote-corpus/route.ts header + guard shape), NOT in proxy.ts publicRoutes (authenticated; in-handler role guard):

  • POST app/api/q-a-pairs/dedup-proposals/[proposalId]/approve/route.ts — body carries the curator’s chosen survivor_id (defaults to proposed_survivor_id; may be overridden — INV-13). Guard: const auth = await getAuthorisedClient(['admin','editor']); if (!auth.success) return authFailureResponse(auth);. The write runs under the curator’s own role-scoped client (auth.supabase) — NOT service-role (INV-9). It does the archive UPDATE on the non-survivor: publication_status:'archived', superseded_by:<survivor_id>, with CAS .eq('publication_status','published') + affected-row guard (mirror promote-corpus.ts:1010–1045); then sets the proposal status='approved', resolved_survivor_id, resolved_by, resolved_at. New helper (NOT a call into retireSupersededPairs, which is extraction-lineage-keyed): add mergeDedupPair(client, { survivorId, nonSurvivorId }) in lib/q-a-pairs/dedup-merge.ts using sb()/tryQuery() (@/lib/supabase/safe) — direct file import, no barrel. The q_a_pair_history trigger fires automatically on the archive UPDATE → provenance recorded (INV-16); no new write path to the history mirror.
  • POST app/api/q-a-pairs/dedup-proposals/[proposalId]/reject/route.ts — same guard; sets proposal status='rejected', no q_a_pairs write (INV-13).
  • Atomicity / no half-fire (INV-15): the archive UPDATE + the proposal status flip must not leave a partial state. Do the archive UPDATE FIRST and assert affected-row=1; only then flip the proposal to approved. If the archive fails (or CAS matches 0 rows — already archived by a concurrent run, the :1037–1044 graceful pattern), report the failure explicitly to the surface and leave the proposal pending (never a misleading “approved” with no archive, never an archive with no superseded_by). Batch approve (INV-17) loops per-proposal applying each proposal’s own (possibly overridden) survivor — no batch action merges unseen pairs.

P-6 — Index posture (resolves OQ-120-3-index) — INV-21

Section titled “P-6 — Index posture (resolves OQ-120-3-index) — INV-21”

v1 = brute-force, consistent with Stage-5’s current pgvector posture and acceptable at the current published-pair corpus size (RESEARCH §4(d)). No HNSW/ivfflat migration in v1. Bound the scan cost operationally in P-2’s candidate query rather than with an index:

  • The candidate generation is a self-join q_a_pairs a JOIN q_a_pairs b ON a.id < b.id over the published embedding-bearing population, with WHERE (1.0 - (a.question_embedding <=> b.question_embedding)) >= $threshold. To keep this from being an unbounded O(n²) seq-scan as the corpus grows, the pass MAY batch by source_workspace_id-agnostic blocks and/or cap candidates per survivor (top-K nearest above threshold). The brute-force vs index choice is a performance optimisation only and must not change which pairs are proposed (same threshold ⇒ same candidate set — INV-21). Behavioural constraint: the proposer run completes within the pipeline-run window without degrading the rest of the walk (INV-21); if a calibration run shows the seq-scan exceeding the window at the client’s corpus size, escalate to the HNSW migration — recorded as a follow-up (§Follow-ups), not v1.

P-7 — Re-propose / no-nag mechanism (resolves OQ-120-re-propose) — INV-5

Section titled “P-7 — Re-propose / no-nag mechanism (resolves OQ-120-re-propose) — INV-5”

Store a fingerprint watermark per pair on the proposal row (pair_a_fingerprint, pair_b_fingerprint). The fingerprint is a cheap stable derivation of the pair’s question state — v1: md5(question_text) (or the pair’s updated_at epoch). On each proposer run, a pair (A,B) that already has a rejected (or approved) proposal row is skipped (no new pending proposal) UNLESS the current fingerprint of A or B differs from the stored one — i.e. the underlying question materially changed since rejection. This realises INV-5 (“no nagging re-proposal of an unchanged, already-rejected pair”) while still allowing a genuinely-changed pair to resurface. An already-archived/superseded pair (publication_status='archived' or non-null superseded_by) is never resurfaced regardless of fingerprint (INV-2, INV-5).

Tests are behaviour-first (test-philosophy.md): assert the proposed/approved/rejected outcomes and the corpus state, not the internals. Python pipeline tests via python3 -m pytest scripts/tests/ (run from the worktree CWD — namespace-package hazard, scripts/CLAUDE.md); TS via bun run test (never bun test); shared Supabase mock per __tests__/CLAUDE.md.

Invariant(s)Verification
INV-1, INV-7Pytest: proposer attaches after handle.ready() and runs as a service-role post-pass (mirror test_cocoindex_stage_5_resolution.py harness); a proposer raise is re-wrapped as _QaDedupProposerStageErrorqa_dedup_proposer_failed, walk not aborted.
INV-2Pytest: only published, non-null-embedding, non-superseded pairs enter the candidate set; draft/in_review/archived/superseded pairs are never proposed (survivor or non-survivor).
INV-3, INV-19, INV-20Pytest: a pair at cosine ≥ QA_DEDUP_COSINE_THRESHOLD (0.92) yields exactly one proposal of exactly two distinct pairs with the score recorded; a pair just below threshold yields none; threshold is one named constant (assert single source).
INV-4Pytest: re-running the proposer over an unchanged corpus creates no duplicate pending proposal for the same (A,B) (UPSERT/skip; pair_a_id<pair_b_id canonicalisation).
INV-5, INV-7 (chains)Pytest: an already-approved/rejected pair is not resurfaced; a rejected pair whose question fingerprint changes IS resurfaced (P-7); a 3-pair chain emits separate pairwise proposals.
INV-6, INV-8Pytest: the candidate read spans multiple source_workspace_ids and forms (cross-workspace/cross-form fixture); the widened read is confined to the proposer (a non-proposer caller path does not perform a whole-corpus read).
INV-9, INV-14, INV-15Vitest: approve route gated to admin/editor (viewer→403 via authFailureResponse); the archive UPDATE uses CAS + affected-row guard; archive-then-flip ordering means a failed archive leaves the proposal pending and the corpus unchanged (no half-fire); reject writes nothing to q_a_pairs.
INV-10, INV-18Vitest/component + manual: detail surface shows both questions AND both answers side-by-side with each pair’s workspace/form, publication status, DD/MM/YYYY, survivor + reason; a cross-workspace/cross-form proposal is badged (non-colour-only) “spans workspaces/forms”.
INV-11, INV-16Vitest: an approval where survivor and non-survivor have different source_workspace_id succeeds (cross-workspace superseded_by permitted by FK); the q_a_pair_history trigger row snapshots source_workspace_id + superseded_by by value (no new provenance store).
INV-12, INV-13Vitest: nominated survivor follows the (publication_status → confidence → recency) policy with the displayed reason; curator override swaps the survivor and the archive targets the curator’s chosen non-survivor.
INV-17Vitest/component: per-pair approve/reject is the default; approve is not pre-selected; a batch approve applies each proposal’s own (possibly overridden) survivor and never merges an unseen pair.
INV-19Component: explicit empty (“no pending duplicate proposals”), loading (skeleton), and error states; semantic tokens only; UK English; DD/MM/YYYY.
INV-21Pytest: the candidate set is identical with and without any index (same threshold ⇒ same proposals); a corpus-size fixture run completes within the run window (perf assertion / timing budget).
INV-22Vitest: viewer cannot reach the proposal surface or the approve/reject routes; no interactive role gains a general corpus-wide q_a_pairs read through this Task.
INV-23Component: the similarity score renders only as a subordinate “match strength” affordance, not an AI-confidence headline.

Manual verification (Liam’s eyes-on gate): screenshot the detail surface for a genuine cross-workspace/cross-form near-duplicate (both answers visible, “spans workspaces/forms” badge, survivor + reason); approve with an override; confirm the non-survivor is archived + superseded_by set and a q_a_pair_history row was written; confirm reject leaves the corpus unchanged.

  • Brute-force O(n²) scan growth (INV-21). Bound per-survivor top-K + run-window timing assert (P-6); escalate to HNSW migration only if a calibration run breaches the window (§Follow-ups).
  • REST PATCH silent no-op. The approval archive UPDATE MUST assert affected-row=1 and treat CAS-0-rows as already-archived-by-concurrent-run (mirror promote-corpus.ts:1037–1044) — never assume success (CLAUDE.md / __tests__/CLAUDE.md).
  • App-side-only read boundary (no DB backstop). RLS is USING(true) (correct — one DB per client); the widened read’s only boundary is the named proposer (INV-8). The proposer MUST be a real, tested, named caller (P-2/P-3), not “nobody else calls this” — assert a non-proposer path does not whole-corpus-read. DB-level per-workspace RLS hardening of q_a_pairs is explicitly out of scope (a separate backlog note — DECISION-BRIEFING Decision 2).
  • New API routes + proxy allowlist. The approve/reject routes are authenticated — do NOT add them to proxy.ts publicRoutes; the in-handler getAuthorisedClient(['admin','editor']) guard is the boundary (mirror promote-corpus route header).
  • Migration discipline. New table via supabase migration new + db push foreground (interactive CLI hangs background shells); regen types (supabase/CLAUDE.md); never hand-edit database.types.ts. Schema parity prod ↔ staging applies.
  • Threshold mis-calibration floods the queue. v1 0.92 is conservative; calibrate against the live distribution before enable (P-2); a high reject-rate signals raise-the-threshold (INV-20).

Chain-dependent slices, decomposable but ordered: (1) P-1 migration + types (blocks all); (2) P-2/P-3 proposer + attach (depends on 1); (3) P-5 approve/reject API + mergeDedupPair helper (depends on 1); (4) P-4 review surface + query keys/fetchers (depends on 1, consumes 3). Slices 2 and 3/4 can run in parallel after slice 1. Worktree isolation per CLAUDE.md; well under the 25-Subtask ceiling. A {120.4} PLAN is warranted to sequence these (confirm at dispatch).

  • HNSW/ivfflat index on q_a_pairs.question_embedding — deferred (P-6); promote to a backlog note + migration when a calibration run shows the brute-force scan breaching the pipeline-run window at the client’s corpus size.
  • DB-level per-workspace RLS hardening of q_a_pairs — explicitly out of v1 scope (DECISION-BRIEFING Decision 2); backlog note for a future cross-cutting tenancy-hardening Task.
  • Hybrid (lexical + vector + KG) proposer — deferred to v1.1 (RESEARCH §3.4); v1 ships the single question-embedding-cosine surface.

Empirical verification (pre-ratification, OQ-3)

Section titled “Empirical verification (pre-ratification, OQ-3)”

No external-library API claim drives this TECH. The substrate is internal (Stage-5 scaffold, retireSupersededPairs archive primitive, q_a_pair_history trigger) plus the in-repo pgvector <=> operator over vector(1024) — both verified PRESENT and EXERCISED in shipped SQL (q_a_search, squash_baseline.sql:4282) and in the live Stage-5 pass, not asserted from prose (RESEARCH §6, PRODUCT empirical table). No external import-and-call check required; no ABSENT / SIGNATURE_DRIFT / BEHAVIOUR_DRIFT to report.


End of TECH. Maps 1:1 against PRODUCT.md INV-1..INV-23 (§Testing table). Resolves the TECH-delegated open questions: cosine threshold = single tunable constant, v1 0.92 precision-first, calibrated pre-enable (OQ-120-3-threshold); index = brute-force v1, no HNSW migration, bounded operationally (OQ-120-3-index); proposal store = new q_a_pair_dedup_proposals table mirroring the content-dedup precedent (OQ-120-proposal-store); re-propose = per-pair fingerprint watermark (OQ-120-re-propose). The merge write reuses the archive primitive via a new mergeDedupPair helper (NOT retireSupersededPairs, which is extraction-lineage-keyed). No implementation, no DDL applied, no ledger writes performed.