Skip to content

Commit 4768bb9

Browse files
committed
feat(objectql): report dangling references left by isSystem writes (#4551)
#4441 enforces lookup referential integrity on the write path but exempts isSystem writes, so seed replay / package install / boot provisioning can still store a reference that points at nothing with nothing saying so. PR #4511 recorded the residual; this closes it by reporting. inspectDanglingReferences() walks non-readonly reference fields, probes each stored id with the SAME private probe the write-path guard uses, and reports object/record/field/target/id. Reports; never rewrites. Unprobeable targets count into undetermined and unreadable objects into skipped, so an outage can never read as a clean bill of health. Bounded per object and per pass, RBAC link tables first; hitting a cap sets truncated. Rides the existing ADR-0057 lifecycle clock (hourly, first run past boot) rather than arming a second one. LifecycleService.sweep() is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
1 parent 4638aaa commit 4768bb9

6 files changed

Lines changed: 834 additions & 2 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
feat(objectql): report dangling lookup references left behind by `isSystem` writes (#4551)
6+
7+
#4441 made the write path refuse a `lookup` id that exists in no row of the
8+
object it references, but deliberately exempted `isSystem` writes: seed replay,
9+
package install and boot-time provisioning legitimately write rows in an order
10+
that is self-consistent only once the batch completes, and failing them closed
11+
would turn an ordering detail into a boot failure. The exemption is correct, and
12+
it left a real residual — **the platform itself could still store a reference
13+
pointing at nothing, and nothing said so.** PR #4511 recorded that residual
14+
rather than silently accepting it; this closes it, by reporting.
15+
16+
`ObjectQL.inspectDanglingReferences()` walks the non-`readonly` reference fields
17+
of every registered object and names each stored id that resolves to no row:
18+
object, record id, field, target object, the id itself, and the holding row's
19+
tenant. It **reports and never rewrites** — the rows genuinely exist and were
20+
genuinely written, so nulling a link would make the stored data disagree with
21+
what happened, and whether the repair is to create the missing target, re-run
22+
the seed or delete the holder is a judgement the platform cannot make.
23+
24+
Three properties are worth knowing before reading a report:
25+
26+
- **Unknown is not healthy.** The probe is the same one the write path enforces
27+
with. It answers "missing" only when it RAN and found nothing; an unregistered
28+
target, an absent driver or a probe that throws is counted into
29+
`undetermined`, and an unreadable object is named in `skipped`. A datasource
30+
outage can therefore never read as a clean bill of health.
31+
- **`readonly` references are skipped**, as on the write path: the value there
32+
was minted by the platform, and at least one is a sentinel by design
33+
(`sys_metadata_history.recorded_by` stores `actor ?? 'system'` in a
34+
`lookup('sys_user')`).
35+
- **Bounded, RBAC-first.** The RBAC link tables are visited first — a dangling
36+
row there is a security-surface record that resolves to nothing, and the
37+
audience-anchor gate has to resolve that very target to evaluate the grant.
38+
Per-object and total row caps bound one pass; hitting one sets `truncated`, so
39+
a report that stopped early cannot read as "everything was checked".
40+
41+
It rides the existing ADR-0057 lifecycle clock (hourly, first run delayed past
42+
boot — exactly when seed-written references become checkable) rather than arming
43+
a second one, so the finding surfaces without an operator knowing to go looking.
44+
It runs *after* the sweep and is isolated from it in both directions.
45+
`LifecycleService.sweep()` is unchanged: tooling that calls it directly still
46+
gets policy enforcement and nothing else.
47+
48+
New exports from `@objectstack/objectql`: `DanglingReference`,
49+
`DanglingReferenceReport`, `REFERENCE_SCAN_PRIORITY_OBJECTS`,
50+
`DANGLING_SCAN_ROWS_PER_OBJECT`, `DANGLING_SCAN_MAX_ROWS`. Nothing was removed
51+
or renamed, and #4441's enforcement is untouched.

0 commit comments

Comments
 (0)