Skip to content

fix(spec, objectql): a tree field with no reference is a self-reference, so its deleteBehavior takes effect - #23092

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23065-referenceless-tree
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23065-referenceless-tree

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

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 FieldSchema.deleteBehavior defaults set_null onto EVERY field type, so built artifacts ship the key on text/datetime/number fields #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 0511341408 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.

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

…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>
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 6 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/field-value.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-driven.mdx (via FieldSchema (symbol, a top-level const), ObjectSchemaBase (symbol, a top-level const object))
  • content/docs/data-modeling/external-datasources.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx (via FieldSchema (symbol, a top-level const))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-1.mdx (via FieldSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/field-value.zod.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 141 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6662e3f590e9790a28f598d3eb6f665e46f10703 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8327cddc2a0c0d2f34a0c30893524f889074cad2 — the merge of head d09dffd8bfa4dd035daa612fbf5448bf9a8ea7b9 into base 6662e3f590e9790a28f598d3eb6f665e46f10703, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6662e3f590e9790a28f598d3eb6f665e46f10703 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bd8eff9eb6160fdbefcc1ff66b073eece047fa5d
Local-runs: none

Inputs: card #23065 (body; triage 6128730227; claim 6129099833; os-dev-report 6131566986), PR #23092 (body, 13-file list, net diff origin/main...bd8eff9eb6, 568+/24-), and the 39 check-runs on the head. Read-only: git reads of the fetched PR ref and REST reads; no build, test, gate or ablation run here. The head is 7 commits behind origin/main (merge-base 275ad4229a, main at 8c10776152); the PR reads mergeable: true.

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 check:api-surface, check:authorable-surface and check:docs). The landing rule's all-green condition is checked at landing, not by this record; the dev reports the same gates green locally at this head.

① Derived judgments

  1. Parse output — RIGHT. ObjectSchema and ObjectExtensionSchema run materializeTreeSelfReferences as an .overwrite after their refinements, writing the declaring object's name into reference on a tree field that names none. Against the triage ruling: the ruling's binding content is one reading, enforced, with no fallback in one reader; route (ii) satisfies it more strictly than editing the readers would — one helper, two object-aware doors, zero reader changes, and all 26 referenceTargetOf call sites plus the carrier-reading delete walk see an ordinary self-reference. The ruling's phrase "the engine's reference readers take the field's own object" is realised by handing the readers a field that names it; nothing in the ruling requires the readers themselves to change, and the ⛔ it does carry (no reader-side fallback) is honoured. Against refuseForeignTreeReference: its guidance "Drop reference (it is optional on a tree)" stays true at the input and now yields exactly the self-reference it promised; the refusal itself is untouched. Round trip: output is byte-identical to a field that wrote its own name (the key lands at FieldSchema's shape position) and a re-parse is a no-op — both pinned in tree-reference-self-only.test.ts. A stored sys_metadata body keeps its bytes; the registry serves getItem('object', name) through getObject (the materialized merge), so a GET then PUT of /meta/object stores the own name, which the parse accepts. Diffs and os validate: a built artifact or defineStack output for such a field gains one key, the same class of change as the deleteBehavior: set_null materialization already on the field; os validate and os lint read that output, so the lint graph gains the edge. Authorable surface: unchanged — reference was already optional on tree and nothing new is accepted at the input; the input schema (gen:schema) does not move. Extension: ObjectExtensionSchema writes extend, which is right — the fields merge into that object (the [finding] a tree field's reference has four different meanings across spec, designer, docs and the one shipped example — and nothing reads or enforces any of them #14892 refusal already judges a tree inside an extension against extend), and the engine registers an extension as { name: ext.extend, ... } with ownership extend (engine.ts manifest loader), so the registry door stamps the same name.

  2. The registry — RIGHT, one funnel. registerObject runs the same spec helper immediately after the field-type door and before applySystemFields, on a copy (schema = { ...schema, fields }; the helper copies the map and each stamped field), so the caller's definition is not mutated (pinned in engine-cascade-delete-tree.test.ts). It is a second SITE, not a second OPINION: same predicate, same output, no reader logic; the registry is the one choke point every door passes (declared stacks, manifests, the saveMetaItem write-through, the sys_metadata boot rehydration, raw registerObject), and the parse door is still needed for the authoring-time outputs (defineStack, create(), built artifacts, os validate). The one place that keeps the raw body — applyObjectRegistryMutation also calls registerItem('object', item) — is never read for objects, since getItem routes object to getObject. No ADR-0087 conversion entry is needed: no authorable key is added, aliased, renamed or retired; the stored shape stays legal and is materialized at every registration.

  3. Door behaviour for a referenceless tree, each with its direction. The delete walk under the materialized default set_null: before, DELETE answered 200 and the child kept the deleted id; now 200 and the child's parent is null — the declared contract taking effect (pinned at the engine, the REST door and the metadata save door). restrict: before 200 and the delete went through; now 409 DELETE_RESTRICTED naming the tree's own object — a refusal at the delete door that did not exist, exactly the declared deleteBehavior (pinned, both doors). cascade: before children kept; now the subtree is deleted as one unit of work — a destructive widening at the delete door, exactly as declared (pinned). The write-time reference check: before, POST of a child naming a parent id that does not exist answered 201; now 400 reference_not_found — a NARROWING at the record write door, which is assertReferencesResolve doing for this field what it does for every reference field once referenceTargetOf answers (tabled in the PR, stated in the changeset, NOT pinned by a committed test). expand=parent: 404 to 200, and the nested relation filter: 400 to 200 — widenings at the read door (tabled, not pinned). The dangling audit and os verify --delete-behaviors now cover the field (the verify derivation is pinned). Clause-② line: Clause-②: yes (widening) is RIGHT — the public surface grows by one export and the parse output gains a materialized key, while the published accept set (what the contract accepts or refuses) is unchanged. The write-door refusal does not call for the (narrowing) arm: by the repo's own rule the arm answers for the PUBLISHED contract surface and pulling the runtime back to the declared contract does not touch Clause-②, and the value a reference field may carry was always "an existing row of the target" — the field simply had no target before. Accordingly no ADR-0087 disposition is due (check-adr-0087-registration keys on major, a BREAKING banner, a ! type or the narrowing arm; none applies, and the ledger governs authorable-shape migrations, of which there is none). The Widens: sentence is correct as scoped ("nothing that parsed before is refused"); it does not itself name the 201-to-400 change at the record door, which the body's table and the changeset's consumer-facing text do. Complete for the declaration's purpose; naming the record-door refusal in that sentence would have been tidier.

  4. The new export materializeTreeSelfReferences(ownName, fields) — RIGHT. Reachable from @objectstack/spec/data: src/data/index.ts re-exports object.zod, package.json exports['./data'] addresses it, and both api-surface/data.json and export-origins/data.json carry the symbol. Name states the act. Contract: generic over the field map, returns the same reference when nothing is written, otherwise a shallow copy with copied stamped fields; absence is undefined, null or empty string (the carrier reader's absence); a present value is untouched (a foreign one is the refusal's, a non-string one the carrier reader's); an array or a nameless call returns the input. A runtime helper in packages/spec sits inside the precedent of the exported normalize* parse helpers already there.

  5. One judgment named WRONG, bounded: the two doors disagree on reference: '' on a tree. The parse door refuses it (refuseForeignTreeReference continues only on undefined or the own name, so an empty string is "a foreign target"), while the registry door — which never parses — rewrites it to the own name (the helper reads empty as absence, and the dev's test pins exactly that). So a raw registration or a metadata save carrying reference: '' is accepted and silently repaired where the contract refuses it. The effect is confined to a shape the contract already refuses at the authoring door, and before this PR the same shape read as "no target" at the registry, so no accepted input changes meaning; it is not a FAIL ground. See ③ item 10.

② Semver level

@objectstack/spec: minor — RIGHT. A new exported symbol on the published ./data index is an additive widening of a public surface; under WHICH LEVEL in pr-automation.yml that is at least minor, and the fix( type cannot lower it. @objectstack/objectql: patch — RIGHT. registry.ts changes one import line and the body of registerObject; the exported signature registerObject(schema, packageId?, namespace?, ownership, priority) is unchanged, index.ts exports nothing new, and the registered definition now carrying a self-reference is a fix of what the registry hands its readers, not a new public symbol, key or value. The level axis (check-changeset-no-major) needs at least one moved package at minor on a yes declaration, and spec is it; Check Changeset and the Lint CI, release and engine gates run are green. @objectstack/verify publishes nothing here (one test file) and needs no changeset; @objectstack/lint is untouched; the docs are generated.

③ Boundary flags

  1. Route (ii) completed at the registry instead of parse-only or reader-threading — ANSWERED, accepted. The measured necessity holds (the save door stores the body verbatim; a raw registration is never parsed), and the result is inside the triage's one-reading, no-fallback ruling. packages/objectql/src/registry.ts is inside the claim's "engine's callers in packages/objectql/src/ only as needed".
  2. File surface: registry.ts not engine.ts; the door pin in packages/verify/src — ANSWERED, accepted: the card asked for the bootStack reading as the pin, and bootStack lives in verify.
  3. The user sentence reworded rather than removed — ANSWERED, accepted: DELETE_SEMANTICS_REFERENCE_TYPES in engine.ts is exactly master_detail, lookup, tree, so user is outside the family and the substance was true.
  4. Not merging origin/main before opening the PR — ESCALATED to the seat. AGENTS.md multi-agent discipline §10 says: before opening a PR, pull main, refresh build state and run the full suite once, and names the generated snapshots as the classic jointly-wrong artifact when packages/spec moved on either side. Measured here: 7 commits landed on main since the merge-base (three of them feat(spec), one touching registry.ts); exactly two files are touched on both sides — packages/objectql/src/registry.ts (main hunks at 2023 and 4594 to 4827; PR hunks at 1 and 2651: disjoint) and content/docs/references/data/object.mdx (main hunk at 456; PR hunks at 241 and 583: disjoint, and the file is generator-owned). A textual-disjointness reading is what §10 says is not a working-merge proof, and the incoming commits do touch the PR's packages, so the deviation is real. Compensations in place: the PR's check-runs ran on GitHub's merge ref against main at run time, and the queue rebuilds the PR as merged onto current main and re-runs the subscribing workflows, where check:docs reads the regenerated object.mdx. The seat decides: queue as is on the rebuilt-generation leg, or have the dev merge origin/main, rebuild spec and run check:generated and the suites, and push — which moves the head, and a merge commit is not a pure regeneration, so the record would be re-rendered.
  5. Clause-②: yes (widening) where the dispatch spelled bare yes — ANSWERED, accepted: the arm is the closed-pair form clause2-line.mjs reads as the same widening, and the PR body and the changeset agree.
  6. The 32-minute hold on the shared verify lock — noted for the lane; no action for this record.
  7. objectui's use of referenceTargetOf and referenceCarrierOf NOT MEASURED (two 403s) — ANSWERED with a bound, residual named. Neither reader's signature or behaviour for a field that names a target changes, so a pinned checkout cannot break on a symbol. The shape a consumer now receives — a tree whose reference is its own object — is already in the shipped corpus (the showcase's f_tree writes it), so objectui has been receiving it; the residual is a reader that distinguishes a referenceless tree from a written one by key presence, which this review cannot rule out and the Console Pin Gate did not run on this PR. The seat can close the residual with one grep of ../objectui at the pinned sha if it wants it closed.
  8. The save-door lint judging the raw body — ANSWERED, accepted as a note. assertRuntimeAuthoringRules evaluates AUTHORING_RULES over the authored body before registerObject, and the object-graph rule reads referenceTargetOf(def), so a referenceless tree inside the very object being saved contributes no edge in that one evaluation while os validate and os lint on defineStack output see it. The effect is a graph finding that cannot be raised at the save door, never a false refusal — the same class as every parse default the lint does not see. Not a contract violation under Prime Directive chore: version packages #10; the seat may file a lint-lane note if it wants the save door to lint the materialized body.
  9. ObjectSchema.create() typed off the authored literal, and the verify dist race — ANSWERED, accepted as notes (the first is every parse default's property; the second is an environment property, observed once and green on re-run).
  10. Found by this review, not raised by the dev: (a) the reference: '' door disagreement in ① item 5 — carrier: none; a one-line tightening (stamp only undefined and null, leave the empty string to the carrier reader as before) would make the registry door refuse-or-ignore exactly what the parse refuses; dedupe words: tree reference empty string registry parse disagree. (b) Three door readings the PR tables — write check 201 to 400, expand 404 to 200, nested relation filter 400 to 200 — are measured but not pinned by a committed test; the card's acceptance asked for the bootStack delete pins only, so this is not a gap in the card, and the seat may ask for the write-check pin since that reading is the one refusal added at a data door. (c) Seven check-runs are still in progress at the stamp (Test Core 1 to 6, Type Check · workspace); this record does not wait on them, and the landing step reads them.

Adopted by domain:engine seat 2 at 2026-10-12T20:10Z, after a tool-use audit of the isolated reviewer's transcript: reads, git fetch into a review ref, scratch files inside this PR's scratch directory, and one record write. It made no GitHub write and ran no build, test or gate.

Implemented-by: claude/issue-23065-referenceless-tree
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

VERDICT: PASS

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d09dffd8bfa4dd035daa612fbf5448bf9a8ea7b9
Local-runs: none

Delta review after the PASS record 6131807682 on bd8eff9eb6160fdbefcc1ff66b073eece047fa5d. Inputs: card #23065 (body; triage 6128730227; claim 6129099833; os-dev-report 6131566986; the seat's patch-round note 6131822738; os-dev-report 6133690514), PR #23092 (the seat-edited body, the 13-file list, the net diff against main, the delta bd8eff9eb6..d09dffd8bf, its two comments including the prior record), and the 48 check-runs on the head. Read-only: git reads of the PR ref fetched into refs/review/pr-23092-r1 and REST reads; nothing built, run or re-run here. The head's merge-base with origin/main is 0511341408, the merged tip; origin/main had moved on to 55828c5145 at the read.

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 check:api-surface, check:authorable-surface and check:docs, which the prior record saw still running), Type Check · source, consumer and debt gates, TypeScript Type Check, Build Core, Build Docs, Test Core 1/6 through 6/6 and the aggregate, Lint and Repo Gates, Flag docs affected by code changes, Check Documentation Links, the four claim guards and filter. Skipped: Console Pin Gate, the Dogfood Regression Gate shard matrix, Dogfood Verify CLI, Packed-tarball smoke (opt-in) and Temporal Conformance, all by path filter, plus Auto Label and Check PR Size on the body-edit event at 2026-10-12T22:24Z, whose first runs on this head succeeded. No legacy commit statuses exist (combined status total 0). GitHub read the PR as mergeable: true, mergeable_state: clean, draft, on a re-read after that stamp.

① Derived judgments

  1. reference: '' on a tree — now ONE reading across the parse and the registry, RIGHT; the prior record's item 5 (its one WRONG) is closed. The helper's predicate at object.zod.ts line 1703 writes only when reference === undefined || reference === null; every other value, '' included, is returned untouched and an only-'' map returns the same object reference. The parse: refuseForeignTreeReference (line 1635) continues only on undefined or the own name, so '' is refused at the path fields, f_tree, reference — pinned in tree-reference-self-only.test.ts (ObjectSchema.safeParse(zoo('')) fails with exactly that one issue path). The registry: a raw registerObject of { type: 'tree', reference: '' } is stored with reference equal to '', and referenceTargetOf answers undefined because referenceCarrierOf (line 259) reads the empty string as naming nothing — pinned in engine-cascade-delete-tree.test.ts through engine.registry.registerObject, which never parses. Both doors now agree that '' is never a self-reference. Bound: the registry has no tree-specific refusal of its own, so a raw '' stays a silent no-target tree there, exactly what it was on main before this PR; outside the card. The helper pin was corrected to match (out.c is the same object as the input's c; the input is not mutated). The null arm: FieldSchema.reference is z.string().optional(), so the parse refuses null as a non-string shape, while the registry writes the own name on it. That is not the '' class: the tree refinement does not judge null a foreign target, the blueprint's StrictField.reference is strictIdentOrNull (solution-blueprint.zod.ts line 319) so null is a contract-admitted spelling of "names no target" at a door that reaches the registry raw, and referenceCarrierOf (line 258) already returned undefined for null without throwing, with its docblock naming null and undefined as ABSENCE. Both doors read null as absence; the parse refuses the spelling, as it refuses null on every optional string. Noted as a bounded residual in ③ item 2.

  2. The merge — RIGHT; both sides are held at the head. The merge-base of the head and origin/main is 0511341408, and git diff --numstat 0511341408 d09dffd8bf is exactly the 13 files of the REST file list, +643/-24; the three-dot diff against origin/main at 55828c5145 is the same 13 files. content/docs/references/data/object.mdx: main's hunk between the old merge base 275ad4229a and 0511341408 was the target row for type:'url' at line 459; the PR's hunks are the two reference rows at 241 and 583; the net diff against 0511341408 holds only the two reference rows, and the url row's text is present at the head (one match). The four artifacts only the branch moved (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, references/data/field.mdx, references/system/migration.mdx): main did not touch them between 275ad4229a and 0511341408 (empty diff), and their bytes at the head equal bd8eff9eb6's (empty diff), so there was nothing of main's there to swallow. packages/objectql/src/registry.ts: main's 18 hunks (collectBundle at 2026 and SchemaRegistry 4613 to 4888) are carried, and the PR's own hunk against the merged tip is still the import line plus registerObject at 2667, +15/-1; the delta's 92-line change to that file is the merge bringing main's hunks in (git diff f77dd94819 d09dffd8bf -- registry.ts is empty). The dev's own post-merge changes (f77dd94819..d09dffd8bf) are exactly five files: object.mdx (the regeneration, 1+/1-), object.zod.ts (docblock and predicate) and the three test files. One precision the PR body does not state: the merge commit f77dd94819 ALONE holds the branch's blob of object.mdx (ea117eff20, the same blob as at bd8eff9eb6), so in that one commit main's url row is reversed, and b390b3cbae restores it. That is the documented shape of os-regen-merge.sh: step 1's git merge --no-edit auto-commits the os-regen driver's output (content/docs/references/** is routed merge=os-regen in .gitattributes, and the driver drops a side silently), step 2 restores main's side into the WORKTREE only, step 3's ordinary commit is refused by the pre-commit hook by design, and step 4's regeneration commit carries both sides. The body's "Step 2 took main's side of object.mdx" is true of the worktree after step 2, not of f77dd94819. Effect: a bisect that lands on f77dd94819 reds check:docs; the queue merges the head, not that commit. Not a FAIL ground.

  3. The new door pins — RIGHT, as the seat asked. packages/verify/src/delete-behavior-referenceless-tree.test.ts, on the one bootStack of a defineStack config, gains two tests: the write check — POST /data/rtl_default with parent: 'no_such_parent_id' answers 400, code VALIDATION_FAILED, fields[] containing { field: 'parent', code: 'reference_not_found' }, with the CONTROL that an existing parent is accepted and read back as stored; and expand=parent answers 200 with record.parent matching { id, title }, then the nested relation condition { parent: { title } } lists exactly the one child. The objectql suite gains the raw-registration '' pin and the spec suite the parse-refusal pin, with the helper pin corrected (item 1). All of them ran in Test Core 1/6 to 6/6, success on this head. Bound: the three reader pins run on rtl_default, which reaches the registry already stamped by the parse; the registry-only door at the public surface is pinned for the delete walk (PUT /meta/object/rtl_saved then DELETE), and the registry writes the same key the parse writes, so the readers cannot tell the two doors apart — the write check, expand and the nested filter are not separately pinned behind PUT /meta/object. A residual, not a gap in the ask.

  4. The prior record's ① items 1 to 4 still hold on this head: the .overwrite wiring is unchanged (the object schema chain closing at object.zod.ts line 2846 and ObjectExtensionSchema at 3658 call the same helper with object.name and extension.extend), the registry hunk is byte-identical to round 0 against the merged tip, and field.zod.ts, field-value.zod.ts, the changeset and the two generated spec surfaces are untouched by the delta (empty diffs bd8eff9eb6..d09dffd8bf). The route-(ii) judgment, the one-funnel judgment, the door-behaviour table and the export's reachability are as the prior record read them; its item 5 is superseded by item 1 here.

② Semver level

Unchanged, RIGHT. The changeset at the head carries '@objectstack/spec': minor and '@objectstack/objectql': patch, with Clause-②: yes (widening) as its own line; the PR body's second line is the same Clause-②: yes (widening) and its Widens: line is unchanged and correct as scoped (the new ./data export and the materialized key on parse output; nothing that parsed before is refused). The delta added no export and changed no signature: api-surface/data.json and export-origins/data.json are byte-identical to bd8eff9eb6, the helper's predicate narrowed on an input the parse refuses, and registerObject's signature is unchanged. Check Changeset is success on this head twice (2026-10-12T20:16Z and 2026-10-12T22:25Z, the second after the seat's body edit), and the body installed by the seat equals the report's pr_body_replacement byte for byte. No (narrowing) arm and no ADR-0087 disposition is due, for the reasons the prior record gave; the write-door refusal is the runtime pulled back to the declared contract.

③ Boundary flags

  1. The merge (prior item 4) — ANSWERED; ① item 2. Residual the seat decides: origin/main has moved 8 commits past the merged tip since the merge (0511341408..55828c5145 at the read; the report named 2), and two of them touch files in this diff: abf86554c9 (fix(metadata-protocol): the artifact envelope is stamped only on the bundle member the package ships (#23074) #23094) moves registry.ts at 4782 and 4808 (the PR's hunk is at 1 and 2667, disjoint), and 55828c5145 (feat(spec): an empty state names its one next step (link: { label, url }), and a list page carries its own emptyState #23098) moves one generated row of object.mdx at 413 (emptyState gains link; the PR's rows are at 241 and 583). GitHub reads the PR mergeable and clean. The object.mdx case is the exact class os-regen-merge.sh exists for: the file is routed merge=os-regen, and the driver drops a side silently. Either queue on the leg whose rebuild regenerates object.mdx and runs check:docs on the merged tree, as the prior record's item 4 described, or ask one more os-regen-merge.sh round, which moves the head and re-renders this record. Textual disjointness is not a working-merge proof.

  2. The '' disagreement (prior item 10a) — ANSWERED; ① item 1. Residual, bounded: the registry writes the own name on null, which the parse refuses by shape; the contract admits null as absence at the blueprint door and the carrier reader already read it so, so no reader's reading of an accepted input changes. Not a FAIL ground and not asked by the prior record; the seat may ask for an undefined-only predicate if it wants parse-shape parity at the registry.

  3. The unpinned door readings (prior item 10b) — ANSWERED; ① item 3, with the residual that the three readers are pinned through the parse-fed object only.

  4. objectui not measured (prior item 7) — UNCHANGED. The acceptance note stands in the PR body, the Console Pin Gate skipped on this head by path filter, and the prior record's residual (a consumer that distinguishes a referenceless tree from a written one by key presence) stands; the seat can close it with one grep of ../objectui at the pinned sha.

  5. The dev's flags this round: (a) the resume message said the run stopped before check:generated --fix, and the dev measured a clean tree with nothing half-run — noted; the head's check-runs are the authority either way, and Type Check · workspace is success. (b) main moved two commits — superseded by item 1 here. (c) a later gate builds dists, the likely source of round 0's verify flake — an environment note, matching round 0's out-of-scope finding. (d) the shared verify lock held 32 minutes again, the second occurrence — a lane note, no action for this record.

  6. Found by this review: (a) f77dd94819 alone reverses main's url row of object.mdx, restored one commit later — a bisect hazard only (① item 2). (b) The Docs Drift Check comment was refreshed for this head at 2026-10-12T20:16Z on the merge of d09dffd8bf into 6662e3f590: advisory, 11 hand-written pages named through FieldSchema, 3 changed files with no anchor — the same rows as round 0, no new page. (c) Zero check-runs in progress at the stamp; the two skipped rows at 2026-10-12T22:24Z (Auto Label, Check PR Size) are re-runs on the body-edit event whose first runs succeeded, not path-filter skips.

Adopted by domain:engine seat 2 at 2026-10-12T22:39Z, after a tool-use audit of the isolated reviewer's transcript: reads, git fetch into a review ref, scratch files inside this PR's scratch directory, and one record write. It made no GitHub write and ran no build, test or gate. On the ACT finding, the seat re-measured object.mdx on the queue merge (git merge-tree of origin/main with this head). The merged file holds main's row and this PR's two reference rows. The merge group's Type Check · workspace (check:docs) verifies the regeneration on the merged tree, so the PR queues on this head.

Implemented-by: claude/issue-23065-referenceless-tree
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 12, 2026 22:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 12, 2026 22:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit 8464380 Oct 12, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23065-referenceless-tree branch October 12, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

2 participants