Mindbridge Β· track status

VIEWER-TRUTH

“Which person node is this user?” had five independent implementations and three delete paths that destroyed the answer β€” an account with no answer sees an empty product, and nothing in the app says why.

Re-measured 2026-08-16 from live gh issue list; production figures from 2026-08-14. Verify before starting β€” this page's prose decays as the track moves.

Next β†’ #3148 β€” the client still resolves the viewer itself and now disagrees with the server; the last lane, blocked by nothing.
14 open β€” spine live (10 of 14 linked accounts bound Β· 4 unresolvable) Β· tier reconciler live, 0 of 14 diverging Β· one unowned gap: Gmail signup still lands on an empty product, permanently.
14open issues
10/14linked accounts resolve
17expressions of the rule
0conflations on prod
1,316suppressed persons leak
Open work Why this order Rulings History

Open work

The remaining lanes β€” finish the collapse

p1 #3148 W3: the client resolves the viewer itself, and now disagrees with the server last lane disagrees with server Latent
client/services/viewer.ts resolves the viewer itself with .contains('provenance_person_ids', …).limit(1).maybeSingle() and no .order(), while the server now picks the oldest deterministically. Any account whose identity bridges to two un-merged nodes can show a different person than every server RPC returns β€” zero such accounts today, but the caller is live. Not a mechanical repoint: viewer_bindings is deny-all RLS by design, so it needs a SECURITY DEFINER RPC (the already-written current_viewer_person_id(), exposed to authenticated) or a self-row policy. Six client/services/*.ts touch the bridge β€” one accessor, not six repoints.
p1 #3162 "Which person node is this account?" has 17 independent expressions and no detector no detector across seventeen expressions Dev only
The post-W2 census counted 17 expressions on main; W2 corrected 3 of 12 and no cross-lane detector exists. Four were on no register: the id-space mixup (a person_identities.id threaded through as person_node_id in the platform console), the settings.tsx banner gated on person_identities.user_id with no check that a node binds (an account whose identity lost its node provenance reads as linked and never sees the banner), and two same-named find_person_node_ids_by_user_ids over different tables. Fix is one accessor per lane plus a parity test that fails when two lanes disagree β€” not seven one-at-a-time repoints.

Blocks calling done β€” the uncensused questions

p1 #3173 VIEWER-TRUTH: the viewer's display NAME has 5-6 implementations that disagree, and no detector name differs by surface All
“What do we call the signed-in human?” has 5-6 independent implementations returning different strings for the same account β€” the link response says "Free Palestine πŸ‡΅πŸ‡Έ" while the very next profile read says "Ahmed Eldaly". Collapsing the ladders is product-visible and needs a before/after, but no detector was added either, so a seventh ladder can land tomorrow with nothing firing. One accessor; _resolve_owner_name stays excluded with its reason (an owner who only ever sent from_me is never materialised); a check.py rule fires on a new coalesce-ladder.
p2 #3174 VIEWER-TRUTH: the identity→node reverse join is forked across ~20 sites in 4 syntaxes reverse join forked twenty ways Dev only
The inverse lookup is written ~20 times in 4 syntaxes; one shared helper (substrate_version.person_node_join) has exactly 1 caller. The bulk CTE must stay a bulk variant (measured 240s → 4.3s). Trap: node→identity looks 1:1 today only because merges currently drop the loser's provenance_person_ids; the day that is unioned, every [0]-style pick becomes ambiguous. Unblocks #3172.
p1 #3172 VIEWER-TRUTH: a suppression filter that filters nothing β€” 1,316 persons leak into the attribution index filter excludes zero of 1,316 blocked
load_unambiguous_name_index passed exclude_suppressed=True over person_identities, where the three flags are never written β€” they live on nodes_v2.properties. Measured: 0 of 5,568 identity rows carry a flag while 1,316 nodes are genuinely suppressed, so the filter excluded zero. PR #3169 set the call to False (zero behaviour change β€” it never filtered) so the code stops asserting a guarantee it does not provide. The real fix resolves each identity to its node to read the flags β€” which is the reverse join. Blocked on #3174.
p1 #3179 VIEWER-TRUTH: no real-DB test covers list_tenant_users, and the 50-id chunk boundary is untested no real-DB coverage Dev only
PlatformService.list_tenant_users β€” whose field mapping shipped the tier-edit break β€” is covered only by a mocked unit test. ViewerBindingRepository.find_bindings_for_users chunks at 50 (the .in() ban) and nothing exercises >50 ids. Add a real-DB test asserting both id fields land in their own fields, and a 51+-account chunk test.

Tier-claim follow-ups β€” the reconciler is live, the create path is not

p1 #3265 VIEWER-TRUTH: a create with NO person writes a tier the reconciler will later discard tier reverts to default silently Enterprise
create_tenant_user without a person_node_id writes the requested tier only to auth.users.app_metadata β€” no authoritative column exists, so the claim is honoured today. The moment the person's identity row appears, the #3175 reconciler joins it and rewrites the claim to the fresh row's default β€” the admin's choice silently lost within an hour. Needs an owner ruling between three shapes: refuse a non-default tier without a person, create the identity row as part of the invite, or accept-and-document.
p1 #3267 VULN-05's confirmation is auto-supplied by the client β€” the tier-0 downgrade guard cannot fire downgrade guard cannot fire Enterprise
client/services/admin.ts sets confirm_tier_zero_downgrade=true unconditionally whenever the requested tier is 0, so the backend guard β€” which raises TierDowngradeConfirmationRequiredError β€” can never fire on the only UI reaching it. No dialog exists anywhere on that path (verified by search); tier 0 is public to every authenticated user. This makes the #3234 ruling “one confirmed path” currently just “one path.” Fix is a real in-app confirmation (not window.confirm), the flag sent only when a human confirms.
p2 #3270 Two concurrent creates onto one person: the LOSER writes the winner's tier claim loser writes winner's tier Enterprise
Two admins creating for the same person: the losing request re-reads the row after the winner links, writes the winner's account claim to the loser's tier, then fails and is deleted β€” an access-control write from a failed operation onto a third party. Low likelihood, bounded blast radius (both are admins in the same tenant), and the #3175 reconciler will not repair it because it makes the copy match the (also-wrong) column. Opened when #3234 moved the write above the link.

Live bug triage

p1 #3276 disclose() enforced on a PERSONAL tenant and blanked the surface β€” an unresolved viewer removes the exemption that would have spared it personal tenant surface blanked Mizo
Measured in production: all three open_asks audit rows are a personal tenant with viewer_person_id null β€” 12 of 12 candidates withheld, the surface empty. An unresolved viewer gets INVISIBLE on every candidate, and the PERSONAL short-circuit is applied by the resolver β€” so the same failure that leaves the viewer unresolved also removes the exemption that would have made it harmless. The default is correct against leakage, catastrophic for availability. Fix: resolve deployment_mode from the tenant row, not resolver success; a personal tenant does not run ADR-058 enforcement; an unresolved enterprise viewer becomes a counted, alertable condition.
p1 #3243 A linked account that resolves to no person node is silently dropped from the nightly nudge audience β€” Regina's audience is halved half Regina's audience dropped Enterprise
Regina has 2 bound accounts; one resolves to no person node, so half the tenant's nudge audience gets nothing β€” silently, indistinguishable from a quiet week, the run reporting success. Its third question decides scope: if the binding is broken everywhere, this is viewer-resolution repair, not a nudge fix. Shared with NIGHTLY-NUDGE.
p1 #3080 nodes_v2.properties.user_id owner-stamp is never cleared once written, and a stale stamp overrides identity silently stale stamp overrides identity silently Latent
The materializer writes the owner stamp only when an owner is present; absent keys survive the JSONB merge, so nothing anywhere unsets it once written. A node stamped with a now-stale user A, whose provenance bridges user B, keeps A indefinitely β€” no log, no warning (the unstamped ambiguity case does log and skip). Stamp-wins is defensible; the silent provenance/stamp mismatch is not. Census prod for the size before changing policy.
p1 #1314 fix(compose): proactive nudge + AMA narrative prose must attribute the sender by resolved display_name compose anonymizes the resolved sender Mizo
The substrate has the right name (“Lina El-Daly”), but the compose layer collapses both roles into one and outputs “Someone shared…” instead of “Lina mentioned…” β€” the prompt does not enforce sender attribution. Thread the resolved display_name through provenance, and reject “someone”/“they” as the subject when a sender resolves (Prime Directive β€” no silent anonymous default).
p2 #3176 VIEWER-TRUTH: the viewer-resolution detector has 4 working bypasses detector has four bypasses Dev only
The check.py viewer-resolution-single-reader rule is evaded by split/concatenated string literals, a receiver-name gate, indirect calls (getattr), and PostgREST .filter/.overlaps (the regex matches only .contains(). Also: the SQL conjunction is line-local and website/ sits outside the path glob. Acceptance: each row fires, or is named in .ai/choke-points.md as a limit with its reason.
Why is it in this order?

Finish the collapse, then the blockers, then the create path, then triage. The spine (W0/W1/W2) is live, so what remains is the work it deliberately left: the client lane (#3148) and the missing detector (#3162) finish ADR-142 and stop an 18th expression β€” the track's founding defect. Then the four uncensused questions that block calling done (#3173, #3174, #3172, #3179), ordered so #3174 unblocks #3172. Then the tier-claim follow-ups (#3265, #3267, #3270) β€” the reconciler is live but the create path still writes claims it will discard. Then live-bug triage, ordered by whether exposure is live (#3276 blanks a Mizo surface, #3243 halves Regina's audience) before silent or latent defects.

Deliberately ignored: priority labels alone (all but three are p1, so the label cannot order them); filing order and age (#1314 is the oldest, filed 2026-05, and sorts last); how cheap or well-understood a fix looks; and the severity a body asserts before exposure was measured.

Standing rulings

History Β· forensics

The spine is merged and live. W0 stop-loss (PR #3103) stopped disconnect+merge from deleting a user's identity. W1 the spine (PR #3122) built viewer_bindings β€” one row per (tenant, account), composite FK, ON DELETE RESTRICT, one reconciler β€” and backfilled 10 rows. W2 the resolver (PR #3152) minted current_viewer_person_id(), repointed 7 SQL functions and 2 Python sites, shipped the platform resolvability console, and carried the disclosure fix. Verified from the database, not run metadata.

W2 carried a cross-user disclosure fix β€” W1's reconciler counted its refusal over a de-duplicated set and its warning over the raw one, so an account bridging two nodes would bind while the log said it had not.

The census, run after W2 merged (#3162): 17 expressions on main, 3 of 12 corrected, no cross-lane detector β€” get_viewer_person_id is mocked in every test touching it, which is how every lane looked closed from inside itself. The real-DB parity test shipped in the sweep below.

The choke-point sweep (PR #3169, merged 2026-08-08) found three defects in its own code, all one shape β€” a repoint carried the new source over and left behind a guarantee the old code had: a dropped runaway-page bound, a dropped sort order (one concurrent insert silently drops an account from the recipient list), and a roster field needing a different id space that broke every tier edit (0 of 4,978 nodes carry a shared id). The gate itself could be silenced by a string literal β€” the suppression token matched the raw line.

The four uncensused questions, now measured: display NAME (now 5-6 implementations, #3173); suppression scope (ruled per-viewer, #3177); access TIER β€” the reconciler is live (PR #3249 β†’ 7dfedb9e7, pg_cron PR #3258 β†’ ff097766a, hourly at :26), the write gap that fed the drift closed (PR #3272 for #3234), re-measured 0 of 14 diverging; identity subsumption (the reverse join, #3174).

The nine ways a user arrives, traced through the code 2026-08-06: three are still broken β€” journey 2 (Mizo signup connects Gmail β†’ an empty product, permanently: POST /api/identity/link exists and no frontend calls it, unowned), journey 3 (WhatsApp user later connects Gmail β†’ two identities), and journey 8 (their WhatsApps mint a second node) β€” the last two waiting on the unfiled W4 merge-union fix. Journey 9's exposure ending is latent. They collapse into three families: an identity deleted while ignoring the binding (5, 9), five parallel implementations of the question (2, 4, 6, 7), and four of “merge two people” (3, 8, 9).

What this report does not claim: W2 made no production account newly resolvable β€” re-measured 14 linked, 10 bound, 4 unresolvable, unchanged, and all ten that resolve already resolved before it. An earlier version said Regina went from zero to one resolvable; that was true only of the bridge, not the serving path. And the zeros are two different zeros β€” the latent measure (no node fuses two linked identities, no account bridges to two nodes) is the load-bearing one, and it is exactly the precondition for both the disclosure defect and the client divergence.