diff --git a/.changeset/28001-cascade-delete-objects-beside-item.md b/.changeset/28001-cascade-delete-objects-beside-item.md index c697ff4980b..d2645ff2ec0 100644 --- a/.changeset/28001-cascade-delete-objects-beside-item.md +++ b/.changeset/28001-cascade-delete-objects-beside-item.md @@ -11,6 +11,6 @@ Clause-②: yes (widening) Metadata-protocol: added 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. -- **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. +- **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. - **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. - **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. diff --git a/.changeset/29253-user-field-delete-behavior.md b/.changeset/29253-user-field-delete-behavior.md index 836ce77f9ef..2a84361c20c 100644 --- a/.changeset/29253-user-field-delete-behavior.md +++ b/.changeset/29253-user-field-delete-behavior.md @@ -7,7 +7,7 @@ fix(objectql)!: a `user` field's declared `deleteBehavior` takes effect when the 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. Metadata-protocol: semantic - + **BREAKING** for an app that declares `deleteBehavior` on a `user` field: the declared value now takes effect when the user is deleted. @@ -17,6 +17,6 @@ A `user` field accepts `deleteBehavior`, but the referential delete walk only re - **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. - **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. -- **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. +- **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. **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`. diff --git a/.changeset/29415-user-delete-behavior-entry.md b/.changeset/29415-user-delete-behavior-entry.md new file mode 100644 index 00000000000..9480554512d --- /dev/null +++ b/.changeset/29415-user-delete-behavior-entry.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': patch +--- + +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) + +Clause-②: no +Metadata-protocol: none + +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. + +- **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. +- **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. +- **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. diff --git a/content/docs/references/api/app-bundle.mdx b/content/docs/references/api/app-bundle.mdx index 5ac9046f4df..c1e5c69008b 100644 --- a/content/docs/references/api/app-bundle.mdx +++ b/content/docs/references/api/app-bundle.mdx @@ -99,7 +99,7 @@ const result = AppMetadataBundleResponseSchema.parse(data); | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | | **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged Actions): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | -| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: 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, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: 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, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `AppMetadataBundleResponse.objects[number]` @@ -123,7 +123,7 @@ const result = AppMetadataBundleResponseSchema.parse(data); | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | | **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged Actions): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | -| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: 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, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: 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, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `AppMetadataBundleResponse.views[number]` @@ -147,7 +147,7 @@ const result = AppMetadataBundleResponseSchema.parse(data); | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | | **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged Actions): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | -| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: 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, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: 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, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `AppMetadataBundleResponse.pages[number]` @@ -171,7 +171,7 @@ const result = AppMetadataBundleResponseSchema.parse(data); | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | | **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged Actions): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | -| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: 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, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: 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, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `AppMetadataBundleResponse.dashboards[number]` @@ -195,7 +195,7 @@ const result = AppMetadataBundleResponseSchema.parse(data); | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | | **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged Actions): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | -| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: 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, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: 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, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 6dc761fcc64..b56cd972d5a 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1463,7 +1463,7 @@ This schema accepts one of the following structures: | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | | **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged Actions): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | -| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: 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, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: 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, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `GetMetaItemResponse.sortability` diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index d3262f8f1fb..e31c216d763 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -106,7 +106,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **dispatch** | `{ mode: Enum<'record' \| 'per-row'>; index: integer; scope: Record }` | optional | How this hook call relates to the caller's write (engine-produced) | | **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context | | **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) | -| **referentialFieldClear** | `boolean` | optional | Engine-produced marker: true exactly when this write is the engine's own referential cleanup (the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member from — a lookup that references a record being deleted). Absent on every other write, including a user hand-clearing the same lookup; read as `ctx.referentialFieldClear === true`. The declared projection of the operation-private `__referentialFieldClear`, carried across the sandbox boundary by contract; server-derived and never client-supplied. | +| **referentialFieldClear** | `boolean` | optional | Engine-produced marker: true exactly when this write is the engine's own referential cleanup (the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member from — a lookup, tree or user field that references a record being deleted, where a user field takes part only when it declares its deleteBehavior). Absent on every other write, including a user hand-clearing the same field; read as `ctx.referentialFieldClear === true`. The declared projection of the operation-private `__referentialFieldClear`, carried across the sandbox boundary by contract; server-derived and never client-supplied. | | **referentialCascadeDelete** | `boolean` | optional | Engine-produced marker: true exactly when this delete is one the engine's cascade issued (the child delete removing a row that references a record being deleted through a relation whose deleteBehavior resolves to cascade, at any depth), in both phases of that delete. Absent on every other delete, including a user hand-deleting the same child and a delete a hook issues through ctx.api; read as `ctx.referentialCascadeDelete === true`. The delete twin of referentialFieldClear, carried across the sandbox boundary by contract; server-derived, never client-supplied, and with no operation-private spelling to read instead. | | **transaction** | `any` | optional | Database transaction handle | | **ql** | `any` | ✅ | ObjectQL Engine Reference | diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 219a9391737..07fdec57e8d 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -820,7 +820,11 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // `data.type != 'joined'`, a single-quoted literal comparison. // It is 65 today: #28255's `field :: percentDisplay` row is gated on // `data.type == 'percent'`, a single-quoted literal comparison. - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(65); + // It is 63 today: #29415 opened both `deleteBehavior` gates (field form and + // object form) to `user` and respelled them `data.type in ['lookup','user']`, + // an `in`-list outside this rule, so two `==` literal comparisons left the + // census. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(63); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 744b13f7428..35c31719c72 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -766,8 +766,8 @@ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ ), cascadeDeleteObjects: z.array(z.string()).optional().describe( 'The objects, by name, that deleting a record of this object can also delete: 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, in the ' + + '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, in the ' + 'order the delete reaches them. This object itself is named only when a cascading relation leads back ' + 'to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the ' + 'delete runs, and object-level: it never says which records or how many. Present only on an `object` ' diff --git a/packages/spec/src/contracts/objectql-engine.ts b/packages/spec/src/contracts/objectql-engine.ts index 713d41ed007..bc93b121ee4 100644 --- a/packages/spec/src/contracts/objectql-engine.ts +++ b/packages/spec/src/contracts/objectql-engine.ts @@ -414,10 +414,10 @@ export interface IObjectQLEngine extends IDataEngine, QueryEvaluationGate { /** * The objects a delete of a record of `objectName` can cascade into, by * name: every object holding a relation into it whose resolved delete - * behaviour is `cascade` (a `master_detail` by default, a `lookup` or - * `tree` that declares `deleteBehavior: 'cascade'`), and transitively the - * objects that cascade into those, each once, in the order the delete's - * own walk first reaches them. `objectName` itself is named only when a + * behaviour is `cascade` (a `master_detail` by default, a `lookup`, `tree` + * or `user` field that declares `deleteBehavior: 'cascade'`), and + * transitively the objects that cascade into those, each once, in the + * order the delete's own walk first reaches them. `objectName` itself is named only when a * cascading relation leads back to it. A `restrict` or `set_null` * relation removes nothing and is not named. `undefined` when the engine * does not hold `objectName`. diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 2ad14f23ed6..70d57b429c8 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -218,6 +218,14 @@ export const fieldForm = defineForm({ // their translation exactly as they are today. The two branches are // mutually exclusive, so no author ever sees both spellings at once. // + // `user` shares the `lookup` branch (#29415): since #29253 a declared + // `deleteBehavior` on a `user` field takes effect when the user is + // deleted, with all three outcomes legal, so the designer offers the + // key it honours. A `user` field has NO default — declaring nothing + // keeps it out of the delete walk — so the control writes a value only + // when the author picks one; the enum's advertised `default` is a + // `lookup` fact the select does not prefill. + // // `helpText` is identical on both — they share one i18n key // (`metadataForms.field.fields.deleteBehavior`), so divergent text would // collide silently. @@ -232,7 +240,7 @@ export const fieldForm = defineForm({ // target is always `sys_user`), which is why this row is not grouped // under that one's visibility. { field: 'referenceNameVisible', label: 'Show Name When Unreadable', visibleWhen: "data.type in ['lookup','user']", helpText: "Lets a reader of this record see the referenced record's name, as plain text with no link, even when they may not open that record. Nothing else of it is shown, and a field-level rule on the name still holds (the name then reads Record # and the id). Unset: such a reference shows as a restricted record." }, - { field: 'deleteBehavior', visibleWhen: "data.type == 'lookup'", helpText: 'What happens when referenced record is deleted' }, + { field: 'deleteBehavior', visibleWhen: "data.type in ['lookup','user']", helpText: 'What happens when referenced record is deleted' }, { field: 'deleteBehavior', type: 'select', visibleWhen: "data.type == 'master_detail'", helpText: 'What happens when referenced record is deleted', options: [ { label: 'Cascade (delete children)', value: 'cascade' }, { label: 'Restrict (block the delete)', value: 'restrict' }, diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index ccb34347a02..46988f81d3c 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1831,9 +1831,12 @@ export const FieldSchema = lazySchema(() => { * it per the #9689 idempotent-materialization ruling). On every other type * the key was inert by construction — the engine's referential delete walk * reads it only on a `master_detail`, `lookup` or `tree` field that carries a - * `reference` (the family predicate `statesDeleteSemantics` in + * `reference`, and on a `user` field that declares a `deleteBehavior` (the + * family predicate `statesDeleteSemantics` in * `packages/objectql/src/engine.ts`; a `tree`'s `reference` is its own - * object, written by the object's parse when the author leaves it out) — yet + * object, written by the object's parse when the author leaves it out; a + * `user` field's target is `sys_user`, fixed by the type, and #29253 made its + * authored value take effect) — yet * the materialized value shipped in every built artifact as an apparent * explicit declaration (the #4447 shadowing mechanism). A bare `text` / * `datetime` / `number` field now parses to output WITHOUT the key; an @@ -2746,8 +2749,10 @@ export const FieldSchema = lazySchema(() => { // No ADR-0087 conversion carries it (the D3 entry // `user-field-foreign-reference-refused` says why): rewriting the field to a // `lookup` would add it to the referential delete walk with - // `deleteBehavior: 'set_null'`, which a `user` field never joined, and - // `deleteBehavior` has no value meaning "leave it", so the author decides. + // `deleteBehavior: 'set_null'`, while a `user` field that declares no + // `deleteBehavior` stays out of that walk (#29253: one that declares a value + // joins it), and `deleteBehavior` has no value meaning "leave it", so the + // author decides. if ( field.type === 'user' && field.reference !== undefined @@ -2762,7 +2767,8 @@ export const FieldSchema = lazySchema(() => { `\`reference: '${field.reference}'\`. To link records of \`${field.reference}\`, declare a ` + `\`lookup\`: \`{ type: 'lookup', reference: '${field.reference}' }\` (\`Field.lookup('${field.reference}', …)\`), ` + "and choose its `deleteBehavior` (a `lookup` defaults to `'set_null'` when the target record is " + - 'deleted; a `user` field never did). If a person was meant, drop `reference`.', + 'deleted; a `user` field has no default and keeps the id unless it declares one). If a person was ' + + 'meant, drop `reference`.', }); } @@ -3310,9 +3316,12 @@ export const FieldSchema = lazySchema(() => { // stay legal. `tree` (hierarchical reference) keeps materializing with // `lookup`: it is in the relational family, where the key states delete // semantics, and the delete walk reads it there (#22957). `user` is - // stored identically to `lookup` but sits outside the delete walk's family - // (`master_detail`, `lookup`, `tree`) exactly like `text` does, so it takes - // the non-reference side; an authored value there still round-trips. + // stored identically to `lookup` but takes the non-reference side: it + // joins the delete walk only when its author DECLARES a `deleteBehavior` + // (#29253, `statesDeleteSemantics`), so a materialized value here would be + // a delete outcome nobody chose. A bare `user` field therefore parses with + // no key and keeps the deleted user's id; an authored value round-trips + // and takes effect. if (field.deleteBehavior === undefined && (field.type === 'lookup' || field.type === 'tree')) { patch.deleteBehavior = 'set_null'; } diff --git a/packages/spec/src/data/form-delete-behavior-options.test.ts b/packages/spec/src/data/form-delete-behavior-options.test.ts index c4a5039b58f..6f67d21a1d7 100644 --- a/packages/spec/src/data/form-delete-behavior-options.test.ts +++ b/packages/spec/src/data/form-delete-behavior-options.test.ts @@ -205,6 +205,22 @@ describe('`deleteBehavior` is never offered a value the schema refuses', () => { expect(visible.map((d) => d.path)).toHaveLength(1); }); + // #29415: since #29253 a declared `deleteBehavior` on a `user` field takes + // effect when the user is deleted, so the designer must offer it there — + // all three outcomes, from the same single control a `lookup` author gets. + it('offers exactly one control to a user author, with all three values', () => { + const visible = decls.filter((d) => isVisibleForType(d.spec, 'user')); + expect(visible.map((d) => d.path)).toHaveLength(1); + const offered = declaredOptionValues(visible[0].spec) ?? (derivedDeleteBehaviorNode().enum as string[]); + expect(offered).toEqual(['set_null', 'cascade', 'restrict']); + }); + + // The control stays confined to the types whose `deleteBehavior` states + // delete semantics a Studio author can set; a `text` field is the control. + it('offers no control to a text author', () => { + expect(decls.filter((d) => isVisibleForType(d.spec, 'text'))).toHaveLength(0); + }); + // THE DEFECT. A master_detail author must never see "Set null". it('does not offer set_null to a master_detail author', () => { const visible = decls.filter((d) => isVisibleForType(d.spec, 'master_detail')); diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 013a0a9113b..c8281f0e192 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -1401,11 +1401,12 @@ export const HookContextSchema = lazySchema(() => z.object({ /** * Referential-Cleanup Marker * `true` exactly when THIS write is the engine's own reference cleanup — the - * UPDATE `cascadeDeleteRelations` issues against a row that HOLDS a lookup - * whose `deleteBehavior` resolves to `set_null`, while the record it - * references is being deleted (clearing the slot, or on a `multiple: true` - * lookup removing the deleted member). Absent on every other dispatch; read - * it as `ctx.referentialFieldClear === true`. + * UPDATE `cascadeDeleteRelations` issues against a row that HOLDS a + * reference field whose `deleteBehavior` resolves to `set_null` (a `lookup` + * or `tree` by default, or a `user` field that declares it, #29253), while + * the record it references is being deleted (clearing the slot, or on a + * `multiple: true` field removing the deleted member). Absent on every other + * dispatch; read it as `ctx.referentialFieldClear === true`. * * ## Why a declared key (commit 34ce8e7db) * @@ -1458,8 +1459,9 @@ export const HookContextSchema = lazySchema(() => z.object({ referentialFieldClear: z.boolean().optional().describe( "Engine-produced marker: true exactly when this write is the engine's own referential cleanup " + "(the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member " - + 'from — a lookup that references a record being deleted). Absent on every other write, ' - + 'including a user hand-clearing the same lookup; read as `ctx.referentialFieldClear === true`. ' + + 'from — a lookup, tree or user field that references a record being deleted, where a user field ' + + 'takes part only when it declares its deleteBehavior). Absent on every other write, including a ' + + 'user hand-clearing the same field; read as `ctx.referentialFieldClear === true`. ' + 'The declared projection of the operation-private `__referentialFieldClear`, carried across ' + 'the sandbox boundary by contract; server-derived and never client-supplied.', ), @@ -1470,10 +1472,11 @@ export const HookContextSchema = lazySchema(() => z.object({ * child DELETE `cascadeDeleteRelations` performs for each row that * references a record being deleted through a relation whose * `deleteBehavior` resolves to `cascade` (a `master_detail` unless it - * declares `restrict`; a `lookup` or `tree` that declares `cascade`), at any - * depth: a grandchild the child's own cascade removes is marked too. Present - * in both phases of that delete (`beforeDelete`, `afterDelete`). Absent on - * every other dispatch; read it as `ctx.referentialCascadeDelete === true`. + * declares `restrict`; a `lookup`, `tree` or `user` field that declares + * `cascade`, the `user` one since #29253), at any depth: a grandchild the + * child's own cascade removes is marked too. Present in both phases of that + * delete (`beforeDelete`, `afterDelete`). Absent on every other dispatch; + * read it as `ctx.referentialCascadeDelete === true`. * * The delete twin of {@link referentialFieldClear}. That key marks the * `set_null` cleanup UPDATE; this one marks the `cascade` child DELETE. A diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index bb2682c6196..50d11c75bb3 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -341,7 +341,8 @@ export const objectForm = defineForm({ // visibility fails OPEN, keeping the option. Either way the author // would still be offered "Set null". // - // ⚠️ The predicates must stay disjoint AND total over the two types: + // ⚠️ The predicates must stay disjoint AND total over the types they + // serve (`lookup` and `user` on one, `master_detail` on the other): // an overlap renders two selects writing one key, a gap makes the // control vanish for that type. Both are pinned in // `form-delete-behavior-options.test.ts`. @@ -350,7 +351,14 @@ export const objectForm = defineForm({ // form fields by field PATH (`metadataForms..fields.`), // so these two declarations share one translation key — divergent // text here would collide silently, last writer winning. - { field: 'deleteBehavior', type: 'select', helpText: 'What happens when the referenced record is deleted', visibleWhen: "data.type == 'lookup'", options: [ + // + // `user` shares the `lookup` branch (#29415): since #29253 a + // declared `deleteBehavior` on a `user` field takes effect when the + // user is deleted, and all three outcomes are legal there. Unlike a + // `lookup`, a `user` field has no default (declaring nothing keeps + // it out of the delete walk), so the select writes a value only when + // the author picks one. + { field: 'deleteBehavior', type: 'select', helpText: 'What happens when the referenced record is deleted', visibleWhen: "data.type in ['lookup','user']", options: [ { label: 'Set null', value: 'set_null' }, { label: 'Cascade (delete children)', value: 'cascade' }, { label: 'Restrict (block the delete)', value: 'restrict' }, diff --git a/packages/spec/src/migrations/entries/semantic/18.user-field-delete-behavior-enforced.ts b/packages/spec/src/migrations/entries/semantic/18.user-field-delete-behavior-enforced.ts new file mode 100644 index 00000000000..276c59ba2e3 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.user-field-delete-behavior-enforced.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #29253 (PR #29368) made a declared `deleteBehavior` on a `user` field take +// effect when the user is deleted; this entry is its ADR-0087 record (#29415, +// should-fix A of that PR's contract review). A semantic entry and not a +// conversion, as `field-master-detail-set-null-refused` is: no key moved, the +// enforcement of an accepted declaration changed, and whether a declaration +// was meant as an outcome or as documentation is the author's to say. +export const entry: SemanticMigration = { + id: 'user-field-delete-behavior-enforced', + surface: + '`object.fields[].deleteBehavior` (and `objectExtension.fields[].deleteBehavior`) on a field of ' + + '`type: \'user\'`.', + replacement: + 'Declare on a `user` field only the `deleteBehavior` you want enforced when the referenced user is ' + + 'deleted: `restrict` refuses the delete, `cascade` deletes the rows holding the user, `set_null` ' + + 'clears the field (on a `multiple: true` field, removes that user). Drop the key to keep the field ' + + 'out of the delete walk: a `user` field that declares no `deleteBehavior` keeps the deleted user\'s ' + + 'id, as before.', + reason: + 'The spec always accepted and round-tripped `deleteBehavior` on a `user` field, but the referential ' + + 'delete walk read it only on `master_detail`, `lookup` and `tree` fields, so the declaration did ' + + 'nothing: a `restrict` refused no delete and a `cascade` deleted no row. A `user` field that ' + + 'declares a value now joins the walk and reads it as a `lookup` reads its own, so a delete that ' + + 'succeeded can now be refused with 409 `DELETE_RESTRICTED`, and rows can now be deleted or ' + + 'cleared. A `user` field that declares nothing stays out: the parse writes no default on it and ' + + 'the engine invents none. No conversion can carry the change, because no key or value moved; ' + + 'what is owed is the author\'s reading of each declaration. One stored population needs it most: ' + + 'objects saved while the parse still wrote `deleteBehavior: \'set_null\'` on every field type ' + + 'can carry a `set_null` on a `user` field that nobody authored. On a field that is not ' + + '`required` it now clears the field when the user is deleted, which is what the same declaration ' + + 'does on a `lookup`, and is accepted as it is. On a `required` field it now refuses the user ' + + 'delete with 409 `DELETE_RESTRICTED` wherever clearing would leave the field empty, a refusal no ' + + 'author chose.', + acceptanceCriteria: + 'Every `user` field that declares `deleteBehavior` declares the outcome its author wants when the ' + + 'referenced user is deleted, and a `required` `user` field carries `set_null` only where refusing ' + + 'that delete is intended. A stored object whose `user` field carries a `deleteBehavior` nobody ' + + 'wrote is saved again without the key, or with the value chosen. Deleting a test user that each ' + + 'such field holds gives the declared outcome: 409 `DELETE_RESTRICTED` for `restrict` (and for ' + + '`set_null` on a `required` field that the delete would leave empty), the holding rows deleted for ' + + '`cascade`, the field cleared for `set_null`, and the id kept where the field declares nothing.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.user-field-foreign-reference-refused.ts b/packages/spec/src/migrations/entries/semantic/18.user-field-foreign-reference-refused.ts index 7b765200cc7..ac96e33eb33 100644 --- a/packages/spec/src/migrations/entries/semantic/18.user-field-foreign-reference-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.user-field-foreign-reference-refused.ts @@ -23,10 +23,10 @@ export const entry: SemanticMigration = { + '`current_user` default, a sharing rule over the users a field holds) treated its values as users. ' + 'No conversion can carry the rewrite. A `lookup` joins the referential delete walk and gets ' + '`deleteBehavior: \'set_null\'` by default, so it would start clearing the column when the target ' - + 'record is deleted, which a `user` field never did, and `deleteBehavior` has no value that means ' - + '"leave it". A converted field would also stop counting as one that holds users for sharing, and ' - + 'a `current_user` default on it would be refused. So a delete-behaviour judgement is owed by the ' - + 'author.', + + 'record is deleted, which a `user` field that declares no `deleteBehavior` does not do, and ' + + '`deleteBehavior` has no value that means "leave it". A converted field would also stop counting ' + + 'as one that holds users for sharing, and a `current_user` default on it would be refused. So a ' + + 'delete-behaviour judgement is owed by the author.', acceptanceCriteria: 'Every `user` field in the stack either declares no `reference` or declares `reference: \'sys_user\'`, ' + 'and `os validate` passes. A field that still names another object is refused at parse with an '