Repository navigation
fix(spec, objectql): a tree field with no reference is a self-reference, so its deleteBehavior takes effect - #23092
Conversation
…ng object Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…nce at the parse, the registry and the public doors Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…s for materializeTreeSelfReferences Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 141 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8327cddc2a0c0d2f34a0c30893524f889074cad2 && git checkout 8327cddc2a0c0d2f34a0c30893524f889074cad2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6662e3f590e9790a28f598d3eb6f665e46f10703 d09dffd8bfa4dd035daa612fbf5448bf9a8ea7b9 && git checkout -B drift-repro 6662e3f590e9790a28f598d3eb6f665e46f10703 && git merge --no-ff d09dffd8bfa4dd035daa612fbf5448bf9a8ea7b9
node scripts/docs-audit/affected-docs.mjs --json 6662e3f590e9790a28f598d3eb6f665e46f10703
|
Contract reviewServed-tier: Inputs: card #23065 (body; triage 6128730227; claim 6129099833; os-dev-report 6131566986), PR #23092 (body, 13-file list, net diff Check-runs on the head at 2026-10-12T20:07Z: 27 success (among them Check Changeset, Governed Surface Queue Guard, Lint · CI release and engine gates, Lint · PM dispatch-gates, Spec property liveness, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Build Core, Build Docs); 5 skipped by path filter (Console Pin Gate, the sharded Dogfood Regression Gate, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance); 7 STILL RUNNING: Test Core 1/6 through 6/6 and Type Check · workspace (the job that holds ① Derived judgments
② Semver level
③ Boundary flags
Adopted by Implemented-by: VERDICT: PASS |
…he merged tree Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…eck, expand and nested filter at the door materializeTreeSelfReferences writes only an undefined or null reference. An empty string is refused by the parse as a foreign target, so the registry leaves it as written instead of repairing it unparsed. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta review after the PASS record 6131807682 on Check-runs on the head at 2026-10-12T22:26Z: 48 rows, 41 success, 7 skipped, 0 queued or in progress. Success includes Check Changeset (twice: the push run and the re-run on the body edit), Governed Surface Queue Guard, Lint · CI, release and engine gates, Lint · PM dispatch-gates, Lint · ESLint, Lint · docs and code guards, Lint · repo and tooling gates, the three scoped lints, Spec property liveness, Type Check · workspace (the job holding ① Derived judgments
② Semver levelUnchanged, RIGHT. The changeset at the head carries ③ Boundary flags
Adopted by Implemented-by: VERDICT: PASS |
Fixes #23065
Clause-②: yes (widening)
Widens:
@objectstack/spec/datagains the exportmaterializeTreeSelfReferences, andObjectSchema/ObjectExtensionSchemaparse output now writesreference(the declaring object's name) on atreefield that omits it. The accept set is unchanged: nothing that parsed before is refused.What was wrong (measured at the door on
origin/main275ad4229a)The contract already reads a
treewith noreferenceas a self-reference:refuseForeignTreeReferencetells authors "Dropreference(it is optional on atree)", andFieldSchemamaterializesset_nullon the field. ButreferenceTargetOfreads one field alone and cannot know the declaring object, so every reader answered "no target". Booted with@objectstack/verify'sbootStack, one object withparent: { type: 'tree' }, through the REST door as the signed-in admin:tree, beforereference= own object (control)tree, this PRDELETEparent,set_null(default)parent: nullparent: nullDELETEparent,restrictDELETE_RESTRICTEDDELETE_RESTRICTED,dependentObject= the tree's own objectDELETEparent,cascadePUT /meta/object/:namethenDELETEparentparent: nullPOSTchild with a parent id that does not existreference_not_foundreference_not_foundGET /data/:object/:id?expand=parent{ parent: { title: 'p' } }inspectDanglingReferences)os verify --delete-behaviorscase derivationReadings taken with a scratch suite on both a raw config and a
defineStackconfig (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
referenceTargetOfhas 26 production call sites in 18 files across 8 packages (core,lint,metadata-protocol,objectql,plugin-approvals,plugin-audit,service-analytics,verify; counted at275ad4229a), 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 silentundefined, and any reader that readsdef.referencedirectly is still blind. That is one reading that has to be repeated correctly at every call site.ObjectSchema's parse alone does not reach the runtime for every door.PUT /meta/object/:namestores the authored body verbatim (registry entry measured:{"type":"tree","label":"Parent"}, noreference, nodeleteBehavior), and a rawregisterObject/ rawbootStackconfig is never parsed. So the registry, the one door every registration passes (it already runsobjectFieldTypeRefusalandapplySystemFields), 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
FieldSchemashape position, and a re-parse is a no-op.refuseForeignTreeReference's guidance still reads true, because droppingreferencenow yields exactly the self-reference. The authorable surface and the conversion ledger are unchanged (check:authorable-surfacegreen; no ADR-0087 entry).What changed
packages/spec/src/data/object.zod.ts: new exportmaterializeTreeSelfReferences(ownName, fields). Absence is the two spellings a contract admits for "names no target":undefined(FieldSchema's.optional()) andnull(the blueprint'sStrictField). Every other value is returned untouched.reference: ''is NOT absence here: the parse refuses it on atreeas 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 staysrefuseForeignTreeReference'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.ObjectSchemaandObjectExtensionSchema(own name =extend) run it as an.overwriteafter their refinements, so the schema stays aZodObject.packages/objectql/src/registry.ts:registerObjectruns the helper right after the field-type door, on a copy, so the caller's definition is not mutated. An extension registers undername: ext.extend.packages/spec/src/data/field-value.zod.ts: thereferenceTargetOfdocblock no longer calls a referencelesstree"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): thereferenceTSDoc and.describe()("…and writes the own name when it is left out"); thedeleteBehaviorTSDoc'sFieldSchema.deleteBehaviordefaultsset_nullonto EVERY field type, so built artifacts ship the key ontext/datetime/numberfields #9784 paragraph and the.overwriteblock now say the delete walk reads the key onmaster_detail,lookupandtree(thestatesDeleteSemanticsfamily). Theusersentence names that family:useris still outside it, so the substance was true and the wording was stale.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:minorfor@objectstack/spec(export added),patchfor@objectstack/objectql.Merge with
origin/main(patch round 1)origin/mainwas merged at0511341408(9 commits past the merge base275ad4229a) throughscripts/pm/os-regen-merge.sh, by merge commitf77dd94819. Step 2 kept the branch's bytes of the four artifacts only the branch moved, and took main's side ofcontent/docs/references/data/object.mdx, the one both sides moved. Step 3's commit was refused by theos-regenpre-commit as designed. Thengen:schema && gen:docsregeneratedobject.mdxon the merged tree, and the staged diff, read before committing, held both sides' rows: main'stargetrow fortype: 'url'and this PR's tworeferencerows. That was committed asb390b3cbae, and the hook printedcontent/docs/references/data/object.mdx — current … marker cleared. On the merged head, after a rebuild,check:generated --fixreported all 14 generated artifacts up to date,check:api-surface,check:authorable-surfaceandcheck:docsincluded, with nothing left to write.git diff --stat 0511341408 HEADis exactly this PR's 13 files. Since the merge,mainhas gained two commits,6662e3f590(runtimeapp-plugin.ts) and1badd31838(ui/app.zod.tsdocs). 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()anddefineStackwrite it. The extension door writesextend. The refusal's remedy holds. CONTROL: a written self-reference,lookup,master_detailandtextparse exactly asFieldSchemaalone parses them. Helper semantics:undefinedandnullare 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 atfields.f_tree.reference. One existing assertion (referencetoBeUndefined 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-stringreferencereaches the registry as written andreferenceTargetOfanswers no target. CONTROL: a written self-reference. 28/28.packages/verify/src/delete-behavior-referenceless-tree.test.ts: the door pins on onebootStack. ThroughDELETE /data:set_null,restrictandcascade. The metadata save door.runDeleteBehaviorProofsproves all referenceless cases through both doors. The write check: a parent id that names no record answers 400VALIDATION_FAILEDwithfields[]{ field: 'parent', code: 'reference_not_found' }(it was 201), and CONTROL: an existing parent is accepted and stored.expand=parentresolves 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.@objectstack/spec714 files / 20898 passed.@objectstack/objectql409 files / 7989 passed.@objectstack/verify35 files / 278 passed, run after the gates had finished: the previous round's concurrent-build "Failed to resolve entry" flake did not recur.typecheckis green for objectql and verify, test layers included; spec's test layer is OK throughcheck: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 byablation-dist-preflight, and atraprestore.bd8eff9eb6): the helper writes nothing. Red: spec 6/13, objectql 8/27, verify 5/6. Green: every CONTROL and the [finding] atreefield'sreferencehas four different meanings across spec, designer, docs and the one shipped example — and nothing reads or enforces any of them #14892 / finding: atreefield'sdeleteBehavioris never honoured — the spec materializesset_nullontree, but the engine's referential walk reads onlylookup/master_detail, so deleting a parent leaves children holding its id #22957 pins.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.''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.git diff HEADempty, spec rebuilt in full,ablation-dist-preflight --absentclean 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-loadsfirst 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-surfaceandcheck:docs("226 generated files in sync").dispatch-gates --ranwith exit codes: 115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN.Acceptance notes
treeinside the very object being saved sees no target there, whileos validate/os lintreaddefineStackoutput, 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 writtenreferenceon its result (the runtime value is there). This is the same property every parse default already has.sys_metadatarow keeps its body verbatim (ADR-0005 appendix (c)). Only the registered definition carries the self-reference, and a round trip throughGET/PUT /meta/objectstores the own name, which re-parses clean.referenceTargetOf/referenceCarrierOfis 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'sf_tree).Generated by Claude Code