ADR 0011 — Graph projection enrichment (cardinality + pii_level; refusal_count deferred)
- Status: Accepted
- Date: 2026-06-05
- Extends: ADR 0010 (the PR-16 backend). ADR 0010 stands as written (it is a point-in-time record); this ADR adds to the
/api/graphcontract rather than rewriting it.
Context
The marketed-vision launch makes the knowledge graph the signature surface (docs/internal/marketed_vision_launch_plan_2026_06_01.md). PR-16 (ADR 0010)
shipped the backend with a deliberately small node/edge shape: node = group
- live
catastrophicboolean +row_count; edge = honestevidenceband +canonical_path_rank.
design_handoff_schemabrain/app/graph.jsx) renders three
signals the PR-16 contract does not carry:
- a cardinality label on each relationship edge (
N:1/1:1); - a three-state PII tier per node (no PII / has PII / catastrophic), a lighter halo for the middle tier vs the catastrophic alarm;
- a per-node refusal-count badge.
Decision
1. Edge cardinality — SNAPSHOT, declared-FK edges only
canonical_joins.cardinality already persists the equi-join shape
(one_to_one / one_to_many / many_to_one / many_to_many), inferred at
join-suggest time from FK-column-set vs PK-column-set membership
(joins/suggest.py::_infer_fk_cardinality). The projection writer already
holds the join in hand, so the value is a zero-cost field copy onto a new
graph_edges.cardinality column (store schema v16, an additive read-model
ALTER repopulated by the next projection rebuild — no data migration).
It is a snapshot, not a live overlay (unlike the PII floor): cardinality is
a static property of the persisted join, so the snapshot can never drift the
way a PII tag can.
Honesty gate (load-bearing): the writer carries cardinality ONLY when the
edge is declared (FK-backed). A log_mined or inferred (manually-authored
/ LLM-suggested / dbt-imported) join MAY still carry an operator- or
dbt-asserted cardinality in its YAML, but the engine never validated it
against a live DB constraint. Rendering that identically to an FK-derived
value would dress a guess as fact. So _graph_edge_from_join drops it for
non-declared edges (cardinality = join.cardinality if origin == "declared" else None). null on the wire means “not derivable from a declared
constraint” — never a guess.
Documented conservative inaccuracy: _infer_fk_cardinality uses
PK-membership as its only uniqueness proxy; the Postgres connector does not
read standalone UNIQUE constraints. A declared FK referencing a column
covered by a standalone UNIQUE (not the PK) is therefore classified
many_to_one when it is truly one_to_one. This is wrong-but-conservative —
it over-counts the “many” side, never invents a tighter shape than the
evidence supports. The field is “cardinality from the declared PK/FK shape,”
not “true cardinality”; surfaces must not market it as exact.
2. Node pii_level — LIVE, 5-state, replaces the catastrophic boolean
The route already computes _entity_pii_level(...) per node (the SAME helper
the PII matrix uses) and then collapses it to == "catastrophic",
discarding four of five states. The enrichment simply stops discarding it: the
node emits the full pii_level (catastrophic / pii / confidential /
internal / none). No store change, no migration — PII is a pure live
overlay; graph_nodes stores nothing about PII.
catastrophic: boolean is replaced, not supplemented. Keeping both would
create two sources of truth for catastrophic === (pii_level === 'catastrophic') — exactly the drift the live overlay exists to prevent. The
surface reads pii_level === 'catastrophic' for the alarm and the
non-catastrophic tiers for the lighter halo, from one value. The TS type reuses
the existing PiiLevel (web/lib/types/meta.ts), already emitted by
/api/entities.
Middle-tier wording rule (binds PR-17): confidential and internal are
sensitivity levels, not PII categories. A halo labelled “has PII” that
lights up on a confidential/internal-only entity would overstate PII
presence. So:
- if the middle-tier halo label reads “has PII”, map ONLY
pii→ middle; - if it maps
pii/confidential/internal→ middle, the label MUST read “sensitive (non-catastrophic)”.
pii_level string and never re-derive catastrophic-ness from a
chip-kind helper client-side.
3. Per-node refusal_count — DEFERRED (not derivable today)
mcp_audit carries no table/column/entity reference (audit/ddl.py): the
entity identity exists in-process at refusal time
(PiiBlockedError.anchor_entity, mcp/server.py) but build_audit_row
discards it by privacy-by-construction. There is no column to GROUP BY, so
any per-node count today would be fabrication. It is dropped from PR-17 and
the per-node refusal badge is cut from the first surface ship.
The future shape, recorded so it is not re-litigated: add a nullable,
persisted-but-NOT-canonical mcp_audit.anchor_entity column (so the
RFC-6962 chain/Merkle hash over canonical_audit_row is unaffected and
historical verification still passes), populate it from the in-process anchor,
and aggregate refusal_count LIVE in the route (like the PII floor — the audit
log is an append-only growing stream a snapshot would lag). When pursued, a
bare 0 is honest as “zero attributable refusals” ONLY if the column
exists AND an explicit “unattributed refusals” aggregate is surfaced
(non-entity-scoped refusals — cost_cap_exceeded, schema_drift,
ambiguous_resolution, allowlist_violation, and pii_blocked with a null
anchor — carry no entity and would otherwise make the node-sum silently
undershoot the total). This touches the tamper-evident audit log — the most
sensitive surface in the product — so it is its own PR, kept off the graph
surface’s critical path.
Consequences
/api/graphedges gaincardinality(declared-only; null otherwise); nodes swapcatastrophicfor the 5-statepii_level. Dashboard wire contract bumpsDASHBOARD_SCHEMA_VERSION1.6 → 1.7; theCardinalityenum is now parity- checked TS↔Python bytest_ts_charter_sync.py.- Store schema bumps v15 → v16 for the single additive
graph_edges.cardinalitycolumn._migrate_v15_to_v16ALTERs an existing v15 store; a store chaining up from v13/v14 gets the column from the fresh DDL instead (the ALTER is guarded on table existence because the v15 graph tables are created by the DDL loop, which runs after migrations). No data migration — a reindex/reapply repopulates it. - The shipped graph is honestly leaner than the handoff prototype: no edge cardinality on mined/inferred edges, no per-node refusal badge. The surface narrates the gaps as roadmap rather than rendering fabricated data.
- Smoke note: any pre-existing v15 projection (e.g. the stashed live SaaS store)
reads
cardinality = nulluntil the projection is rebuilt — a manual smoke must reindex/reapply, not just hit the route against a stale projection.
Alternatives considered
- Keep
catastrophicAND addpii_level— rejected: two sources of truth for the same fact, the exact drift ADR 0010’s live overlay exists to prevent. - Carry cardinality on every edge (including mined/inferred) — rejected: unverified shapes rendered as engine-derived fact; the honesty line in §1.
- Ship
refusal_countvia atool_name/pii_categoriesjoin — rejected: those columns do not identify an entity; the result would be fabricated. - Block PR-17 on the
mcp_audit.anchor_entitywork — rejected: it touches the tamper-evident audit log and is independently PR-sized; ship the graph without the badge and fast-follow.