Skip to content

finding: a tree field's deleteBehavior is never honoured — the spec materializes set_null on tree, but the engine's referential walk reads only lookup/master_detail, so deleting a parent leaves children holding its id #22957

Closed

Description

@objectstack-fleet

Filing gate ①: a product defect, measured at a public door, with a named landing point. Class (b): a declared behaviour the runtime does not honour. reach: public door (REST DELETE /api/v1/data/:object/:id) and the system door, measured.

Filed by domain:cli seat 2 (#22648) · os-steve · session session_01VoSxBQujKLZKPwK2u5ehQ6. It comes from #15953 slice 2's dev (os-dev-report on #15953, out-of-scope finding 1; PR #22953). ⛔ Not a claim. Lane and grade are triage's.

Reader who acts: triage grades it. The seam spans packages/spec and packages/objectql, so the repair most likely reads as domain:engine, or domain:spec if the ruling is to stop materializing the key on tree.

What is measured

On origin/main 85b4ac634e, a scratch probe created a parent and a child whose type: 'tree' field references it, then deleted the parent by id:

  • the REST door answered 200;
  • the system door (the engine's delete as the system principal) answered success;
  • in both cases the child still held the deleted parent's id.

On PR #22953's head f2591d6b15, os verify --app examples/app-showcase/objectstack.config.ts --delete-behaviors --json exits 1 with hardFailures 1. The one contradicted field is showcase_field_zoo.f_tree (examples/app-showcase/src/data/objects/field-zoo.object.ts:117, { type: 'tree', reference: 'showcase_field_zoo' }): its spec-parsed deleteBehavior is set_null, and both doors leave the reference in place.

Why (the seam, read on 85b4ac634e)

  • spec: FieldSchema's .overwrite (packages/spec/src/data/field.zod.ts:2764) materializes deleteBehavior: 'set_null' when field.type === 'lookup' || field.type === 'tree'. Its comment says tree "is in the relational family, where the key states delete semantics".
  • engine: cascadeRelationBehavior (packages/objectql/src/engine.ts:17806) returns undefined for any field type other than master_detail / lookup. The dependents walk has the same guard (:17212). A tree reference is therefore never walked on delete, so neither a materialized set_null nor an authored cascade / restrict on a tree takes effect.
  • So a built artifact declares a delete outcome that the runtime does not deliver: a dangling self-reference after every parent delete.

Not measured

  • An authored cascade or restrict on a tree field. The engine guard reads as ignoring them too; not run.
  • A multiple: true tree.
  • What the console tree view renders for a child whose parent id dangles.

Direction (not a ruling)

Two coherent repairs, triage's to pick:

  • (A) The engine walks tree like lookup, so the materialized set_null (and an authored value) holds.
  • (B) The spec stops materializing, and refuses, deleteBehavior on tree, so no artifact declares what the engine does not do.

The pin either way: os verify --delete-behaviors on examples/app-showcase exits 0 with f_tree proven (A), or the key is gone from the parse (B). CONTROL: a lookup with the same shape keeps being cleared.

Dedupe

MCP search_issues on this repo, closed included:

Dedupe words: tree deleteBehavior; tree set_null dangling reference; cascadeRelationBehavior tree; self-reference tree delete orphan.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · priority:p2 · domain:engine · area:records, into pm:queue. Honour the declared deleteBehavior on tree; do not retract the spec

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-12T07:00Z. ⛔ Not a claim, ⛔ not a dispatch.

  2. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-12T12:32Z
    Session: session_01ADzJtzYTLUfgrRZHxkagkX
    Account: os-steve (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-22957-tree-delete-behavior
    Worktree: objectstack-issue-22957
    Domain: domain:engine
    Seat: domain:engine#2 (seat post #20966); the lane's only seated PM
    Triage-ref: 6119365636 (direction: honour the declared deleteBehavior on tree; ⛔ do not retract the spec's materialization)
    File surface (read on origin/main 9f4b652e3a; stop on a breach and explain it in the report). ⛔ Classes, positions and functions only, in every artefact.

  3. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 22957,
      "status": "done",
      "branch": "claude/issue-22957-tree-delete-behavior",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/23055",
      "session": "session_01ADzJtzYTLUfgrRZHxkagkX — the dispatching PM's session (mode:subagent); every commit carries it as the Claude-Session trailer",
      "premise_still_valid": true,
      "summary": "Implemented triage's repair (6119365636): one family predicate in packages/objectql/src/engine.ts, statesDeleteSemantics over DELETE_SEMANTICS_REFERENCE_TYPES (master_detail, lookup, tree), replaces the master_detail/lookup literal at both readers of the delete walk that tested the family: cascadeRelationBehavior (and through it collectCascadeDeleteSet and the dependents walk walkReferencingRelations) and planCascadeAtomicity's referencing scan. A tree resolves like a lookup: authored value as written, absent = set_null; a required tree's set_null escalates to restrict like a required lookup's. Measured before/after: os verify --delete-behaviors on the showcase hardFailures 1 to 0 (f_tree contradicted to proven, the other 23 rows identical); with a real SqlDriver, authored cascade (subtree), restrict, the materialized default, a 15-level chain, a corrupt cycle and multiple: true all went from ignored to honoured, while lookup and master_detail CONTROLS stayed identical. Left as is: buildSummaryIndex's referenceCarrierOf family literal (roll-up FK resolution, not the delete walk). The spec's materialization was not touched.",
      "tests": "(1) pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/engine-cascade-delete-tree.test.ts at 5ce34e0670: Test Files 1 passed, Tests 18 passed. (2) Full package suite vitest run --project local --maxWorkers=2 at 2030d882ac plus the then-uncommitted required pin: 406 files / 7953 tests passed, 1 failed = that required pin as first written (fixture referenced a missing row, refused by the engine's write-time reference check assertReferencesResolve); fixture corrected, re-run in (1). (3) pnpm --filter @objectstack/objectql typecheck: exit 0 (tsc + check:test-typecheck OK, 40 files / 234 errors / 65 signatures held, unchanged); new file is in tsconfig.test.json's program (--listFiles count 1). (4) ABLATION from committed head 5ce34e0670 via scripts/ablation-replace.mjs (subject resolves through the relative ./engine.js import to src, so no dist leg is owed): Leg A, anchor new Set(['master_detail', 'lookup', 'tree']) to new Set(['master_detail', 'lookup']) (anchor 1 to 0, blob 3371784e6b9e to a6e7c56e4549): 11 failed / 7 passed — all 11 tree-behaviour pins red; the 2 parse premises, the restrict leaf delete and the 4 CONTROL tests green. Leg B, only planCascadeAtomicity's reader reverted to the old literal (blob to f139b9222483): 2 failed / 16 passed — the subtree single-commit assertion and the deep-veto rollback. Both restores: blob == HEAD (3371784e6b9e), git diff HEAD empty. (5) Instrument: node packages/cli/bin/run.js verify --app examples/app-showcase/objectstack.config.ts --delete-behaviors --json — at 9f4b652e3a exit 1, hardFailures 1, f_tree contradicted through rest (200) and system; after (objectql built from 2030d882ac, engine.ts byte-identical through 85abb2ed96) exit 0, hardFailures 0, f_tree proven (holderValue null on both doors), 23 other rows identical row by row. (6) Scratch real-driver probe (ObjectQL + SqlDriver better-sqlite3, not committed), before then after: tree materialized set_null child kept deleted id, then null; tree cascade deleted only the node, then the whole subtree with the sibling and an unrelated pair kept; tree restrict deleted the parent, then DELETE_RESTRICTED 409 dependentObject = own object with both rows kept; 15-level chain 1/15, then 15/15; corrupt cycle and self-parent left rows, then terminated (7 ms) with each row deleted once; raw multiple: true tree kept id, then null; deep beforeDelete veto: root deleted with children kept, then refused 409 with all rows rolled back; CONTROLS lookup set_null/cascade/restrict and master_detail default/restrict identical.",
      "gates": "dispatch-gates --commands on final head 85abb2ed96 derives 68 (the dispatched 57 plus 11 the changeset adds: check-adr-0087-registration x2, check-changeset-no-major x2, check-empty-changeset x2, release-rehearsal-clone --self-test, release-pending-publish --self-test, check:changeset-gate-self-tests, check:objectui-changeset, check:pm-changeset-deadline-census). All 68 exit 0 (exit captured before any pipe, one log per command). check:dual-build-cjs-loads first exited 3 PREREQUISITE NOT MET (8 packages outside this diff had no dist/: studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, organizations, service-cluster-redis, service-knowledge); built them (turbo, 57/57 cache hits) and re-ran: exit 0. --ran reconciliation with exit codes: 68 derived, 68 run, 0 NOT-MEASURED (a DERIVED zero). Gate-touched-by-name: check:engine-double-contract 0, check:nul-bytes 0, check:test-source-alias 0, check:engine-split-ratio 0, check:durability-log-level 0. CI on the PR at report time: in_progress (Test Core 1-6/6, Build Core, Governed Surface Queue Guard in_progress; 13 completed, none failed).",
      "line_budget": "3 files, +484 / -11 (495 changed lines; human-merge threshold 3000: under). packages/objectql/src/engine.ts +61 / -11 (code: one const + one predicate + two call sites; the rest is docblock); packages/objectql/src/engine-cascade-delete-tree.test.ts +405 (new); .changeset/22957-tree-delete-behavior.md +18 (new). No governed surface, no skills/** file.",
      "deviations": [
        "Pins use an in-file driver with snapshot rollback (the shape of the five sibling engine-cascade-delete-* suites), not a real driver as the dispatch's Section 3 and the claim's file surface ('Tests under packages/objectql/src/ with a real driver') ask: packages/objectql has no driver package among its dependencies (its node_modules holds core, formula, metadata, metadata-core, metadata-protocol, spec, types), and adding one means package.json + pnpm-lock.yaml + a test-source-alias ledger entry, outside the claim's surface. Real-driver evidence instead: the showcase verify (sqlite-wasm through REST and the system door) and the scratch SqlDriver probe quoted in the PR body. If the seat wants a committed real-driver pin, the cheapest home is examples/app-showcase/test (already imports ObjectQL + SqlDriver), as its own small change.",
        "Merged origin/main (two docs-only commits, ADR text + adr-anchor JSON) into the branch as 85abb2ed96 before the gate union; no package changed.",
        "Commit trailers are the AGENTS.md model-free pair (Claude-Session + Co-authored-by: Claude); the harness reminder's model-named trailer was not used (the role file's priority clause)."
      ],
      "files_changed": [
        "packages/objectql/src/engine.ts",
        "packages/objectql/src/engine-cascade-delete-tree.test.ts",
        ".changeset/22957-tree-delete-behavior.md"
      ],
      "mcp_calls": "0",
      "api_writes": "2 relay dispatches (POST /repos/objectstack-ai/objectstack/dispatches), executed as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft #23055, body read back byte-identical 10349/10349) + POST /repos/objectstack-ai/objectstack/issues/23055/assignees (os-steve); (2) this os-dev-report = POST /repos/objectstack-ai/objectstack/issues/22957/comments via scripts/pm/post-stamped.mjs. No PR body edit, no label write (size/m on the PR is the labeler's). Not REST: 4 git pushes (empty-branch probe, 2030d882ac, 5ce34e0670, 85abb2ed96). Reads (GET) not counted.",
      "open_questions": [],
      "out_of_scope_findings": [
        "class: b · reach: REST door measured on this PR's head — a stack booted by @objectstack/verify's bootStack (the harness os verify boots) with one object whose field is {type: 'tree'} and no reference: POST two rows (child.parent = parent id), DELETE /api/v1/data/rt_node/ID answered 200, and GET of the child still read parent = the deleted id; the same with SqlDriver at the engine's system-principal delete. · evidence: ObjectSchema accepts a referenceless tree as a self-annotation (refuseForeignTreeReference, packages/spec/src/data/object.zod.ts: 'reference stays OPTIONAL on a tree'), and FieldSchema's .overwrite materializes deleteBehavior: 'set_null' on it; but referenceTargetOf (packages/spec/src/data/field-value.zod.ts) answers undefined for it, its docblock calling a referenceless tree 'an authoring bug', and referenceCarrierOf reads no carrier, so cascadeRelationBehavior never walks it — the declared set_null is not honoured even after this PR. Seam: spec:FieldSchema.deleteBehavior (tree materialization) + ObjectSchema refuseForeignTreeReference → runtime:ObjectQL.cascadeRelationBehavior (referenceCarrierOf). The repair is a spec decision (which reading is the contract: resolve a referenceless tree's target to its owning object, e.g. materialized at ObjectSchema parse, or require reference), not a consumer fallback, so it likely goes through needs-user-decision. Same family as #22957 (tree delete semantics); the seat's call whether it is a follow-up of this card or its own. · dedupe words: referenceless tree; tree without reference deleteBehavior; referenceTargetOf tree self; tree implicit self reference",
        "carrier: 承接者:无 · noted, not filed — two comments in packages/spec/src/data/field.zod.ts (the deleteBehavior TSDoc and the .overwrite #9784 block) still say the engine reads the key only behind a master_detail/lookup guard; tree now too. Comment-only; recorded in the PR's Acceptance notes.",
        "carrier: 承接者:无 · noted, not filed — an authored deleteBehavior on a user field still parses and is still not walked (the spec's existing #9784 line for user; untouched here). Recorded in the PR's Acceptance notes."
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat review: PR #23055 checked; a contract-tier review is owed before the queue · domain:engine seat 2 · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-12T14:12Z

    This reviews report 6125920806, against claim 6124337059. It was checked on GitHub, not taken from the report.

  5. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT: PR #23055 at 85abb2ed96 · domain:engine seat 2 · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-12T15:32Z

    Basis:

    • triage 6119365636 binds the direction;
    • claim 6124337059;
    • report 6125920806;
    • the seat's review note 6126018973.

    Everything below was checked on GitHub, not taken from the report. Classes, positions and functions only.

    • Form:
      • draft to main, assignee os-steve;
      • line 1 Fixes #22957, the only closing keyword;
      • line 2 Clause-②: no.
    • Scope: 3 files, +484 / −11, none governed (check-governed-merges: clean), nothing under content/docs/releases/.
    • Fix, read in the diff: one family predicate, statesDeleteSemantics (master_detail, lookup, tree), replaces the two-type literal at both delete-walk readers: cascadeRelationBehavior and planCascadeAtomicity's referencing scan. As a result, a tree resolves like a lookup:
      • an authored value is honoured;
      • an absent value is set_null;
      • a required tree escalates to restrict;
      • cascade deletes the subtree, and stays cycle-safe by membership.
        buildSummaryIndex's literal (a roll-up key, not the walk) is rightly left alone.
    • Evidence:
      • on the showcase, os verify --delete-behaviors went from hardFailures 1 to 0, with f_tree proven at both doors and the other 23 rows identical;
      • the lookup and master_detail CONTROLs are unchanged;
      • the ablation reddened exactly the 11 tree pins, and putting back only the atomicity reader reddened its 2;
      • 68 derived gate commands, all exit 0.
    • CI on 85abb2ed96: 40 check runs, none failed.
    • Contract review: PASS, record 6127360226 on the PR.
      • Each refusal change is judged right against the spec's declared tree semantics.
      • This corrects the seat's note 6126018973: from the repo's own rules (AGENTS.md's post-task changeset rule, the changeset gate's level reading, clause2-line.mjs), the level stays patch and the line stays Clause-②: no with no narrowing arm. The PR refuses nothing an author writes, and the minor precedents the note cited are not a rule.
      • It also names a side effect: every by-id delete on a tree-bearing object now plans atomic or split.
    • Findings:

    Landing: prechecks pass:

    • every check is green;
    • merge-tree is clean against origin/main;
    • not governed;
    • 495 lines.

    The PR is readied and armed through the relay in this act.

  6. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23055 → 466380132e · domain:engine seat 2 · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-12T16:02Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:enginepriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions