Skip to content

fix(objectql)!: a user field's declared deleteBehavior takes effect when the user is deleted (#29253) - #29368

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-u66mdq
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-u66mdq

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Requested by Steve · project thread

Fixes #29253

Clause-②: no (narrowing)
Metadata-protocol: semantic
Skills-docs: follow-up #29400 — skills/objectstack-data/rules/field-types.md (the user row lists no deleteBehavior), skills/objectstack-data/rules/relationships.md; content/docs/data-modeling/fields.mdx is updated here

BREAKING for an app that declares deleteBehavior on a user field: the declared value now takes effect when the user is deleted. A delete of a user held by a restrict field (or a required field declaring set_null) is now refused with 409 DELETE_RESTRICTED where it succeeded; a declared cascade now deletes the holding rows; a declared set_null now clears them. The fix: if you declared deleteBehavior on a user field only as documentation, remove it or change it to what you want enforced.

维护者速读

  • 改了什么:user 字段上作者写明的 deleteBehavior 现在真的生效——删除用户时,restrict 拒绝删除(409 DELETE_RESTRICTED),cascade 删掉持有该用户的记录,set_null 清空该字段(多值字段只移除该成员);没写 deleteBehavior 的 user 字段行为不变。
  • 为什么改:spec 接受并保留 user 字段上的 deleteBehavior,但引擎的删除联动只认 master_detail / lookup / tree,user 字段上的声明一直是摆设,被删用户的 id 残留在记录里。
  • 风险与回滚:席位裁定为收紧(narrowing,BREAKING,objectql minor):写了 restrict(或 required + set_null)的应用,删除被引用的用户从 200 变成 409;写了 cascade 的会连带删记录。FieldSchema.deleteBehavior defaults set_null onto EVERY field type, so built artifacts ship the key on text/datetime/number fields #9784 之前存下的对象可能在 user 字段上带着自动写入的 set_null,现在也会生效。仓内平台对象和示例里没有这样的字段。回滚即 revert 本 PR。
  • 先看:packages/objectql/src/engine.ts(statesDeleteSemantics 与 deleteWalkTargetOf),packages/objectql/src/engine-cascade-delete-user-field.test.ts,packages/objectql/src/federated-injected-column-readers.test.ts(台账两行)。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/engine-cascade-delete-user-field.test.ts,预期 16 条全绿。
  • 需要你做的:无,席位落地。

What changes

Measured first: which doors delete a user (at origin/main b1779200):

Door Reaches ObjectQL.delete (so the walk runs)? Code path
better-auth admin remove-user, self delete-user (and its callback) yes, by id, as the system internalAdapter.deleteUser deletes the user model through the plugin-auth adapter: objectql-adapter.ts delete / deleteMany call dataEngine.delete(objectName, { where: { id } }), and dataEngine.delete is e.delete(m, asAttributedSystem(q)) (isSystem: true). The request runs inside runSubjectErasureAtomically (SESSION_ERASURE_PATHS), so a refusal rolls the credential rows back.
Generic data doors (REST DELETE /data/sys_user/:id, MCP / batch data tools) refused before the delete identity-write-guard.ts registers a beforeDelete that refuses a caller-context write to any managedBy: 'better-auth' object (ADR-0092 D2); sys_user declares no generic delete (userActions: { edit: true } only).
Ban / soft delete not a delete admin-ban-endpoints.ts updates the user and deletes its sessions; the sys_user row stays.

Every door that removes the sys_user row reaches the walk, so this is (B) enforce, and no spec change.

The change (packages/objectql/src/engine.ts only):

  • statesDeleteSemantics (the one family test every reader of the walk asks) admits a user field when, and only when, it declares a deleteBehavior of the enum's three values. A user field that declares none stays out: the spec's parse materializes no value on it, and the engine invents no default.
  • deleteWalkTargetOf (new) answers the object a relation points at for cascadeRelationBehavior and planCascadeAtomicity. It still reads the reference carrier through referenceCarrierOf, so an unreadable carrier still throws; for a user field it answers referenceTargetOf (sys_user), so a { type: 'user' } with no reference still points at the user.
  • Nothing else moves: an admitted user field then goes through the walk exactly as a lookup with the same authored value, including the multi-value $contains probe and member removal, the required set_null → restrict escalation, the two-phase cascade set and the one-transaction plan.

DELETE_SEMANTICS_REFERENCE_TYPES keeps master_detail, lookup, tree; its docblock's old "user stays out … made in the spec, never here" paragraph is replaced by the declared-only rule above.

Docs: one paragraph in content/docs/data-modeling/fields.mdx (Referential integrity). Changeset: .changeset/29253-user-field-delete-behavior.md, @objectstack/objectql minor (the level a semantic metadata-protocol kind takes), with the BREAKING banner, the one-line fix, and the ADR-0087 disposition not-required (no-migration-prescription): no schema key, export or type moves, and there is no stored value for a conversion to rewrite.

Tests

New packages/objectql/src/engine-cascade-delete-user-field.test.ts (16 tests; the real ObjectQL over a stub driver with snapshot rollback, whose WHERE matcher answers $or / $and / $in / $contains and throws on any other operator). Deletes go through the engine with and without the adapter's system context:

  • no deleteBehavior (parsed single, parsed multi, raw, required): the user is deleted and the holder keeps the id;
  • restrict, single and multi-valued: DELETE_RESTRICTED / 409 naming the holder's object, both rows kept; the same code and status a lookup restrict to sys_user answers; a user nobody holds still deletes; a user field with no reference still refuses;
  • cascade, single and multi-valued: the holders are deleted, a row holding another user stays; a later restrict rolls the cascade back;
  • set_null: single cleared to null; multi loses only the member, an emptied set is []; required set_null refuses.

Forced test edit: packages/objectql/src/federated-injected-column-readers.test.ts (the #21918 reader ledger). The two referenceCarrierOf() seam uses moved from cascadeRelationBehavior / planCascadeAtomicity into deleteWalkTargetOf, so the two old skips rows are replaced by two not-a-read rows for the helper. The two callers' own isFederatedUnprovisionedInjectedColumn() rows stay and still bind the skip.

Acceptance notes

  • Behaviour that changes for an app: a user field that declares restrict, or set_null with required: true, now refuses deleting the user it holds (409, was 200); cascade now deletes the holders; set_null now clears them. A repo-wide scan of Field.user( / type: 'user' declarations under packages/ and examples/ found none that declares deleteBehavior.
  • Rows stored before FieldSchema.deleteBehavior defaults set_null onto EVERY field type, so built artifacts ship the key on text/datetime/number fields #9784: the spec's parse then wrote deleteBehavior: 'set_null' on every field type, user included, so an object stored in that era can carry a set_null on a user field no author wrote. It now takes effect (cleared on user delete; refused on a required field). Stated in the changeset; the seat accepted it with no spec card.
  • The two declaration lines above were ruled by seat 5 after round 1 (a narrowing, semantic kind); the dispatch claim carried no arm and kind none.
  • Spec prose left as it is (no packages/spec/** edit on this card): the FieldSchema .overwrite comment in packages/spec/src/data/field.zod.ts still says a user field sits outside the walk's family; true for a bare one, no longer for an authored one.
  • os verify --delete-behaviors reports an authored user field not-derivable (its target sys_user is not an app-declared object), so that family does not start probing these fields.

Verification

Red first: the pins against the walk as it was (ablation 1)

return false; // ABLATION-29253 in place of the user arm of statesDeleteSemantics (the walk on main): Tests 10 failed | 6 passed (16). Every restrict / cascade / set_null pin fails (five with "expected the delete to be refused"); the four no-deleteBehavior controls stay green.

Ablations (scripts/ablation-replace.mjs; restore by git checkout HEAD, git diff HEAD empty, disk blob == HEAD blob 5defa623)
  1. user arm answers false (user out of the walk, as on main): Tests 10 failed | 6 passed (16).
  2. deleteWalkTargetOf answers the carrier only: Tests 1 failed | 15 passed (16), only "a user field naming no reference still references sys_user".
  3. user arm answers true (every user field joins, a bare one defaulting to set_null): Tests 4 failed | 12 passed (16), the four no-deleteBehavior controls (e.g. expected { person: null } to match { person: 'r_1' }, multi expected { person: [] }).
Code head 7675ff7: tests and gates (origin/main merged once, after #29245 landed)
  • pnpm --filter @objectstack/objectql test: Test Files 531 passed (531), Tests 10292 passed (10292); then pnpm --filter @objectstack/objectql typecheck (incl. check:test-typecheck): exit 0 (lock VERDICT command-exit 0). The first full run on the merge head 039625f was 1 failed: the reader-ledger test above, fixed in 7675ff7.
  • New pins src/engine-cascade-delete-user-field.test.ts: Tests 16 passed (16); src/federated-injected-column-readers.test.ts + src/engine-cascade-federated-tenant-anchor.test.ts: Tests 17 passed (17); fix(objectql): an update carrying a stored reference back unchanged is not refused because it stopped resolving (#29062) #29245's src/engine-lookup-referential-integrity.test.ts runs in the full suite above.
  • node scripts/pm/dispatch-gates.mjs --commands (no paths): 93 families derived; all 93 run, each exit 0; --ran: 93 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero). Includes check:where-matcher, check:objectql-double-limit, check:engine-double-contract (none new).
  • Lint: root eslint over packages/objectql with --no-inline-config: exit 0. Repo-wide pnpm lint is CI's.
Round 2 head 4a0ed13 (changeset and declaration only; code unchanged from 7675ff7)
  • Dogfood battery, closure built first (pnpm --filter '@objectstack/dogfood^...' build, lock VERDICT command-exit 0), then pnpm --filter @objectstack/dogfood test (no shard, no path): lock VERDICT command-exit 0 · Test Files 392 passed | 3 skipped (395) · Tests 3227 passed | 28 skipped (3255) · no failures.
  • node scripts/check-adr-0087-registration.mjs --base origin/main: exit 0, 1 declared-breaking changeset (banner, bang and narrowing arm all read), disposition not-required (no-migration-prescription).
  • node scripts/check-changeset-no-major.mjs --base origin/main --event (this body as the pull_request payload): exit 0, level axis reads the narrowing arm and the semantic kind, no moved package graded patch.
  • node scripts/pm/dispatch-gates.mjs --commands (no paths): the same 93 families; all 93 run, each exit 0; --ran: 93 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero).

…en the user is deleted

The referential delete walk now admits a user field that declares a
deleteBehavior; a user field that declares none stays outside it, so a
bare one is left as it was.

Claude-Session: https://claude.ai/code/session_01ADHkmPJSWAxwHWeAsqirug
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 7 documentable anchor(s).

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

  • content/docs/api/data-api.mdx (via set_null (literal, a string literal in DECLARED_DELETE_BEHAVIORS; a string literal in a comment in cascadeRelationBehavior))
  • content/docs/data-modeling/field-types.mdx (via set_null (literal, a string literal in DECLARED_DELETE_BEHAVIORS; a string literal in a comment in cascadeRelationBehavior))
  • content/docs/data-modeling/fields.mdx (via set_null (literal, a string literal in DECLARED_DELETE_BEHAVIORS; a string literal in a comment in cascadeRelationBehavior))
  • content/docs/data-modeling/validation-rules.mdx (via set_null (literal, a string literal in DECLARED_DELETE_BEHAVIORS; a string literal in a comment in cascadeRelationBehavior))
  • content/docs/deployment/cli.mdx (via set_null (literal, a string literal in DECLARED_DELETE_BEHAVIORS; a string literal in a comment in cascadeRelationBehavior))
  • content/docs/deployment/troubleshooting.mdx (via set_null (literal, a string literal in DECLARED_DELETE_BEHAVIORS; a string literal in a comment in cascadeRelationBehavior))
  • content/docs/protocol/objectql/types.mdx (via set_null (literal, a string literal in DECLARED_DELETE_BEHAVIORS; a string literal in a comment in cascadeRelationBehavior))

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

  • content/docs/releases/v15.mdx (via set_null (literal, a string literal in DECLARED_DELETE_BEHAVIORS; a string literal in a comment in cascadeRelationBehavior))
  • content/docs/releases/v17/17-1.mdx (via set_null (literal, a string literal in DECLARED_DELETE_BEHAVIORS; a string literal in a comment in cascadeRelationBehavior))

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
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 79 pages)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 20 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 eef116e4d1dd3b01d68371df4f775b0c77227ef0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from af739b179eae0b0e3faf4dac464144a785dbf826 — the merge of head 4a0ed134f4d43b1fd9079c2cb3e60e27b1d70a02 into base eef116e4d1dd3b01d68371df4f775b0c77227ef0, 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 af739b179eae0b0e3faf4dac464144a785dbf826 && git checkout af739b179eae0b0e3faf4dac464144a785dbf826
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eef116e4d1dd3b01d68371df4f775b0c77227ef0 4a0ed134f4d43b1fd9079c2cb3e60e27b1d70a02 && git checkout -B drift-repro eef116e4d1dd3b01d68371df4f775b0c77227ef0 && git merge --no-ff 4a0ed134f4d43b1fd9079c2cb3e60e27b1d70a02

node scripts/docs-audit/affected-docs.mjs --json eef116e4d1dd3b01d68371df4f775b0c77227ef0

⚠️ 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 eef116e4d1dd3b01d68371df4f775b0c77227ef0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet objectstack-fleet Bot changed the title fix(objectql): a user field's declared deleteBehavior takes effect when the user is deleted (#29253) fix(objectql)!: a user field's declared deleteBehavior takes effect when the user is deleted (#29253) Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract-review-claim: session_01ADHkmQKwKFCp1m6Pb6Q8c1 · head 4a0ed13 · 2026-11-02T14:07Z

The narrowing face is reviewed at CONTRACT_REVIEW_TIER by this seat, which dispatched the card, through an at-tier reviewer that is not the drafter. The scope is every path of the diff outside the prose faces.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4a0ed134f4d43b1fd9079c2cb3e60e27b1d70a02
Local-runs: none
Scope: packages/objectql/src/engine.ts, packages/objectql/src/engine-cascade-delete-user-field.test.ts, packages/objectql/src/federated-injected-column-readers.test.ts + packages/spec/src/data/field.zod.ts (deleteBehavior, the .overwrite tail, the user reference refusal, Field.user), packages/spec/src/data/field-value.zod.ts (referenceTargetOf, referenceCarrierOf, MULTI_CAPABLE_TYPES), packages/objectql/src/engine-referential-integrity.ts (the write-side check; the delete walk is not there), packages/plugins/plugin-auth/src/identity-write-guard.ts, packages/plugins/plugin-auth/src/objectql-adapter.ts (withSystemContext, delete/deleteMany, rethrowAsBetterAuthError), packages/plugins/plugin-auth/src/auth-manager.ts (runSubjectErasureAtomically), packages/plugins/plugin-auth/src/session-tombstone.ts (SESSION_ERASURE_PATHS), packages/spec/src/data/injected-system-column-provenance.ts (owner_id), packages/platform-objects/src/identity/sys-user.object.ts, packages/verify/src/delete-behavior.ts, packages/spec/src/migrations/entries/semantic/18.user-field-foreign-reference-refused.ts, packages/spec/src/migrations/entries/semantic/18.field-master-detail-set-null-refused.ts, .changeset/29253-user-field-delete-behavior.md, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs. The prose faces (content/docs/data-modeling/fields.mdx, the changeset body as prose) are the seat's ACCEPT; the changeset's declaration lines and semver level are judged in ②.

Inputs read: card #29253 (body, triage 6575476068, claims 6575764728 / 6575797254 / 6575850669 / 6575856999, os-dev-report 6578157141), PR #29368 (body, file list, net diff origin/main...4a0ed134), the check-runs on the head. Not read as inputs: the dispatch order or the dispatching seat's conclusions. The dev's report was treated as claims and each one was checked in the tree.

Check-runs on this head: every run is completed. Every check is success or skipped, with one exception. Check Changeset has three runs on this head: run 41452481747 (13:22Z) failed at the step "Guard against accidental major bumps (launch window)", and runs 41458196936 (14:02Z) and 41458906013 (14:07Z) succeeded. That step reads the PR body as the declaration input (--event), and the PR body was edited between the runs. The newest run of that check on this head is green, so the gate verdict on the head is green; the earlier red is not a merge-base signature and is not re-judged here. The PR is a draft with no auto-merge armed.

① Derived judgments

  1. Accept set of what an author may write: unchanged. FieldSchema on main accepts deleteBehavior on any type and returns an authored value verbatim (the === undefined guard in the .overwrite tail). The diff touches no packages/spec/** file. Right.
  2. Runtime meaning of an authored deleteBehavior on a user field: from inert to enforced. statesDeleteSemantics admits a user field when, and only when, deleteBehavior is one of set_null / cascade / restrict; the field then runs the lookup path with its authored value. restrict and a required set_null refuse the user delete with 409 DELETE_RESTRICTED where it answered 200. This is the narrowing. Right, and the only runtime change the diff makes.
  3. A bare user field: unchanged. The spec's parse materializes set_null on lookup and tree only (field.zod.ts, if (field.deleteBehavior === undefined && (field.type === 'lookup' || field.type === 'tree'))), so a parsed user field carries no key, and the engine invents none (DELETE_SEMANTICS_REFERENCE_TYPES still lists master_detail, lookup, tree; the || 'set_null' fallback is unreachable for user). Pinned by four controls and ablation 3. Right.
  4. deleteWalkTargetOf: the carrier is still read through referenceCarrierOf (an unreadable carrier throws; absence answers undefined); a user field answers referenceTargetOf, which is sys_user by IMPLICIT_REFERENCE_TARGETS whatever the carrier says. So { type: 'user', deleteBehavior: 'restrict' } with no reference takes part. Right; it follows the one arbiter the spec already publishes.
  5. planCascadeAtomicity('sys_user'): answers 'atomic' (or 'split' across datasources) instead of 'none' once a registered object declares a user field with a deleteBehavior. A user delete then runs inside a transaction where none was opened. Right, and required: the pinned "restrict after cascade rolls the cascade back" depends on it. The driver-without-beginTransaction degrade is the inherited ADR-0119 D1 one.
  6. getCascadeDeleteObjects / the metadata read's cascadeDeleteObjects (Delete confirm: an object can word its Delete confirmation, or the confirmation names what a cascade will delete #28001) reads through cascadeRelationBehavior, so an object whose user field declares cascade is now named. Right (same reader, one answer). The published describe strings still enumerate only master_detail / lookup / tree: see ④ finding B.
  7. Reader ledger (federated-injected-column-readers.test.ts): the two referenceCarrierOf() rows under cascadeRelationBehavior and planCascadeAtomicity are replaced by two not-a-read rows under deleteWalkTargetOf (referenceCarrierOf() and referenceTargetOf()). Both callers keep their own isFederatedUnprovisionedInjectedColumn() rows and, in the code, still ask the skip after the reference match. Right; Test Core on the head is green, so the source scan agrees with the table.
  8. Public surface: no export, type, schema key or error code moves. USER_REFERENCE_TYPE, DECLARED_DELETE_BEHAVIORS, statesDeleteSemantics and deleteWalkTargetOf are module-private. DELETE_RESTRICTED / 409 is the walk's existing envelope. Right.
  9. Doors, as the dev measured and as read here: better-auth's delete / deleteMany in the adapter call dataEngine.delete(objectName, { where: { id: record.id } }) under withSystemContext (isSystem: true, attribution added), which is the engine's by-id branch, so the walk runs. identity-write-guard registers beforeDelete at priority 10 and refuses a caller-context delete of any managedBy: 'better-auth' object (sys_user declares it). /delete-user, /delete-user/callback and /admin/remove-user are in SESSION_ERASURE_PATHS and run under runSubjectErasureAtomically. Ban is an update. No driver.delete of sys_user exists outside the engine (grep of non-test sources). Right.
  10. The repo-wide claim "no platform object or example declares deleteBehavior on a user field": checked by listing every non-test, non-spec file that declares Field.user( or type: 'user' and counting deleteBehavior in it; only engine.ts hits (its comments). The injected owner_id column is a lookup to sys_user (already in the walk), not a user field; sys_user.manager_id is a lookup. Right.
  11. os verify --delete-behaviors: packages/verify/src/delete-behavior.ts reads REFERENCE_VALUE_TYPES, so a user field with an authored value is a case; its target sys_user is not an app-declared object, so it is graded not-derivable, never contradicted. The dev's claim holds; that family does not start failing apps.

② Semver level

  • '@objectstack/objectql': minor. Right under the launch-window rule in check-changeset-no-major.mjs: the level follows the metadata protocol alone; a semantic kind grades minor; breaking ships as minor with the BREAKING banner and an ADR-0087 disposition as the carriers.
  • Clause-②: no (narrowing) on the PR body (line 6) and the changeset (line 7). Right. Nothing is widened; at the user-delete door an existing declaration turns a 200 into a 409, which is the narrowing arm's definition. The title carries the bang. The dispatch claims (6575764728 / 6575856999) said Clause-②: no and Metadata-protocol: none; the seat re-ruled after round 1 (PR body, Acceptance notes). The re-ruling is the right reading; see ③ flag 1.
  • Metadata-protocol: semantic. Right: no key is added or narrowed; an accepted key's meaning changes on one type.
  • The ADR-0087 marker, adr-0087: not-required (no-migration-prescription) with its why-text. Mechanically valid (the gate's prescription regexes look for framed arrows and table rows, and the body has none; the head's Check Changeset passed the disposition step). Judged on substance it is the weaker of two honest answers, for three reasons. (a) The ledger's SemanticMigration kind exists for exactly this class, and two 18.x entries on main are the precedent: field-master-detail-set-null-refused (an accepted deleteBehavior whose enforcement changed, no key moved) and user-field-foreign-reference-refused (which says a user field's delete behaviour "is owed by the author"). (b) The changeset itself states the consumer judgment owed ("remove it or change it to what you want enforced") and names a stored population that must be reviewed (pre-FieldSchema.deleteBehavior defaults set_null onto EVERY field type, so built artifacts ship the key on text/datetime/number fields #9784 materialized set_null on user fields), while the marker text says "there is no stored value for a conversion to rewrite". The two statements contradict each other inside one changeset; the honest form is "no conversion, one semantic entry", as the two precedents spell it. (c) Only a ledger entry reaches objectstack migrate meta and the generated upgrade guide; the changeset banner does not. The dev could not write the entry: the claim's file surface forbids packages/spec/**. Finding A in ④, should-fix: a domain:spec follow-up registers a semantic entry, and the disposition becomes registered.

③ Boundary flags

  1. open_questions[0], the Clause-② reading (dev recommended A: no; seat ruled B: no (narrowing)). Answer: B. The arm is read at the consumer's door, not at the authoring face. An author's accept set is unchanged, but a deployment's user-delete request that succeeded is refused on this head with an existing declaration. That is a narrowing. Resolved on this head: bang, arm on body and changeset, banner, and the dogfood run the body reports.
  2. open_questions[1], rows stored before FieldSchema.deleteBehavior defaults set_null onto EVERY field type, so built artifacts ship the key on text/datetime/number fields #9784 (the dev recommends A: accept). Answer: A for set_null on a non-required user field, because clearing a deleted user's id is the only effect and it is what the same declaration does on a lookup. For a required user field that carries the era's materialized set_null, the user delete becomes a 409 nobody authored. That is a consumer judgment with no mechanical answer, which is what the ledger's semantic kind records. Escalated to domain:spec as part of finding A. The changeset states the population; it cannot be measured from this container, and I did not re-measure.
  3. Dev flag, forced edit of the reader ledger. Answer: right (① item 7).
  4. Dev flag, main moved 25 commits after the one merge. The head's checks ran on the PR merge ref and are green; no re-judgment owed here.
  5. Dev flag, the harness asked for an assignee and a review request via MCP and the dev declined. Procedural; the seat's.
  6. Dev out_of_scope finding, stale spec prose. Confirmed, and wider than the two sites named. On main: the field.zod.ts .overwrite comment ("user ... sits outside the delete walk's family ... exactly like text"); the user reference refusal message ("a user field never did"); the D3 entry user-field-foreign-reference-refused ("which a user field never did"); hook.zod.ts referentialFieldClear / referentialCascadeDelete describes (name lookup and tree only); contracts/objectql-engine.ts getCascadeDeleteObjects docblock and api/protocol.zod.ts cascadeDeleteObjects describe (enumerate master_detail / lookup / tree). Describe strings are the published spec. Finding B in ④, should-fix, one domain:spec card together with A.
  7. Also read: field.form.ts and object.form.ts show deleteBehavior only when data.type == 'lookup' or 'master_detail'. Studio cannot author the key on a user field, and cannot clear a pre-FieldSchema.deleteBehavior defaults set_null onto EVERY field type, so built artifacts ship the key on text/datetime/number fields #9784 stored value through the form either. This supports the dev's blast-radius claim (the authored population is code, JSON and YAML authors plus era artifacts) and belongs to the domain:spec card: whether the form shows the key on user now that it is honoured.
  8. Skills-docs follow-up: skills(objectstack-data): a user field's declared deleteBehavior now takes effect when the user is deleted (#29253) — the field-type and relationship rules should say so #29400 exists, is open, and names the two skill files. Noted.
  9. PR user.login is objectstack-fleet[bot]; no out-of-relay author note is owed.
  10. Check-runs: the one red Check Changeset run is superseded by two later green runs on the same head (preamble). No gate on the head is red.

④ Design judgment

The narrowing against the published spec on main. The spec accepts and round-trips deleteBehavior on a user field, describes the key as "What happens if referenced record is deleted", declares no default for user (the .overwrite tail materializes on lookup and tree only), and its own D3 entry says "deleteBehavior has no value that means 'leave it' ... a delete-behaviour judgement is owed by the author". The diff makes an authored value do what the key says, and keeps absence as "leave it". That is the spec's own declaration made real. The one capability it takes away at the consumer is deleting a user that an authored restrict (or a required set_null) holds, which is what the author declared. Verdict: the narrowing holds. Each case the brief names, read in the tree:

  • A user delete through a door that skips the walk. None found. better-auth deletes by id through the adapter into engine.delete; the generic data doors refuse a caller-context sys_user delete in identity-write-guard; a system-context generic delete (a flow's elevated write, a seed) still goes through engine.delete, by id or by predicate, and both arms run the walk (objectql: a predicate delete (multi: true with a filter) skips the referential walk, so a flow's bulk delete_record bypasses restrict, master_detail cascade and set_null #26871). SCIM DELETE /Users/{id} is a deactivation update on 1.7.2 (auth-manager.ts docblock). No raw driver.delete of sys_user outside the engine. Residual, pre-existing and outside this card: the guard's isUserContextWrite needs a userId, so a sessionless caller context passes it; such a delete still reaches the walk, so the narrowing holds for it too.
  • Cascade loops. A user field on sys_user itself would be a self-reference; collectCascadeDeleteSet does not expand a record already in the set and the recursion is bounded by MAX_CASCADE_DEPTH, the same terms a tree cycle has. On main sys_user.manager_id is a lookup (already in the walk) and no object declares a user field with a deleteBehavior. Holds.
  • A multi-valued user field. user is in MULTI_CAPABLE_TYPES, so declaredMultiValued is true: the probe uses the $contains limb and the exact storedReferenceIncludes narrowing, set_null removes the member and writes [] for an emptied set, and the required escalation is decided per row. Pinned for restrict, cascade and set_null. Holds.
  • set_null on a required field. Single-valued escalates to restrict before the probe (409, pinned); multi-valued escalates per row after it (a row that keeps another member takes the removal). The same rule a required lookup has. Holds.
  • A user field with no declared behaviour. Stays out; four controls pinned (parsed single, parsed multi, raw, required); ablation 3 shows the arm is live. Holds.
  • A field naming no reference. deleteWalkTargetOf answers sys_user through referenceTargetOf; pinned (uf_bare_ref). A raw user registration naming a foreign reference (refused at parse) follows sys_user, which is referenceTargetOf's contract on main. Holds.
  • restrict surfacing through the auth door with a correct rollback. The walk throws DELETE_RESTRICTED inside the by-id transaction, which joins the ambient transaction runSubjectErasureAtomically opened (ADR-0067 D2 handle). withValidationErrorMapping maps it to APIError('CONFLICT') with code DELETE_RESTRICTED, developerMessage, dependentObject and dependentCount (the admin-lifecycle: remove-user is non-atomic and can never succeed — 409 leaks as a bodyless HTTP 500, credential rows deleted without rollback #7724 arm, type-agnostic). better-auth returns the 409; a response status of 400 or above throws SubjectErasureRollback, and the session and account rows deleted before the user row roll back. Holds on a driver with beginTransaction; a driver without one has the declared ADR-0119 D1 degrade the auth-manager docblock already states. The engine half is pinned on this PR (committed has length 0); the adapter half is read, not re-pinned here, and is already pinned by the admin-lifecycle: remove-user is non-atomic and can never succeed — 409 leaks as a bodyless HTTP 500, credential rows deleted without rollback #7724 tests for a lookup.
  • Tenancy and cross-org rows. sys_user is platform-global; holders are organization-scoped. The probe runs as { ...context, isSystem: true }; the adapter's context carries no tenantId, so buildDriverOptions forwards none and the probe spans every organization. A restrict holder in any organization refuses the delete; cascade deletes holders in every organization; set_null clears them everywhere. That is the right reading for a global row: a holder in another organization would otherwise dangle. dependentCount is disclosed because the adapter's context is system, so the auth caller sees a count across organizations; the admin plugin is platform-level, and the count discloses nothing the envelope did not already carry for a lookup to sys_user. Holds.

Findings:

  • A, should-fix. ADR-0087 disposition. The right disposition is registered with a SemanticMigration entry (surface: object.fields[].deleteBehavior on type: 'user'; replacement: declare the value you want enforced or drop the key; reason: an accepted declaration is now honoured, and pre-FieldSchema.deleteBehavior defaults set_null onto EVERY field type, so built artifacts ship the key on text/datetime/number fields #9784 stored rows may carry a materialized set_null; acceptance: every user field's deleteBehavior is authored on purpose, and a required one does not carry set_null). The entry belongs to domain:spec (the card's file surface forbade packages/spec/**), so this is a follow-up card, not a change to this PR. The marker's "there is no stored value" clause and the "Rows stored before FieldSchema.deleteBehavior defaults set_null onto EVERY field type, so built artifacts ship the key on text/datetime/number fields #9784" paragraph should be reconciled when the entry lands. Not a must-fix: the marker is mechanically valid, and the banner plus the fix line carry the signal during the launch window.
  • B, should-fix. Published spec prose that enumerates the walk's family or says a user field never joins it (③ flag 6 lists the six sites). One domain:spec card with A. Not a must-fix: describe strings, no accept-set or runtime effect.
  • Note, not graded. DECLARED_DELETE_BEHAVIORS is a second spelling of the spec's z.enum(['set_null', 'cascade', 'restrict']); the spec exports no constant for it, so there is nothing to import today. A raw user registration with an off-enum value is silently out of the walk, where the same value on a lookup falls to the set_null branch. Both are around-the-parse shapes; the user reading is the more conservative one and matches the file's "no default is invented here" rule.

No must-fix. The tree matches the PR body's account on every point checked; nothing was judged from the account.

Implemented-by: claude/project-thread-u66mdq
Reviewed-by: session_01ADHkmQKwKFCp1m6Pb6Q8c1

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

维护者速读

  • 改了什么:user 字段上写明的 deleteBehavior 现在在删除用户时生效。restrict 拒绝删除(409),cascade 连带删除,set_null 清空(多值字段只移除该成员)。没写 deleteBehavior 的 user 字段不变。
  • 为什么改:spec 接受并保留这个声明,但引擎的删除联动不认 user 字段,声明一直是摆设。
  • 风险与回滚:收紧(BREAKING,objectql minor,Metadata-protocol semantic)。写了 restrict 或 required+set_null 的应用,删除被引用用户从 200 变 409。仓内无此类字段。回滚即 revert。
  • 先看:packages/objectql/src/engine.ts 的 statesDeleteSemantics / deleteWalkTargetOf。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/engine-cascade-delete-user-field.test.ts,16 条全绿。
  • 需要你做的:无,席位落地。

席位意见:契约复审 PASS(本 PR 上的记录,head 4a0ed134)。两条 should-fix 都要改 packages/spec/**,已立 spec 卡 #29415,不阻塞本 PR。

席位核对

ACCEPT · domain:server seat 5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 · 2026-11-02T14:23Z · head 4a0ed134f4 · Fixes #29253

  1. Shape: draft against main, Fixes #29253. A minor changeset for @objectstack/objectql with a BREAKING banner. Clause-②: no (narrowing), Metadata-protocol: semantic, and a Skills-docs: line pointing at skills(objectstack-data): a user field's declared deleteBehavior now takes effect when the user is deleted (#29253) — the field-type and relationship rules should say so #29400. Not governed: 478 changed lines, 5 files.
  2. Against the order: a declared deleteBehavior on a user field joins the delete walk. Bare user fields stay out. Read in the diff and confirmed by the at-tier contract review (PASS).
  3. Accepted deviations: the seat ruled the Clause-② arm (narrowing) in place of the dev's no. The changeset and body follow it.
  4. Gates: check-runs on this head: Lint & Repo Gates, TypeScript Type Check and Check Changeset (the last two runs) are green. An early Check Changeset run failed before the body edit; the later runs on the same head passed. Nothing is red or running.
  5. Out of scope: the ADR-0087 semantic entry and the spec prose → spec(migrations): register the user-field deleteBehavior enforcement (#29253) as an ADR-0087 semantic entry and stop the spec prose saying a user field never joins the delete walk #29415 (spec). Skills → skills(objectstack-data): a user field's declared deleteBehavior now takes effect when the user is deleted (#29253) — the field-type and relationship rules should say so #29400.
  6. Landing: pr_ready + automerge_enable through the relay. objectql: a plain lookup write accepts a target the writer cannot read — the reference check probes it elevated, and only master_detail asks the caller's read door #29029 opens its PR after this merges.

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 size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

objectql: deleteBehavior on a user field parses but the delete walk never reads it, so restrict / cascade on a user field are not enforced

1 participant