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..5e242efe405 --- /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 and the `getCascadeDeleteObjects` contract docblock 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/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 63543dcab6d..f98afbfdc27 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -817,7 +817,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/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 8243d3bd3b6..5c10631eb22 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -218,10 +218,18 @@ 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. - { 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 6d5e31de748..a398f4b89be 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1819,9 +1819,11 @@ 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 - * `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 + * `reference`, and on a `user` field that AUTHORS one (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; 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 @@ -2734,8 +2736,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 @@ -2750,7 +2754,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`.', }); } @@ -3298,9 +3303,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 '