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 RLSUSING(true)onq_a_pairsis 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.
Context
Section titled “Context”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 retireSupersededPairs→Function lib/q-a-pairs/promote-corpus.ts:retireSupersededPairs(startLine 826, endLine 1057); incoming calls: exactly ONE —promoteCorpusExtractions; outgoing callsafeErrorMessage(lib/error.ts). The archive UPDATE the proposer’s approval reuses lives atlib/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):retireSupersededPairsitself is keyed toq_a_extractions.promoted_to_pair_idlineage + asource_content_item_idauto-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–1045UPDATE shape) but does not callretireSupersededPairs— see P-5.gitnexus_context promoteCorpusExtractions→Function lib/q-a-pairs/promote-corpus.ts:promoteCorpusExtractions(startLine 234, endLine 525); incoming calls: exactly ONE —POST(app/api/q-a-pairs/promote-corpus/route.ts); outgoing calls includetryQuery(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_resolutionscripts/cocoindex_pipeline/stage_5.py:204,_select_run_entity_mentions:96, attach inflow.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_itemsdedup, 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).
- routes:
Live schema (direct migration reads, supabase/migrations/20260617130000_squash_baseline.sql):
q_a_pairsCREATE:7128;question_embedding extensions.vector(1024)(NULLABLE):7144;superseded_by uuid:7138;source_form_response_id/source_question_id:7146/:7147;publication_statusCHECKdraft|in_review|published|archived; FKq_a_pairs_superseded_by_fkey … REFERENCES q_a_pairs(id) ON DELETE SET NULL:9889; RLS SELECTq_a_pairs_select … USING (true):10904.source_workspace_idis a real column with a btree index (20260619120100_index_unindexed_fks.sql:30).q_a_searchRPC:4268—SECURITY DEFINER, returns previews +embedding_score = (1.0 - (qap.question_embedding <=> p_query_embedding))::numeric(5,4), filtersWHERE 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. NoUSING hnsw/USING ivfflatindex onquestion_embeddingexists (grep over all migrations, RESEARCH §4(d)) → brute-force today.q_a_pair_historyCREATE:7065— append-only mirror snapshottingsource_workspace_id+superseded_byby value at each transition (column comments ID-64.15), written by the existingq_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).
Proposed changes
Section titled “Proposed changes”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 viasupabase db pushforeground (interactive CLI hangs background shells — CLAUDE.md). Regen types:bun runthe supabase type regen persupabase/CLAUDE.md; consume viaTables<'q_a_pair_dedup_proposals'>— never hand-editdatabase.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, CHECKproposed_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'CHECKstatus 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 fromproposed_survivor_id— INV-13);created_at/resolved_at timestamptz;resolved_by uuid(curator). Ordering invariant: enforcepair_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 inapproved/rejectedis never resurfaced aspending(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),viewerdenied (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 newpublic.*()function is strictly required (the proposer is Python-side); IF a list/aggregate RPC is added (see P-6 note), it MUST includeSET search_path = public, extensionsandREVOKE 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 population —
SELECT 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 nosource_workspace_idfilter (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_embeddingusing the SQL<=>operator mirroringq_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 livequestion_embeddingdistribution (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 toq_a_pair_dedup_proposals(UPSERT on(pair_a_id,pair_b_id),ON CONFLICT DO NOTHINGfor an unchanged pending/terminal row — idempotent; INV-4/INV-5). It never touchespublication_status/superseded_byonq_a_pairs. - Survivor nomination (INV-12): compute
proposed_survivor_id+survivor_reasonby the displayed v1 policy in order: (1)publication_status(publishedbeats non-published); (2) confidence/quality signal where a column exists on the pair; (3) recency (laterupdated_at/created_atsurvives). 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 excAdd _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 thegetAuthorisedClient(['admin','editor'])gate;viewernever 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, mirroringnear-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_idandsource_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.adminQaDeduptolib/query/query-keys.tsmirroringkeys.adminNearDup(:398):all,queue(filters?),proposal(id). Fetchers inlib/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 chosensurvivor_id(defaults toproposed_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 (mirrorpromote-corpus.ts:1010–1045); then sets the proposalstatus='approved',resolved_survivor_id,resolved_by,resolved_at. New helper (NOT a call intoretireSupersededPairs, which is extraction-lineage-keyed): addmergeDedupPair(client, { survivorId, nonSurvivorId })inlib/q-a-pairs/dedup-merge.tsusingsb()/tryQuery()(@/lib/supabase/safe) — direct file import, no barrel. Theq_a_pair_historytrigger 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 proposalstatus='rejected', noq_a_pairswrite (INV-13).- Atomicity / no half-fire (INV-15): the archive UPDATE + the proposal
statusflip must not leave a partial state. Do the archive UPDATE FIRST and assert affected-row=1; only then flip the proposal toapproved. If the archive fails (or CAS matches 0 rows — already archived by a concurrent run, the:1037–1044graceful pattern), report the failure explicitly to the surface and leave the proposalpending(never a misleading “approved” with no archive, never an archive with nosuperseded_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.idover the published embedding-bearing population, withWHERE (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 bysource_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).
Testing and validation
Section titled “Testing and validation”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-7 | Pytest: 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 _QaDedupProposerStageError → qa_dedup_proposer_failed, walk not aborted. |
| INV-2 | Pytest: 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-20 | Pytest: 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-4 | Pytest: 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-8 | Pytest: 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-15 | Vitest: 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-18 | Vitest/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-16 | Vitest: 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-13 | Vitest: 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-17 | Vitest/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-19 | Component: explicit empty (“no pending duplicate proposals”), loading (skeleton), and error states; semantic tokens only; UK English; DD/MM/YYYY. |
| INV-21 | Pytest: 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-22 | Vitest: 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-23 | Component: 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.
Risks and mitigations
Section titled “Risks and mitigations”- 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 ofq_a_pairsis 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.tspublicRoutes; the in-handlergetAuthorisedClient(['admin','editor'])guard is the boundary (mirror promote-corpus route header). - Migration discipline. New table via
supabase migration new+db pushforeground (interactive CLI hangs background shells); regen types (supabase/CLAUDE.md); never hand-editdatabase.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).
Parallelization (informs {120.4} PLAN)
Section titled “Parallelization (informs {120.4} PLAN)”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).
Follow-ups
Section titled “Follow-ups”- 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.