Repository navigation
Commit 8464380
fix(spec, objectql): a tree field with no reference is a self-reference, so its deleteBehavior takes effect (#23092)
Fixes #23065
Clause-②: yes (widening)
Widens: `@objectstack/spec/data` gains the export
`materializeTreeSelfReferences`, and `ObjectSchema` /
`ObjectExtensionSchema` parse output now writes `reference` (the
declaring object's name) on a `tree` field that omits it. The accept set
is unchanged: nothing that parsed before is refused.
## What was wrong (measured at the door on `origin/main` `275ad4229a`)
The contract already reads a `tree` with no `reference` as a
self-reference: `refuseForeignTreeReference` tells authors "Drop
`reference` (it is optional on a `tree`)", and `FieldSchema`
materializes `set_null` on the field. But `referenceTargetOf` reads one
field alone and cannot know the declaring object, so every reader
answered "no target". Booted with `@objectstack/verify`'s `bootStack`,
one object with `parent: { type: 'tree' }`, through the REST door as the
signed-in admin:
| reader | referenceless `tree`, before | same field with `reference` =
own object (control) | referenceless `tree`, this PR |
|---|---|---|---|
| `DELETE` parent, `set_null` (default) | 200, child keeps the deleted
id | 200, child `parent: null` | 200, child `parent: null` |
| `DELETE` parent, `restrict` | 200, child keeps the id | 409
`DELETE_RESTRICTED` | 409 `DELETE_RESTRICTED`, `dependentObject` = the
tree's own object |
| `DELETE` parent, `cascade` | 200, child and grandchild kept | subtree
deleted | subtree deleted (child and grandchild 404) |
| `PUT /meta/object/:name` then `DELETE` parent | 200, child keeps the
id | n/a | 200, child `parent: null` |
| write check, `POST` child with a parent id that does not exist | 201
accepted | 400 `reference_not_found` | 400 `reference_not_found` |
| `GET /data/:object/:id?expand=parent` | 404 "declares no target
object" | 200, parent expanded | 200, parent expanded |
| nested relation filter `{ parent: { title: 'p' } }` | 400 "declares no
related object" | 200, rows | 200, rows |
| dangling-reference audit (`inspectDanglingReferences`) | not audited
(no target) | reported | reported, target = own object |
| `os verify --delete-behaviors` case derivation | excluded: "names no
target object" | probed | probed, proven through both doors |
Readings taken with a scratch suite on both a raw config and a
`defineStack` config (same answers), before and after; logs kept in the
dev's scratch directory, not committed.
## Route: both measured, (ii) chosen and completed at the registry
- **(i) thread the owner into the readers.** `referenceTargetOf` has 26
production call sites in 18 files across 8 packages (`core`, `lint`,
`metadata-protocol`, `objectql`, `plugin-approvals`, `plugin-audit`,
`service-analytics`, `verify`; counted at `275ad4229a`), and the delete
walk itself reads the carrier (`referenceCarrierOf`, twice), not the
target. Each would have to pass the owner, a caller that forgets gets
the old silent `undefined`, and any reader that reads `def.reference`
directly is still blind. That is one reading that has to be repeated
correctly at every call site.
- **(ii) write the self-reference where the object is known.** Measured
gap: `ObjectSchema`'s parse alone does not reach the runtime for every
door. `PUT /meta/object/:name` stores the authored body verbatim
(registry entry measured: `{"type":"tree","label":"Parent"}`, no
`reference`, no `deleteBehavior`), and a raw `registerObject` / raw
`bootStack` config is never parsed. So the registry, the one door every
registration passes (it already runs `objectFieldTypeRefusal` and
`applySystemFields`), runs the same spec helper. Every reader, whether
it reads the target or the carrier, then sees an ordinary
self-reference, and no reader changes.
I chose (ii) with the registry door. It is one reading, applied by one
helper at the two places that know the object, with no reader-side
fallback. The triage direction ("the engine's reference readers take the
field's own object") holds without editing a reader. The parse output
changes are byte-identical to a field that wrote its own name: the key
lands at its `FieldSchema` shape position, and a re-parse is a no-op.
`refuseForeignTreeReference`'s guidance still reads true, because
dropping `reference` now yields exactly the self-reference. The
authorable surface and the conversion ledger are unchanged
(`check:authorable-surface` green; no ADR-0087 entry).
## What changed
- `packages/spec/src/data/object.zod.ts`: new export
`materializeTreeSelfReferences(ownName, fields)`. Absence is the two
spellings a contract admits for "names no target": `undefined`
(`FieldSchema`'s `.optional()`) and `null` (the blueprint's
`StrictField`). Every other value is returned untouched. `reference: ''`
is NOT absence here: the parse refuses it on a `tree` as a foreign
target, so the registry leaves it as written instead of repairing it
unparsed, and the carrier reader reads it as naming nothing, as before
(patch round 1, review record 6131807682 item 10a). A foreign name stays
`refuseForeignTreeReference`'s to refuse, and a non-string value stays
the carrier reader's to refuse loudly. It returns the same object when
nothing needs writing. `ObjectSchema` and `ObjectExtensionSchema` (own
name = `extend`) run it as an `.overwrite` after their refinements, so
the schema stays a `ZodObject`.
- `packages/objectql/src/registry.ts`: `registerObject` runs the helper
right after the field-type door, on a copy, so the caller's definition
is not mutated. An extension registers under `name: ext.extend`.
- `packages/spec/src/data/field-value.zod.ts`: the `referenceTargetOf`
docblock no longer calls a referenceless `tree` "an authoring bug". It
says where the target is written and why the function cannot derive it.
- `packages/spec/src/data/field.zod.ts` (prose only): the `reference`
TSDoc and `.describe()` ("…and writes the own name when it is left
out"); the `deleteBehavior` TSDoc's #9784 paragraph and the `.overwrite`
block now say the delete walk reads the key on `master_detail`, `lookup`
and `tree` (the `statesDeleteSemantics` family). The `user` sentence
names that family: `user` is still outside it, so the substance was true
and the wording was stale.
- Generated: `packages/spec/api-surface/data.json`,
`packages/spec/export-origins/data.json`,
`content/docs/references/{data/field,data/object,system/migration}.mdx`.
- `.changeset/23065-referenceless-tree.md`: `minor` for
`@objectstack/spec` (export added), `patch` for `@objectstack/objectql`.
## Merge with `origin/main` (patch round 1)
`origin/main` was merged at `0511341408` (9 commits past the merge base
`275ad4229a`) through `scripts/pm/os-regen-merge.sh`, by merge commit
`f77dd94819`. Step 2 kept the branch's bytes of the four artifacts only
the branch moved, and took main's side of
`content/docs/references/data/object.mdx`, the one both sides moved.
Step 3's commit was refused by the `os-regen` pre-commit as designed.
Then `gen:schema && gen:docs` regenerated `object.mdx` on the merged
tree, and the staged diff, read before committing, held both sides'
rows: main's `target` row for `type: 'url'` and this PR's two
`reference` rows. That was committed as `b390b3cbae`, and the hook
printed `content/docs/references/data/object.mdx — current … marker
cleared`. On the merged head, after a rebuild, `check:generated --fix`
reported all 14 generated artifacts up to date, `check:api-surface`,
`check:authorable-surface` and `check:docs` included, with nothing left
to write. `git diff --stat 0511341 HEAD` is exactly this PR's 13
files. Since the merge, `main` has gained two commits, `6662e3f590`
(runtime `app-plugin.ts`) and `1badd31838` (`ui/app.zod.ts` docs).
Neither touches a file in this diff.
## Tests (at `d09dffd8bf`, the merged head)
- `packages/spec/src/data/tree-reference-self-only.test.ts`: +7 pins.
The parse writes the own name byte-identical to a written one and
re-parses as a no-op. `create()` and `defineStack` write it. The
extension door writes `extend`. The refusal's remedy holds. CONTROL: a
written self-reference, `lookup`, `master_detail` and `text` parse
exactly as `FieldSchema` alone parses them. Helper semantics:
`undefined` and `null` are written; `''`, a non-string and a foreign
value are untouched; nothing to write allocates nothing. `reference: ''`
on a tree has one reading: the parse refuses it at
`fields.f_tree.reference`. One existing assertion (`reference`
toBeUndefined after parse) pinned exactly the behaviour this card
changes and now reads the own name. 14/14.
- `packages/objectql/src/engine-cascade-delete-tree.test.ts`: +10 pins
on a real engine with a snapshot-rollback driver. The registry writes
the self-reference and leaves the caller's object alone. An extension's
tree references the extended object. Also pinned: `set_null` (absent,
field-parsed, authored), the object-parsed door, `restrict` (envelope),
`cascade` (subtree, one unit of work). An empty-string `reference`
reaches the registry as written and `referenceTargetOf` answers no
target. CONTROL: a written self-reference. 28/28.
- `packages/verify/src/delete-behavior-referenceless-tree.test.ts`: the
door pins on one `bootStack`. Through `DELETE /data`: `set_null`,
`restrict` and `cascade`. The metadata save door.
`runDeleteBehaviorProofs` proves all referenceless cases through both
doors. The write check: a parent id that names no record answers 400
`VALIDATION_FAILED` with `fields[]` `{ field: 'parent', code:
'reference_not_found' }` (it was 201), and CONTROL: an existing parent
is accepted and stored. `expand=parent` resolves the parent (it was
404). The nested relation condition `{ parent: { title } }` filters
through it (it was 400). CONTROLS: a written self-reference, `lookup`,
`master_detail`. 8/8.
- Package suites on the merged head: `@objectstack/spec` 714 files /
20898 passed. `@objectstack/objectql` 409 files / 7989 passed.
`@objectstack/verify` 35 files / 278 passed, run after the gates had
finished: the previous round's concurrent-build "Failed to resolve
entry" flake did not recur. `typecheck` is green for objectql and
verify, test layers included; spec's test layer is OK through
`check:generated`.
## Ablation (one-time proofs, not committed)
Each leg was run against the committed fix, with the mutation landed
through `scripts/ablation-replace.mjs` (marker 1, anchor 0), spec dist
rebuilt with the marker confirmed by `ablation-dist-preflight`, and a
`trap` restore.
- Round 0 (at `bd8eff9eb6`): the helper writes nothing. Red: spec 6/13,
objectql 8/27, verify 5/6. Green: every CONTROL and the #14892 / #22957
pins.
- Round 1 leg A (at `d09dffd8bf`): the helper writes nothing. Verify is
red on 7/8: every delete pin, the meta save door, `os verify`, the write
check, and expand plus nested filter. Green: the CONTROLS test.
- Round 1 leg B: `''` read as absence again. Red: the spec helper pin
(1/14) and the objectql empty-string registry pin (1/28). Green: the
spec "parse refuses `''`" pin, which is the parse's own refusal, and
every other pin.
- Restore, each leg: blob == HEAD, `git diff HEAD` empty, spec rebuilt
in full, `ablation-dist-preflight --absent` clean for both markers,
whole tree clean.
## Gates
Union of the dispatch's 84-command list and the list re-derived on the
merged head (115, the same set as round 0): 117 commands at
`d09dffd8bf`, every exit captured before any pipe. All 117 exit 0.
`check:dual-build-cjs-loads` first refused its prerequisite (exit 3,
packages without dist), and its re-run after the later gates had built
them exited 0. Among them, `check:generated` ("All 14 generated
artifacts are up to date"), `check:api-surface`,
`check:authorable-surface` and `check:docs` ("226 generated files in
sync"). `dispatch-gates --ran` with exit codes: 115 derived, 115 run, 0
NOT-MEASURED, 0 UNRUN.
## Acceptance notes
- Author-time lint on the metadata save door judges the authored body
before any materialization, as it does for every parse default. So a
rule that traverses a referenceless `tree` inside the very object being
saved sees no target there, while `os validate` / `os lint` read
`defineStack` output, which carries it. Not measured at a door; noted,
carrier: none.
- `ObjectSchema.create()` is typed off the authored literal, so
TypeScript does not see the written `reference` on its result (the
runtime value is there). This is the same property every parse default
already has.
- A stored `sys_metadata` row keeps its body verbatim (ADR-0005 appendix
(c)). Only the registered definition carries the self-reference, and a
round trip through `GET`/`PUT /meta/object` stores the own name, which
re-parses clean.
- objectui's use of `referenceTargetOf` / `referenceCarrierOf` is NOT
MEASURED: repository-scoped search was refused from this session. No
reader's signature or behaviour changes for a field that names a target,
and the shipped corpus already carries a written self-reference on a
tree (the showcase's `f_tree`).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 36b2889 commit 8464380
13 files changed
Lines changed: 643 additions & 24 deletions
File tree
- .changeset
- content/docs/references
- data
- system
- packages
- objectql/src
- spec
- api-surface
- export-origins
- src/data
- verify/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
76 | 76 | | |
77 | 77 | | |
78 | 78 | | |
79 | | - | |
| 79 | + | |
80 | 80 | | |
81 | 81 | | |
82 | 82 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
241 | 241 | | |
242 | 242 | | |
243 | 243 | | |
244 | | - | |
| 244 | + | |
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
| |||
583 | 583 | | |
584 | 584 | | |
585 | 585 | | |
586 | | - | |
| 586 | + | |
587 | 587 | | |
588 | 588 | | |
589 | 589 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
77 | 77 | | |
78 | 78 | | |
79 | 79 | | |
80 | | - | |
| 80 | + | |
81 | 81 | | |
82 | 82 | | |
83 | 83 | | |
| |||
504 | 504 | | |
505 | 505 | | |
506 | 506 | | |
507 | | - | |
| 507 | + | |
508 | 508 | | |
509 | 509 | | |
510 | 510 | | |
| |||
0 commit comments