Content reaches someone who should not see it β a failure nobody experiences until it is catastrophic.
Sequenced in docs/plans/20260819-graph-integrity/README.md (revision 3). π Read Β§0 and Β§8 before acting on it: revisions 1 and 2 each had their central recommendation inverted by an adversarial panel, both times in the fail-OPEN direction, both times because a cited mechanism's polarity had not been read to the end. The measurements survived all three rounds; the reasoning built on them failed twice.
β Closed 2026-08-19 β PR #3476 flips audience_is_structural to an allowlist of NAME_STYLE_NODE_TYPES (fail-closed for future node types) and deletes the hand-spelled passthrough OR-leg from viewer_needs_you's problem/opportunity lanes β closing the class-wide verbatim-text fail-open across all nine LABELED types. No flag, no backfill: the effect landed at deploy.
π©Έ Two honest residues its own body stated: the production blast radius (how many nodes stop rendering) was unmeasured at merge β and four days later #3488 found the WRITE side still producing carrierless LABELED nodes (~1,223 in 7 days) β which is why this class lived on as its own row, #3488 below, since closed 2026-08-26 with the writer proven clean on live traffic. The description below is the row as it read while open.
A carrierless need-response OFFER is a LABELED type, so it took FULL_STRUCTURAL_NO_AUDIENCE and served verbatim response_text to any viewer within the tier ceiling, while ANSWER β in VERBATIM_TEXT_NODE_TYPES β failed closed. That asymmetry is why the graph-integrity plan's revision 1 had to be thrown away; see its Β§0.
The conversation_* spec tables are re-derived on every enrichment run; nodes_v2 rows persist. So a content node's text is a snapshot of whichever extraction created it, while the spec row it came from has since been rewritten.
backfill_content_node_conversation_identity.py reconstructs a node's conversation by matching canonical_key(spec.text) == node.canonical_name (whitespace-collapse + casefold). Once re-extraction reworded the source, that match cannot succeed β which is the measured root cause of the #3128 backfill healing only 21.5%, and therefore of #3386 existing at all.
π The general lesson worth more than the issue: any backfill that reconstructs identity by matching TEXT is betting on text that another pipeline rewrites. The graph-integrity plan proposes re-scoping or closing this row (Β§5 Step 6) β the carrier-audience route in #3386 does not reconstruct by text at all.
doc_need_matches is a precomputed cache, GRANT SELECT β¦ TO authenticated, whose only policy is tenant_id = public.tenant_id() β tenant only, no clearance, no audience. Identical in shape to the defect #3427 fixed on library_files_mat and library_documents_mat.
π Found by censusing the RULE rather than the artifact. The first census keyed on table_name LIKE '%_mat' and was structurally blind to this table β a clean result from a query that could not have found it. Filed at p2 because the exposure is weaker than the library caches, not because the shape is different.
Handed over from AMA-TRUST (measured 2026-08-19 on the live lane): _enforce_scope zeroes every scope-gated find_attachment result when the driving context carries viewer=None β a diagnostic walk saw all 15 well-ranked xlsx candidates dropped to zero β and _no_results_message is byte-identical whether the corpus is empty or everything was blocked. A real signed-in user always resolves a full viewer, so this is not the live user path; it is a completeness-honesty gap and a misuse trap for any CLI/eval/harness driving AMA as a user (it cost that fleet two confounded repro attempts). ADR-148's coverage-ledger ruling waits on a decision here.
Both halves moved in the 2026-08-19β25 wave. Step 0 ran: the merged-node population is 2,760 nodes / 4,762 provenance conversations, 89.8% LEAKING (2,171) / 0% OVER-HIDING β the direction is measured, and it is the dangerous one. Step 1 shipped in two pieces: the provenance-intersection primitive merged as PR #3485 (which its own docstring said had NO CALLER), and the wiring landed 2026-08-24 as PR #3568 β a shadow-comparison harness inside evaluate_for_serving, observe-only, after the audit row, with a per-call coverage-denominator log line.
π The gate is NOT enforcing, and flipping it is an owner gate this PR explicitly did not ask for. What remains before a flip is defensible, per the PR: close or bound the coverage gap Β· gather a divergence rate over real traffic with the denominator Β· wire the SQL lane (serving_scope_projection.py) Β· demonstrate provable no-op on the ~87,000 non-merged nodes Β· the flip itself. Separately, the #3128 remediation of 2026-08-25 nulled scope on the at-risk bare population (1,702 nodes fleet-wide) β a fail-closed stopgap, not this row's audience-intersection fix.
π provenance_conversations is a monotone UNION across merges, so the leaking population grows with time. The earlier plan doc (docs/plans/20260819-graph-integrity/, revision 3) sequenced this; its Β§0/Β§8 warnings about inverted polarity still apply to whoever flips the gate.
β Closed 2026-08-19 β PR #3475 shipped exactly the structural fix this row asked for: the 338 legacy bare-string elements normalised into the object shape, and a DB CHECK (via an IMMUTABLE helper) makes the bare-string shape impossible for every writer β the shape knowledge now sits in zero application sites, with the reader's string branch kept only as a documented safety net. Measured at merge: 338 elements across 196 rows in 5 tenants.
π Carries state:not-in-effect: the migration does not recreate the TRAIT nodes already deleted by the pre-#3464 prune (demo: 120 legacy rows, 0 TRAIT nodes) β that needs a separate re-materialisation pass, and the label comes off only when that recovery is measured or scoped out, per the PR's own contract.
Filed 2026-08-19. _drop_malformed_traits discards entries with no counter and no log. π A dropped entry is never stored, so it leaves no trace to query β if the model is emitting the wrong trait shape today we are losing traits on every extraction and nothing would ever tell us. Invisible from the data side by construction; only instrumentation can answer it.
π Not a request to coerce here. Coercing at extraction manufactures the evidence-free trait nodes #1275 exists to prevent. The asymmetry with the read path is deliberate: at extraction the grounding is obtainable and the model can be re-prompted; at read time the evidence is unrecoverable and the alternative is deletion.
π Superseded by measurement, 2026-08-19 β this route does not work and the issue should be re-scoped or closed. Two production pilots were run. Re-materialization does not split the merged nodes; separately it exposed that a graph-materialization start phase under-produces β on the demo tenant a fold deleted 49 nodes, every TRAIT node among them, while passing the mass-delete fraction guard at 3.4%.
The prune's guard reads a fraction and cannot see composition. _PRUNE_GUARD_MIN_TENANT_NODES = 50 is an exemption (small tenants skip the fraction check), not a protection β and it is not what let demo through. The needed guard refuses when the victim set wipes ~100% of any node type, and must be blocking with a named override and a threshold validated against prior clean runs.
β Closed 2026-08-26, label off. The write side stopped by PR #3569 is proven on live traffic: with ingestion resumed, 209 enterprise LABELED nodes were created in 24h, 0 of them carrierless (personal: 1,237 created, 0 carrierless) β the control the paused-pipeline state never had. The standing population resolved itself differently than the PR's backfill plan assumed: the concurrent dedup wave (PR #3573's duplicate collapse) hard-deleted the bulk of it, leaving 112 enterprise rows (regina 111, demo 1) β pre-fix, fail-closed INVISIBLE dead weight for which backfilling the carrier is impossible, per the close. The intermediate check this page recorded on 08-25 (3,574 β€ 3,588 baseline, fix holding) was superseded within a day by the stronger control.
Filed 2026-08-23 off the live Kareem Abou Ghaly incident (PR #3535 repaired his node in production: 26 spoofed messages reassigned off his identity, the verdict row deleted). Stage A shipped same-week β PR #3548 requires full corroboration before suppressing an impersonated person: both verdict tables flip from ANY-message to EVERY-message, so a person with any clean message survives. Proven live on the Kareem case.
π Stage B remains: a real message sharing the identical from_email as the spoofed ones still suppresses β that needs message/fingerprint-scoped classification, tracked as a comment in the migration, not yet designed.
π©Έ Proof by incident, 2026-08-25: two concurrent PRs (#3568, #3569) each regenerated the suppression baseline against pre-merge main; the hand-baked total header said 667 while the per-file counts summed to 668, and every PR after that went red β main wedged, deploys blocked β until PR #3580 regenerated the header. Any two concurrent PRs that each added a suppression reproduced this under the stored-header format.
β Design fix shipped: both rollup headers (total and unjustified β same lossy shape) are no longer stored in the baseline; the gate derives them from counts at check time, so disjoint-key concurrent regenerations auto-merge coherently and legacy headers are ignored, not rejected. Documented residual, both directions: two branches moving the SAME (file, rule, kind) leaf git-dedupe to one value. Bump collisions undercount β gate reds main (loud; one regeneration recovers). Drain collisions OVERCOUNT β gate stays green with an inflated ceiling and unjustified growth inside the slack passes ungated β silent, nothing prompts regeneration; structural to any committed-counts gate under git's identical-leaf dedupe (owner follow-up). debt_baseline.json shares BOTH directions (owner decision pending).
Filed 2026-08-25. The DOCUMENT-node audience fold keys on content_hash β the same key that makes attachments 2..N of a busy message disproportionately files the graph has already seen β so a document's computed audience may be wider than any single carrier intended. Unverified by measurement: it needs the same classification treatment #3386's Step 0 got (which direction does it fail, and how big is the population now that the attachment recovery added 76,228 rows?). Kin to the unfiled second fail-open candidate recorded on #2905's close.
π MERGED IS NOT LANDED. The internal/external person classification (PR #3584, merged 2026-08-26) changed no row anywhere: nothing automatic writes properties.org_membership, and new person nodes stay unclassified until a run picks them up. This issue carries state:not-in-effect until every tenant's backfill is measured done β the label comes off then, not when the script runs.
Per tenant, by an operator: dev_tools.py --tenant <slug> backfill-org-membership (dry-run first; --recompute re-classifies). Chunked and resumable β a mid-run crash re-selects unclassified nodes (NOT (properties ? 'org_membership')). Grounded only in deterministic signals: owner identity, a WORKS_AT edge to the owner's own company, owner email domain (generic providers excluded at owner-set build time). Unknown never collapses to external. Open owner decision inside this row: one-off per tenant vs a periodic recompute cadence.
The consumer of the attribute: gate disclosure audiences on organisational membership instead of bare job_function β which carries no organisational dimension at all. Measured on Regina 2026-08-24: 30 person nodes carry executive and exactly ONE works at Pasta Regina; the other 29 are executives at counterparties, so any policy keyed on job_function alone hands an org-relative privilege to mostly-external people.
π Blast radius to price FIRST (why this is owner-sequenced): 1,605 of 1,794 job-function-carrying people are external (89%), and β₯26,212 messages have an external sender whose function currently feeds scope.job_functions. Gating on the attribute narrows those audiences β fewer viewers, silently β an over-hiding regression that must ship behind a flag in shadow mode with coverage measurement, never as a cutover. unknown must fail OPEN at the gate (the classifier's contract, enforced again at consumption).
β Closed 2026-08-16 β gated on the audience axis and legacy connection provenance healed (PR #3336). Kept here because it was this page's Next call and, until it closed, the only client-reachable live leak; the description below is the row as it read while open. The remaining client-reachable render leak is #3155, two rows down.
use-feed.ts β /api/feed/intelligence serves offer/need titles from the denormalized connections table with no carrier pointer, so disclose() has nothing to rule on β ungateable as written. The other row sets in the same function are gated; connection_rows is not. The rows are written by ConnectionDetectionPhase with as_user=False during ingestion β correctly ungated at write time (no viewer exists then); the gate is owed at read time and absent. Enforcement is already on in production. The fix is a decision: give the table provenance (carrier message per side), or re-derive the node ids at read time and join messages. Sibling of #3141; related to #3128.
β Closed 2026-08-17 β the title now derives from the disclosure level on BOTH render branches (PR #3397): FULL keeps the raw title; ABSTRACTED/ATTRIBUTED substitute the row's already-projected variant (no new reads); variant-absent withholds the row; the media branch swaps the disclosed title and its alt_text echo below FULL. Two panel rounds; over-suppression measured empty. The same shape on other tools is #3391. The description below is the row as it read while open.
The body is de-identified at ABSTRACTED/ATTRIBUTED but the title renders raw β _candidate_display_title returns display_name (falling back to canonical_name) unconditionally, computed before the disclosure level is read, so a below-floor viewer reads the real item name. Applies to every candidate _format_graph_candidates renders (hop 1, hop 2, retrieval), not one path. Sibling surfaces do not have this shape β _gated_node_text / _format_connection_pairs degrade the one string they render.
β©οΈ Closed 2026-08-16 (PR #3355), reopened the week of 08-24 as a p0. The conversation-scoped identity cutover was right, but its one-time backfill could not reach everything: PR #3560's exposure census (verified read-only against all three still-open tenants, counts matching exactly) found Regina 1,685 Β· Hassan Abozeid 15 Β· Sudqi 2 at-risk bare content nodes still under-gating live β and 239 Regina nodes whose anchor is unresolvable, so true exposure may exceed the reported number.
π’ The remediation ran 2026-08-25 as a production write (the PR's script, owner-approved): scope nulled on 1,702 nodes fleet-wide β fail-closed, the leak ends as INVISIBLE rather than exposure β plus 21,368 SAME_CONTENT_AS sibling links and 13,138 duplicate bare-node deletes (the PR #3567/#3573 tooling). A 4,187-row doc_need_matches-blocked follow-up remains.
π Why the issue stays open: nulling scope is a stopgap that trades the leak for over-hiding β the properly re-scoped population needs the #3386 audience-intersection machinery, and state:not-in-effect stays until the rows are measured repaired. The full repair campaign (fold β re-merge β unpause) is the REGINA-DEDUP page's story. The row as it read while open: a multi-message node's audience was reduced to one lexically-minimum carrier, discarding 232 contributing audiences across 155 multi-message groups; the identity cutover made one node per (type, canonical text, conversation) so a node cannot span audiences by construction.
β Closed 2026-08-24 by owner ruling, against ADR-147. The re-investigation found the p0 was already designed and shipped β ADR-147 (PR #3392, merged 2026-08-17): labeled no-carrier/no-scope nodes fail CLOSED via the NAME_STYLE allowlist, and disclosure enforcement was verified true on the live app machine. A p0 retired by discovering it was already fixed and verifying that against the running code. Its close recorded a candidate successor it did not file: DOCUMENT nodes with a non-NULL own properties.scope under a NULL anchor may be a second fail-open β kin to #3582 below.
The asymmetry that founded it, kept for the reader: facts failed OPEN on a missing audience (100% of answer and 43.1% of offer nodes had no message anchor) while messages failed CLOSED; of 4,489 conversations with more than one scoped message, 3,921 (87.3%) had mixed audiences.
π’ The Documents Archive half shipped 2026-08-17 (PR #3366) β library_documents now intersects disclose_scope_is_visible over every tier-visible carrier of the document's content_hash, with an unknown audience failing closed. Most-restrictive-wins is now a recorded standing ruling on ATTACHMENT-TRUTH.
π It reached one of three lanes, and its own panel filed the other two β #3367 and #3368 below. Until those land, the Archive withholds a document that Files, Needs, Memories and the Ask lane still disclose.
The defect: a document's audience fell back to an arbitrary carrier message β a COALESCE fallback biting documents with no document-level scope; document_scope wins when present, and sensitivity_tier is document-level and unaffected. The attachment recovery backfill grows the affected population, so it could not ship undecided.
β Closed 2026-08-18 β PR #3408: one shared carrier-scope resolution for every document-serving surface, replacing the per-surface single-anchor COALESCE. The two-surfaces-disagree window opened by PR #3366 lasted one day.
The row as it read while open: library_files_mat.scope was still COALESCE(document scope, source_message_id message scope) with seven live RPCs gating on it, so a document correctly withheld in the Archive was still disclosed through Files, Needs and Memories.
β Closed 2026-08-18 β the same PR #3408 shared resolution; the Ask lane's node_provenance_scope_repository projection no longer decides from the uuid anchor. π The CHOKE-POINT DIRECTIVE's Nth-place test paying out: one question β whose audience is this document? β answered in SQL, a second SQL surface, and Python, and the fix was finally ONE implementation, not three.
β Closed 2026-08-16 β the remaining three matchers gated on the audience axis (PR #3339).
Three of four connection matchers read node text with no audience gate, and nodes_v2 RLS has no audience predicate to fall back on β the "RLS covers it" defence does not hold, because RLS gates tenant and tier, never audience. problem_solution.py and duplicate_work.py read node text with no as_user and no audience parameter. HTTP-reachable by any authenticated member, with no shipped UI caller. The #3141 fix closed the semantic-connections matcher; these three remain.
β Closed 2026-08-17 β business_context is now FULL-only via _business_context_at_level (PR #3399), the only always-gated field in the projection; two panel rounds, mutation-verified. The typed-caption class its panel exposed is #3391. The description below is the row as it read while open.
business_context β document_purpose, contains_pii, contains_financial_data and a 300-char summary β ships on the SSE wire outside the disclosure gate that shapes caption. Nothing renders it today, so the next UI inherits the leak; it is an over-broad payload rather than a rendered leak. The population changed in #2822, which now emits Office documents β the highest-signal class.
β Closed 2026-08-16 β reasoning candidates are gated before the scoring prompt reads them (PR #3338), which is the structural gate this row said the soft prompt instruction was standing in for. π The census it named as never run β how often a withheld candidate co-occurred with a rendered winner β was not run by the fix either.
The scoring prompt shows the model every retrieved candidate ungated (up to 400 chars each, unredacted); only the winners are gated at render, so a losing restricted candidate still shapes the rationale. The only guard between the two is a soft prompt instruction, not a structural gate. The remaining half after PR #3134 gated the profile; the shared base.py::reason() pipeline means all five reasoning primitives route the same way. Gating before scoring means building the disclosure map earlier β one extra evaluation batch per call; current_returned_count semantics need care (candidates fed to the model but never rendered are not "returned", and that number is durable SOC 2 evidence). Not measured: no census of how often a withheld candidate co-occurred with a rendered winner.
β Closed 2026-08-16 β this row's own recommendation (the AST hook first) shipped as ADR-146 Option B (PR #3337): the build now fails when a serving-traversal call's enclosing function names no disclosure symbol, with genuine layer-up gates recorded as named noqas β 15 of the 24 sites at close. ADR-146 records the repository-boundary gate as the endgame the hook buys time for.
The rule has no choke-point β 10 call sites, one rule; five of them are gated only because a human remembered. PR #3290 closed the semantic-connections hole; the rule itself is unresolved. Measured: get_open_asks reaches 7,531 of 7,971 restrictive ask rows; the bridge pool draws on 41,490 of 74,660 restricted personβnode pairs. Options: gate at the repository boundary (as_user=True carries the gate), a check.py AST hook, or a typed carrier. Recommendation: the AST hook first, the repository-boundary gate as the real fix.
β Closed 2026-08-17 β PR #3362 carries the question's carrier on ANSWER and need-response OFFER nodes, so disclose() finally has provenance to rule on. π MERGED IS NOT LANDED. The materializer heals going forward only; the 2,023 existing NULL-carrier ANSWER/OFFER nodes stay ungateable until a one-time backfill runs β backend/scripts/backfill_answer_offer_carriers.py, merged with the PR, dry-run by default, --apply writes. It reconstructs the carrier from the spec tables, skips merged nodes loudly, and is keyset-chunked and idempotent. It has not been run and no owner is assigned to the run. π©Έ PR #3362's own body says this issue "carries state:not-in-effect until the backfill has run and its result is measured" β the label was never applied. Added 2026-08-17. It comes off when the rows are measured as repaired, not when the script is run.
The row as it read while open: all 2,023 answer nodes have source_message_id NULL β AnswerProperties has no scope field and the sole writer calls materialize_node without it, so every projection is COALESCE(NULL, NULL). ANSWER text β full verbatim conversation text β always takes disclose()'s structural passthrough. The ADR must decide three things: carry provenance (materializer change + backfill), or exclude verbatim-content types from the passthrough and fail closed, and what happens to the existing 2,023 rows with no recoverable audience. #3128 blocks the obvious fix.
β Closed 2026-08-16 β the narrower of the two rulings it offered: disclose_current_viewer() resolves SECURITY DEFINER, tenant-guarded (PR #3341), matching its own dependency current_viewer_person_id(). The row as it read while open is below.
disclose_current_viewer() is SECURITY INVOKER, so 12 Regina edges resolve to no audience identity and an entitled viewer is shown nothing β over-suppression, not a leak. 2,106 role edges, 12 above tier 0; job_function nodes sit 73 at tier 0, 3 at tier 1, 7 at tier 3 (the tier-3 materializer default is not what lands in practice), and project nodes are far more skewed β 117 of 260 at tier 3. Two rulings offered; the narrower is SECURITY DEFINER, matching its own dependency current_viewer_person_id().
The criterion, top to bottom β each rung breaks only the ties the one above leaves.
Deliberately ignored: issue age and filing order Β· the priority label alone (severity, re-ranked here on reachability and leak direction) Β· issue number Β· how cheap or well-understood a fix looks Β· track-sharing (#3283 and #3284 sit on WIRE-TRUTH, #3325 on ATTACHMENT-TRUTH β sharing does not move an item here).
#3595, #3386, #3582, #3581, #3389, #3466, #3465, #3390, #3488, #3283, #3155, #3391, #3128, #2905, #3325, #3367, #3368, #3284, #2824, #3135, #3117, #3141, #3118, #3285, #3295. 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.
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.