Skip to content

Commit 63f962f

Browse files
docs(spec): register the user-field deleteBehavior enforcement as an ADR-0087 semantic entry and correct the delete-walk prose, protocol describe included (#29415) (#29712)
domain:spec#2 · os-support-ai · session_01WGufzhJG3tKnksHVa1krBz <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBTc1Gxk14k2PbeArtFLuL8a)_ Fixes #29415 Supersedes #29553 (same commits, resumed on a new branch; #29553 is closed) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:在 #29553 的三个提交之上(从 `59709cbb` 续做,两次合入 main),补上冻结期间欠下的两处文字:对象元数据读取的 `cascadeDeleteObjects` 说明现在把「声明了 `deleteBehavior: 'cascade'` 的 user 字段」列入会级联删除的关系;`FieldSchema` 的 deleteBehavior 注释把容易误读的「AUTHORS one」改成「declares a `deleteBehavior`」。其余内容(语义迁移条目、hook/contract 文字、两个表单的可见性规则)与 #29553 相同。 - 为什么改:#29368 让 user 字段上声明的 deleteBehavior 在删除用户时生效,spec 文字还说 user 字段不参与删除联动;protocol.ts 冻结已解除(#28677 评论 6588593530),上一席的 Release 6591556737 点名这两处为落地前的欠项。 - 风险与回滚:只改文字和重新生成的参考文档,运行时与作者可写的键都不变。第二次合入 main 时 field.form.ts 与 #29639 新增的 `referenceNameVisible` 行相邻冲突,已手工合并、两边都保留。#29253 的未发布 changeset 仍是有意更正,`check-empty-changeset` 按设计保持红灯(与 #29553 相同)。回滚即 revert。 - 先看:packages/spec/src/data/field.form.ts(合并冲突的解决处:`referenceNameVisible` 行与其下的 `deleteBehavior` 行);packages/spec/src/api/protocol.zod.ts(`GetMetaItemResponseSchema.cascadeDeleteObjects`);.changeset/29415-user-delete-behavior-entry.md(新增的同版更正段)。 - 两分钟验证:`grep -c "or .user. field that declares" content/docs/references/api/protocol.mdx`,预期输出 1(`cascadeDeleteObjects` 那一行)。 - 需要你做的:在本 PR 上确认对 #29253 未发布 changeset 的这次更正(check-empty-changeset 的人工确认路径,与 #29553 相同)。 ## What changes This PR carries the three commits of #29553 (`44bec0e9`, `21cd9672`, `59709cbb`) unchanged, merges `origin/main` twice (`744211fc`, then `1c1a5d40`), and adds the delta that the `protocol.ts` freeze held back. #29553's body describes the inherited part: the semantic entry `user-field-delete-behavior-enforced`, the correction of the unreleased #29253 changeset, the prose in `field.zod.ts`, `hook.zod.ts`, `objectql-engine.ts` and the `user-field-foreign-reference-refused` entry, and the `deleteBehavior` visibility rule in both forms. The delta: - **`packages/spec/src/api/protocol.zod.ts`, `GetMetaItemResponseSchema.cascadeDeleteObjects`.** The describe listed the cascading relations as "a `master_detail` by default, a `lookup` or `tree` that declares `deleteBehavior: 'cascade'`". It now reads "a `master_detail` by default, a `lookup`, `tree` or `user` field that declares `deleteBehavior: 'cascade'`", the wording of the `getCascadeDeleteObjects` contract docblock. The engine side already holds: `getCascadeDeleteObjects` walks `cascadeRelationBehavior`, whose family test is `statesDeleteSemantics`, which admits a `user` field that declares a `deleteBehavior` (`packages/objectql/src/engine.ts`, on `744211fc`). - **The other three sites the order named, measured and left as they are.** `GetMetaItemCachedResponseSchema.cascadeDeleteObjects` points at "the item read's `cascadeDeleteObjects`" and lists no relation kinds; the two `[#28001]` docblocks (above `GetMetaItemResponseSchema` and `GetMetaItemCachedResponseSchema`) speak of "the rule its delete walk runs" and list none either. None of the three says or implies that a `user` field stays out of the walk, so none is edited. - **`packages/spec/src/data/field.zod.ts`, the `deleteBehavior` docblock (#9784 paragraph).** "and on a `user` field that AUTHORS one" read as a `user` field authoring a `reference`, which the same schema refuses. It now reads "and on a `user` field that declares a `deleteBehavior`"; the rest of the parenthetical is re-wrapped, unchanged. - **Regenerated:** `content/docs/references/api/protocol.mdx` (1 row) and `content/docs/references/api/app-bundle.mdx` (5 rows), both by `gen:docs`. No other artifact moved (`check:generated`: all 14 up to date). - **Changeset** `.changeset/29415-user-delete-behavior-entry.md` stays a `@objectstack/spec` patch. Its spec-text bullet now names the `cascadeDeleteObjects` description on the metadata `object` read beside the other corrected texts. - **DELIBERATE CORRECTION of the unreleased #28001 note** (`.changeset/28001-cascade-delete-objects-beside-item.md`, patch round 1 at `abde58e1`, the contract review's should-fix 1): its one sentence listing the cascading relations now reads "a `lookup`, `tree` or `user` field that declares `deleteBehavior: 'cascade'`", word for word the corrected describe, so the note is true on the day it ships. `check-empty-changeset` reds on this file and on `.changeset/29253-user-field-delete-behavior.md`, both corrections by design. - **Maintainer authorization of the changeset corrections**, verbatim in the card thread, 2026-11-03T02:56Z: 「授权你修改」. ## Why Release 6591556737 on #29415 names exactly this delta as owed before landing; the freeze that held `protocol.zod.ts` was lifted at 6588593530 on #28677. Triage's at-tier record 6588315424 (PASS on `59709cbb`) covers the inherited commits; contract-review-hold 6588339023 covers the old head until this edit lands. ## Tests No test added: the delta is describe and comment text, and no reader parses it. Text only, so no accept set moves: `Clause-②: no`, `Metadata-protocol: none`. <details><summary><code>pnpm --filter @objectstack/spec test</code> and <code>typecheck</code>, head <code>72a8bffd</code></summary> - `pnpm --filter @objectstack/spec test`: Test Files 803 passed (803), Tests 23790 passed and 1 todo. - `pnpm --filter @objectstack/spec typecheck`: `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck` exit 0 (52 files / 240 errors / 131 pinned signatures held in the debt ledger, unchanged). - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-predicate-path-refs.test.ts`: 62 passed (62), with main's predicate count 91 and this branch's literal-comparison count 63 side by side after the merge. - All three in one locked run: `VERDICT command-exit 0`. - The same spec suite on the pre-merge head `89540569` (before the second `origin/main` merge): Test Files 802 passed (802), Tests 23736 passed and 1 todo, typecheck exit 0. </details> ## Verification <details><summary>Build, generated artifacts and derived gates, head <code>72a8bffd</code></summary> - Two merges of `origin/main`: `83189767` (onto `744211fc`, clean) and `72a8bffd` (onto `1c1a5d40`). The second hit one conflict in `packages/spec/src/data/field.form.ts`: #29639 put its `referenceNameVisible` row directly above the `deleteBehavior` row this branch respells. Resolved by hand, keeping both: main's comment and row, and this branch's `data.type in ['lookup','user']` gate on `deleteBehavior`. `scripts/pm/os-regen-merge.sh` ran after each merge. For `app-bundle.mdx`, `protocol.mdx` and `hook.mdx` it kept the branch's bytes, because main did not touch them. - Full build after the second merge (`turbo run build --filter=!@objectstack/docs --concurrency=2`, under the verify lock): 117/117 tasks, `VERDICT command-exit 0`. Then `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands`, 105 commands on `72a8bffd`), run one after another with `CMD :: exit N` recorded: 104 exit 0, and 1 exits 1 by design, `check-empty-changeset --base origin/main` on `.changeset/29253-user-field-delete-behavior.md` (see Acceptance notes). `dispatch-gates --ran`: 105 derived, 105 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. - No gate is left NOT MEASURED. The built-output gates (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:i18n`, `check:docs-transcript-drift`, `spec check:skill-examples`, `lint check:doc-formula-expressions`, `lint check:doc-security-posture`) ran on a full build and exit 0. `check:dual-build-cjs-loads` was NOT MEASURED on #29553. - Local-only state gates: `pnpm check:dev-prereqs` and `pnpm check:skill-frame-freshness` exit 0. </details> ## Acceptance notes - `check-empty-changeset` stays red by design, as on #29553: this PR rewrites the pending #29253 release note it did not add (the DELIBERATE CORRECTION class). The remedy is a confirmation on the PR, not a restore. - The `#7010 corpus` census pin in `packages/lint/src/validate-predicate-path-refs.test.ts` (63) is inherited from `59709cbb`. `origin/main` moved no `visibleWhen` or `data.type` predicate under `packages/spec/src` between the fork point `399df5bb` and `744211fc` (`git diff -G` over that range: zero files), so the count still holds. The test file passes on the final head (62/62). The second merge brought #29639's `referenceNameVisible` row, which is an in-list too: it moves main's predicate count to 91 and leaves the literal-comparison count at 63. - The inherited acceptance notes of #29553 stand (the `tree` control, the form's missing empty option, #29400). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran. `pnpm lint` was not run locally: it is CI's run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzgmDWPyn8w47uKA3CW)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3eeb81d commit 63f962f

16 files changed

Lines changed: 150 additions & 43 deletions

‎.changeset/28001-cascade-delete-objects-beside-item.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ Clause-②: yes (widening)
1111
Metadata-protocol: added
1212
Skills-docs: none — `cascadeDeleteObjects` is a served-only key beside the object read with no authorable spelling; no file under `skills/**` teaches the object read's envelope keys (a `git grep` of `skills/` for `cascadeDeleteObjects` and `disabledActions` names nothing), and every taught `deleteBehavior` use stays true.
1313

14-
- **Served beside the object** (`@objectstack/metadata-protocol`, `@objectstack/rest`). `GET /api/v1/meta/object/:name` serves `cascadeDeleteObjects` beside `item`: the names of the objects that deleting a record of this object can also delete. That is every object whose relation into it cascades (a `master_detail` by default, a `lookup` or `tree` that declares `deleteBehavior: 'cascade'`), and the objects that cascade into those in turn, each once. A `restrict` or `set_null` relation removes nothing and is not named. The default cached door carries it too, and the list is folded into that door's ETag, so an object registered later that cascades into this one changes the ETag. The key is absent where no engine answered, on a draft read, on the list read and on every type other than `object`; `[]` means nothing cascades. `item` is unchanged. A delete confirmation can now name what else a delete removes without keeping its own copy of the rule.
14+
- **Served beside the object** (`@objectstack/metadata-protocol`, `@objectstack/rest`). `GET /api/v1/meta/object/:name` serves `cascadeDeleteObjects` beside `item`: the names of the objects that deleting a record of this object can also delete. That is every object whose relation into it cascades (a `master_detail` by default, a `lookup`, `tree` or `user` field that declares `deleteBehavior: 'cascade'`), and the objects that cascade into those in turn, each once. A `restrict` or `set_null` relation removes nothing and is not named. The default cached door carries it too, and the list is folded into that door's ETag, so an object registered later that cascades into this one changes the ETag. The key is absent where no engine answered, on a draft read, on the list read and on every type other than `object`; `[]` means nothing cascades. `item` is unchanged. A delete confirmation can now name what else a delete removes without keeping its own copy of the rule.
1515
- **The fact it serves** (`@objectstack/objectql`). `ObjectQL.getCascadeDeleteObjects(object)` answers that list from the delete walk's own relation scan, which the delete and this read now share. It reads the registry and no row, so it never says which records or how many. It returns `undefined` for an object the registry does not hold.
1616
- **The declaration** (`@objectstack/spec`). `GetMetaItemResponseSchema` and `GetMetaItemCachedResponseSchema` gain the optional `cascadeDeleteObjects` (an array of object names), and `IObjectQLEngine` gains the optional member `getCascadeDeleteObjects(objectName)`, which the metadata read calls through the kernel's service table. Nothing about it is authorable.

‎.changeset/29253-user-field-delete-behavior.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ fix(objectql)!: a `user` field's declared `deleteBehavior` takes effect when the
77
Clause-②: no (narrowing) — deleting a user that a `user` field declaring `restrict` (or `set_null` with `required: true`) holds is refused with `409 DELETE_RESTRICTED`, where it succeeded before; a declared `cascade` now deletes the rows holding the user, and a declared `set_null` now clears them. Nothing is widened.
88
Metadata-protocol: semantic
99

10-
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: a `deleteBehavior` the spec already accepts on a `user` field is now enforced by the delete walk the way it is on a `lookup`. The refusal is the walk's own DELETE_RESTRICTED, so there is no stored value for a conversion to rewrite; an author who did not mean the declaration removes it or changes it. -->
10+
<!-- adr-0087: registered user-field-delete-behavior-enforced -->
1111

1212
**BREAKING** for an app that declares `deleteBehavior` on a `user` field: the declared value now takes effect when the user is deleted.
1313

@@ -17,6 +17,6 @@ A `user` field accepts `deleteBehavior`, but the referential delete walk only re
1717
- **Unchanged.** A `user` field that declares no `deleteBehavior` (the spec's parse adds none) stays out of the walk: deleting the user leaves the field as it was. `lookup`, `master_detail` and `tree` behave as before.
1818
- **Which deletes.** Every user delete: better-auth deletes the user (admin remove-user, self delete-user) through the engine by id, and the generic data routes refuse a caller's own `sys_user` delete. A `restrict` refusal reaches the auth caller as `409 DELETE_RESTRICTED`, and the credential rows deleted before it are rolled back.
1919

20-
- **Rows stored before #9784.** Until then the spec's parse 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 that no author wrote. That value now takes effect: the field is cleared when the user is deleted, and on a `required` field the delete is refused.
20+
- **Rows stored before #9784.** Until then the spec's parse 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 that no author wrote. That value now takes effect: the field is cleared when the user is deleted, and on a `required` field the delete is refused. No conversion rewrites it, because whether the value was meant is the author's to say: the semantic migration entry `user-field-delete-behavior-enforced` lists it for `objectstack migrate meta` and the upgrade guide. Save the object again without the key, or with the value you want.
2121

2222
**The fix.** If you declared `deleteBehavior` on a `user` field only as documentation, remove it or change it to what you want enforced. Nothing to do for a `user` field that declares no `deleteBehavior`.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): register the `user`-field `deleteBehavior` enforcement as a semantic migration entry, and stop the spec saying a `user` field never joins the delete walk (#29415)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The enforcement itself shipped in `@objectstack/objectql` (#29253): a `deleteBehavior` declared on a `user` field now takes effect when the user is deleted. This change records it and corrects the spec text around it. Nothing an author can write, and nothing the runtime does, moves.
11+
12+
- **Migration entry.** A new semantic entry, `user-field-delete-behavior-enforced`, so `objectstack migrate meta` and the protocol upgrade guide reach the change. It names the one stored population that needs a look: objects saved while the parse still wrote `deleteBehavior: 'set_null'` on every field type can carry a `set_null` on a `user` field nobody wrote. On a field that is not `required` it clears the field when the user is deleted and is fine as it is; on a `required` field it now refuses the user delete with `409 DELETE_RESTRICTED`, so remove it or declare the outcome you want.
13+
- **Spec text.** The `deleteBehavior` docblocks in `FieldSchema`, the `user`-field `reference` refusal message, the `user-field-foreign-reference-refused` entry, the `referentialFieldClear` and `referentialCascadeDelete` hook-context descriptions, the `getCascadeDeleteObjects` contract docblock and the `cascadeDeleteObjects` description on the metadata `object` read now say that a `user` field joins the delete walk when it declares a `deleteBehavior`, and stays out when it declares none.
14+
- **Studio forms.** The field designer and the object form's field rows now show the `deleteBehavior` control on a `user` field, with all three outcomes, as they do on a `lookup`. A `user` field has no default, so the control writes a value only when you pick one.

0 commit comments

Comments
 (0)