The v2 graph's edge vocabulary does not mean what it says β one edge type carries three unrelated relationships, a merge routine repoints edges onto nodes their own name forbids, and nothing validates the (edge_type, source node_type, target node_type) triple at any point in its life.
#2994, #1483. A closed issue named as current work is the drift this section exists to surface β re-derive the prose, or move the reference into History.
Evidence below is generated from the read-only audit of 2026-08-29 and reconciled against the open issues at build time. Every claim cites the issue it came from. Nothing here is a decision β rulings stay in the prose above, which is written by hand.
Prod (Supabase MCP execute_sql, 2026-08-29): `select count(*) filter (where coalesce(edge_count,0)=0), count(*) from public.nodes_v2 where node_type='person'` β 686 orphans / 5,044 person nodes = 13.60%. Cross-checked against the real edge table, not the denormalized counter: `not exists (select 1 from public.edges_v2 e where e.source_id=n.id or e.target_id=n.id)` β 686, identical, so the counter is not stale. No remediation script exists: `git ls-tree -r --name-only origin/main | grep -i 'reap_orphan|orphan_person'` β zero hits (population = every tracked file on origin/main); the only `keep_orphan` string on origin/main is inside the generator that authored this issue text (docs/investigations/model-eval-artifacts/experimental-scripts/file_graph_quality_issues.py:366).
Prod (Supabase execute_sql, 2026-08-29, schema-qualified public.edges_v2 over the kinship set SPOUSE/SIBLING/PARENT/CHILD/FAMILY/PARTNER/COUSIN/IN_LAW): 54 unordered node pairs carry MORE THAN ONE kinship type; 5 pairs hold SPOUSE and SIBLING simultaneously β the exact defect in the issue title; 18 PARENT rows exist in both directions over the same pair (9 mutual pairs, each person the other's parent); 5 PARENT/CHILD inverse rows persist unmerged. Layer-by-layer on origin/main: (A) prompt UNCHANGED β mizo_conversation_prompts.py:71-74 is still a flat type list plus 'Each relationship needs evidence: a direct quote or paraphrase', no disambiguation, no anti-promotion, no direction rule. (B) schema UNCHANGED and now WORSE β conversation_extraction.py:467-489 ConversationRelationship is person_a/person_b/relationship_type/confidence with no direction field and no `evidence` field at all, so layer D's provenance is not merely at risk, the evidence string is no longer a model field. (C) PARTIALLY fixed β a new aggregator, backend/app/slices/graph_building/aggregators/relationship.py, canonicalizes SYMMETRIC types order-independently via _pair_key (line 43) and dedups by (pair, type) with RELATIONSHIP_MIN_CORROBORATION=1 (constants.py:26). It does NOT unify PARENT with CHILD-inverse and does NOT do per-pair conflict resolution β matching the 18/5/54 prod counts exactly.
origin/main backend/app/services/ask/graph_traversal.py:571-575 defines `_filter_by_target_type`, called only at line 664-665 under `if target_type:`. The sole production caller of `graph_expand` is backend/app/services/ask/tools/traverse_graph.py:96-103, which passes entities, traversal_repo, graph_query_repo, query, limit, max_tier β and NOT target_type. Population for the absence claim: `git grep -n "graph_expand(" origin/main -- backend` β every hit enumerated; non-test, non-script hits are exactly traverse_graph.py:96 plus graph_traversal.py's own definition/doc lines. The other callers are backend/scripts/poc/poc_soma_capture{,_v2,_v3}.py (ad-hoc scripts, verified at poc_soma_capture.py:50 to omit target_type) and test files. The `effective_target_types` path (lines 256-264, 362-363) is live and unaffected.
origin/main:backend/app/slices/edge/properties.py:517 `MatchingEdgeProperties` still declares exactly one field, `edge_type: str`, with the docstring "there is no per-type property to model". origin/main:backend/app/slices/edge/matching.py `matching_edge_intent` signature is still (tenant_id, source_id, target_id, edge_type, match_score, provenance_conversations) β no skill kwarg, docstring still says "Property-less on purpose". origin/main:backend/app/services/ask/connection_resolver.py:489 still states "``MatchingEdgeProperties`` has no ...". Prod: `select count(*), count(*) filter (where properties is not null) from public.edges_v2 where edge_type='MATCHES_OPPORTUNITY_CAPABILITY'` β 112 edges, all with properties, and sampled rows are `{}` β zero skill data persisted.
`git show origin/main:backend/scripts/hooks/check.py | grep -c 'BOUND_READ\|bound_read'` -> 0: no hook references the registry or `register_bound_read_vocabulary`. The proposed structural fix is absent: `git grep -n BoundEdgeVocabulary origin/main -- backend` returns nothing (population = whole backend tree on origin/main), so no NewType carries the registration and mypy enforces nothing. The registry itself is unchanged at `backend/app/slices/edge/contract.py:429-505` β still a plain `dict[str, tuple[str, ...]]` whose docstring states 'Binding a literal DISARMS that gate unless the vocabulary lands here.' The only consumer is the parity test's `_read_edge_types()` (`backend/app/slices/graph_building/edge_traversal_parity_test.py:129-138`), which resolves a registration by literal substring of either leg of `module::SYMBOL` against the 4 `_READ_FILES` β exactly the mechanism the issue describes, with no detector for a bind that never registers. Only registered site on main is `SKILL_CONCEPT_EDGE_TYPES` (attribution_vocabulary.py:210).
15 assessed issues closed since the audit and are not shown β a closed issue's evidence is history, and printing it as current is how a page starts lying.
Order: user-visible wrong edges first, then stale-content surfaces, then structural write/read gaps β and within a cohort by priority (p1 β p2 β p3) then oldest-first. It ignores what blocks what (GitHub carries no dependency edge we populate), how long anything takes, and which commitment an issue serves. The 08-14 ruling that the upsert deadlock (#3307) does not outrank the rest of the track is now moot β #3307 closed 08-31, its real cause fixed under #3598/#3619.
Rounds 1+2 censused 59 live edge types and adjudicated 58 (ORGANIZED/ORGANIZED_BY held back β a design decision, not a defect). Round 2 changed what the track is about: round 1 found wrong edges, round 2 mostly found wrong readers β contracts honoured by one consumer and silently absent from another. Six defects fixed and live: D1 (connection resolver dropped all 109 MATCHES_OPPORTUNITY_CAPABILITY rows β #3104), D5 (reconciliation retyped pinned edges β #3098; forward-only, 41 rows still wrong), D10 (intent-less AMA traversal hit the DB unfiltered β #3097), D13 (completed actions listed as open β #3114), D14 (met needs / resolved problems offered as open β #3120), D21 (skill 2-hop dispatched for three types its SQL can never match β #3246).
Stage 2 (#3160, issue #3140) landed the contract, the consumer half, the attribution vocabulary and the reader census as one release with nine choke-point rows. Its third adversarial panel β run after the fixes β found six more defects plus the same comment-stripper failure in three security gates, all previously failing open.
The AUTHORED split (ADR-143, accepted 08-07) minted MENTIONED_ENTITY for the entity-mention meaning. Stage 5a shipped across #3129 (reader prep), #3210 (remaining readers + intent-map reachability), and #3222 (the flip + reclassification). 5a-live, measured on prod after the deploy: AUTHORED now means authorship in 100% of its rows (was 1.3%); MENTIONED_ENTITY 16,169, AUTHORED 2,303, rollout residue 0; the producer was OBSERVED writing the new spelling (14:40β14:48Z, Regina). A 4-lens panel before merge found silent data loss in the migration: the DELETE lacked the UPDATE's collision guard, so a row committed between the two statements was destroyed and counted as a duplicate. Also fixed: a provenance merge, a guard test that was vacuous by execution, and a MENTIONED_ENTITY label missing from both locales. The 2c nightly gate's runner was structurally dead since 2026-05-13 (87 "success" runs, 0 rows) and was repaired (#3225); 2c's precondition is a row in invariant_runs, not a green workflow run.
D6 ANSWERED direction diverges from ADR-071 (1,957 rows) Β· D7 ATTACHED_TO/CALLED have live producers and zero rows Β· D8 8 dead enum members + a wrong types.py header Β· D9 fetchPersonEdges shares one LIMIT 200 across all 15 edge types Β· D11 personal names typed node_type='company' (β₯3 in ~140 sampled) Β· D12 intent matcher fed the entity name, not the query (1f β the obvious fix activates a banned English lexical matcher, needs an ADR) Β· D15 ATTENDED_BY 7,482 rows, zero consumers Β· D15b use-entity spreads OUTGOING unfiltered β 404 links Β· D16 INVOLVED_IN 309 rows, no consumer Β· D17 MEMBER_OF 731 rows drive access-control, unreachable from any surface Β· D19 MANAGER=REPORT=124 exactly (one few-shot example mints both); PARENT/CHILD drift 33/5 Β· D22 match cards render a 0.70 guess identically to a 1.0 match (1j, unblocked, cheap) Β· D23 pending_attribution silent data loss (surfaced above, unfiled) Β· D24 SHARED_JOB_POSTING 259 rows, zero readers Β· D25 INVOLVES_SKILL has no producer, types.py claims otherwise. Not-started stages: 2c nightly drift gate (use created_at DESC, never ORDER BY id β edges_v2.id is a random v4 UUID), 3 comment + dead-code corrections (trivial), 5b/5c/5e/6 (retyped targets, the six render surfaces β narrower than the plan implies after the flip β the shared 200-row budget, corrected-vocabulary consumers).
Closed after independent re-verification, not on the triage pass alone. The fix this issue asked for (an edge coalescer) already existed and predated the issue by weeks β edge_coalescer.py landed in f9f154ccc (#2219, ADR-112 Β§EΒ²). The actual deadlock cause was diagnosed under duplicate issue #3598: unsorted endpoint lock order in the edge_count triggers, fixed by #3619 (migration 20260827113208_edge_count_trigger_sorted_lock_order.sql), verified live in prod pg_proc. Not verified at close time: whether the CI pg-lock-incident reporter has stayed quiet since the fix deployed β leave that detector wired.
The unique index is (tenant_id, source_id, target_id, edge_type) β a unique index with no named constraint. edge_count triggers fire on INSERT and DELETE only, so an edge_type-only UPDATE leaves the counter untouched. No live SQL reads AUTHORED at all (zero function bodies, views, matviews). Measured 08-08 21:00Z in one snapshot: AUTHORED 2,312 rows / 12 tenants (answer 2,048, document 264), MENTIONED_ENTITY 16,231 / 17 (company 7,811, location 4,639, product 3,414, project 367), edges_v2 173,417 / 22. The migration moved 16,169 (16,166 predicted + 3 minted, the ~400/day rate). The contract still holds against the live producer: zero rows of either type outside their declared target set. The six-vocabulary survey undercounted β the stage-2f census found more, and by 08-08 register_attribution_vocabulary is called 10 times with _UNCONVERTED_FORKS at zero.