Date. 2026-08-15 · Item. [B-105] orphaned ADMIN findings · Ruling. Build list v0.88: findings are per-engagement; the sweep is interim hygiene only; the schema move waits behind B-69; the sweep CR must self-describe as interim.
Mode. READ-ONLY. No writes, no migrations, no CR. Live tree (loomworks-engine at a7ae23c deployed) and production database playground_dev (read queries only).
Findings are memory_events rows, not a table of their own. Every finding row in production sits on the ADMINISTRATIVE engagement — SELECT DISTINCT engagement_id FROM memory_events WHERE object_type='finding' returns exactly one value: 00000000-0000-0000-0000-000000000001 (display identifier E0001, state active).
Columns: event_id, engagement_id, engagement_version, object_id, object_version, object_type ('finding'), event_kind, payload (JSONB), provenance, timestamp, actor_id, actor_kind. The finding's substance is in payload: seed_id (lineage key), state (open/addressed/superseded), requirement_identifier, location_in_seed, suggestion, seed_version_examined, addressed_in_cycle_ref (Finding, engagement/types.py:215).
The FK is memory_events.engagement_id → engagements(id) — one of CR-2026-215's 27 declared policies. It points at the ADMIN engagement, never at the candidate. That is the whole mechanism of the orphaning: discard deletes the candidate's own rows and the seed's ADMIN events, but the FK on a finding row references an engagement (ADMIN) that never gets deleted, so nothing ever pulls the finding along.
Finding (types.py:215) and Assertion (types.py:280) are sibling MemoryObject subclasses — separate classes, separate object_type literals. The assertions read model selects object_type = 'assertion' only (engagement/assertions.py:132); no finding can ever enter it. Corrections-preserved is an assertions discipline and does not bind findings. Findings carry their own intra-lineage vocabulary (open/addressed/superseded) — that is per-finding state, not the assertion retraction discipline.
The one obligation delete does carry: findings ARE memory_events rows, so a sweep is a DELETE FROM memory_events — governed by the no-delete static check (tests/test_no_delete_statements.py), where candidate_discard.py is today's sole allow-listed executor. The sweep module must be explicitly allow-listed (or live inside the existing executor), which is the right shape anyway: the sweep is an extension of the same Phase-25 carve-out ("nothing to preserve before commit") — these rows only ever described discarded candidates' seeds.
payload->>'seed_id' exists on every post-Fault-A finding (seed_id is Optional on the model solely for pre-Fault-A back-compat; 22 legacy latest-version objects in production carry NULL — see §4).
Is the seed deleted at discard? Yes. candidate_discard.py retains the ADMIN seed cleanup: DELETE FROM memory_events WHERE engagement_id = :admin AND object_id = :sid. A discarded candidate's seed rows are gone.
Is absence a safe predicate? Yes, with the predicate stated globally, not ADMIN-scoped. Two facts close it:
relocate_memory_object (memory/events.py:282 — an UPDATE at line 362; the comments are explicit that the (engagement_id, object_id) identity is moved). Verified in production: E0128's seed has 2 rows on E0128 itself. So a committed lineage's seed still exists in memory_events — just not on ADMIN. The predicate must therefore ask "no seed object rows anywhere in memory_events", never "no seed rows on ADMIN".candidate_discard.py the only DELETE path against memory_events in the tree. Absence of a seed lineage ⇔ that candidate was discarded. There is no other reason a seed_id can be absent.
The predicate: latest-version finding objects where payload->>'seed_id' IS NOT NULL AND no memory_events row exists with object_type='seed' and that object_id — then delete all event rows of those finding objects. NULL-seed_id legacy findings are excluded by the NOT-NULL clause and are outside this ruling (they are pre-Fault-A pollution, already invisible to every lineage reader; the sweep CR should name them as untouched, per the WRITING/self-describe discipline).
5 orphaned lineages · 16 latest-version finding objects · 22 event rows total. The seed_ids:
| seed_id | finding objects |
|---|---|
| 7e7c0989-cef7-4336-8bc2-ecafb0ac65d3 (E0129 — the CR-215 specimen; includes R-A9 d544824f…) | 5 |
| d9a6610b-05c9-454f-b6aa-eca4b5593870 | 1 |
| dc418ef1-d6a1-41c7-98e4-c3b626acac6c | 5 |
| ddeb1594-75f4-4d4b-8aac-b93b62c0d9c1 | 3 |
| e4ee052d-9b3c-48cc-ac21-2dc5e29537c9 | 2 |
These correspond to the walk arc's discarded-after-induction candidates (E0129/E0130/E0132/E0133 plus one earlier discard). By state: 6 addressed, 10 open.
Full ledger for context: 51 latest-version finding objects on ADMIN = 16 orphaned + 13 living (7 lineages: 6 current candidates' seeds on ADMIN + E0128's rehomed seed) + 22 NULL-seed_id legacy (13 addressed / 9 open).
| # | Reader | Filter | Sweep effect |
|---|---|---|---|
| 1 | _load_induction_state — engagement/creation.py:646 (query at :675) | ADMIN + payload->>'seed_id' = :sid | None. seed_id comes from a living candidate's row; orphan lineages are unreachable. |
| 2 | GET /engagements/{id}/seed/findings — api/routers/engagements.py:492 (query at :530) | seed lineage from the engagement's own row | None, same reason. |
| 3 | _load_open_findings — orchestration/prompt.py:~260 (CR-212 candidate context) | payload->>'seed_id' = :sid | None. |
| 4 | _candidate_marker — orchestration/prompt.py:~298 (CR-212 staleness marker) | payload->>'seed_id' = :sid | None. |
| 5 | list_my_candidate_engagements — api/routers/engagements.py:1400 (candidates dashboard, open_finding_count) | ADMIN + state='open' — NO seed_id filter | Yes — the value changes. |
Reader 5 is this inspection's finding. The candidates-dashboard route computes open_finding_count per candidate card as a count of every open finding on the ADMIN log — today 31 (12 living + 10 orphaned + 9 NULL-legacy), the same inflated number on every card, regardless of whose lineage the findings belong to. The sweep drops it to 21 — a change toward correctness, not a regression: the count is wrong today because of the orphans (and independently because of lineage-blending and NULL-legacy inflation, defects the sweep does not fix). Surface exposure is nil in practice: open_finding_count is typed (loomworks/src/lib/api/door3.ts:145) but no component renders it — the change is API-visible only. The missing lineage filter on this reader is its own candidate item (same one-line fix shape as the cycle count directly above it at :1385, which DOES filter by seed_id), to be filed at the gate, not folded into the sweep.
A living finding's seed exists in memory_events by construction, so the §3 predicate excludes it:
The census breakdown in §4 is itself the proof run: the 13 living objects all group under seed_exists = true, the 16 orphans all under false, with no overlap — the predicate partitions production cleanly today. The sweep CR should carry this same census as its own pre/post acceptance evidence, and (per the ruling) state in its own text that it is interim hygiene under the per-engagement ruling, with the schema move waiting behind B-69.
DUNIN7 — Done In Seven LLC — Miami, Florida — B-105 sweep Step 0 inspection — v0.1 — 2026-08-15 Five lineages, sixteen objects, twenty-two rows; four readers that cannot see them and one that counts them on every card.