diff --git a/.changeset/23735-reference-deleted-when.md b/.changeset/23735-reference-deleted-when.md new file mode 100644 index 00000000000..09153ec28ef --- /dev/null +++ b/.changeset/23735-reference-deleted-when.md @@ -0,0 +1,25 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-core': patch +'@objectstack/driver-sql': patch +'@objectstack/platform-objects': patch +--- + +feat(spec): `referenceDeletedWhen` beside `referenceVia` marks a polymorphic pointer's target as platform-deleted + +Clause-②: yes + +`FieldSchema` gains one optional key beside `referenceVia`: `referenceDeletedWhen: { field, value }`. On rows where the sibling `select` field `field` holds the option `value`, the record the pointer names was deleted by the platform's own assertion, and a renderer draws a tombstone from that option's own label instead of a link. Example: `record_id: Field.text({ referenceVia: 'object_name', referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' } })`. + +Any other shape is refused at parse, naming the field and the option: + +- the key on a field that declares no `referenceVia` (`FieldSchema`), or an unknown key inside it; +- a `field` that is the declaring field itself, is not declared on the object, is not a `select`, is a `multiple: true` select, or declares no inline `options` (a `picklist`-backed select); a `value` that is not one of the sibling's option values (`ObjectSchema`, at every door: `ObjectSchema.create()`, `defineStack`, raw `.parse()` and the metadata save door). + +Nothing that parsed before is refused: the key is new. Its liveness row is `planned` until the renderer that reads it ships. + +The other packages classify the new key where every field key is classified: + +- **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` deletes a served field's `referenceDeletedWhen` when its `field` names a field the caller cannot read (field-level security); `value` is an option value and never a field name. +- **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies it `presentation`: no DDL reads it. +- **`@objectstack/platform-objects`.** The field form's new `referenceDeletedWhen` row carries its labels and help text in zh-CN, ja-JP and es-ES. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 3d2ae2bf240..aa56d333810 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -78,6 +78,7 @@ const result = CurrencyConfigSchema.parse(data); | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | +| **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | @@ -201,6 +202,13 @@ const result = CurrencyConfigSchema.parse(data); | **default** | `boolean` | optional | Is default option | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | +### Nested Shape: `Field.referenceDeletedWhen` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | The SIBLING `select` field on the same object whose value marks the target as deleted (snake_case). | +| **value** | `string` | ✅ | The option value of that `select` field that means "the referenced record was deleted by the platform". | + ### Nested Shape: `Field.inlineColumns[number]` | Property | Type | Required | Description | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index e7a4b332654..590b26752aa 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -243,6 +243,7 @@ const result = ApiMethod.parse(data); | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | +| **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | @@ -585,6 +586,7 @@ const result = ApiMethod.parse(data); | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | +| **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 3201350a4eb..db1a163c327 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -79,6 +79,7 @@ Add a new field to an existing object | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | +| **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | @@ -506,6 +507,7 @@ Add a new field to an existing object | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | +| **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index a6f2edbf0eb..d59e672d6e2 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 157 | 80 | 2 | 0 | 75 | +| `data/` | 158 | 81 | 2 | 0 | 75 | ## `data/` — sites @@ -45,7 +45,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `driver/turso.zod.ts` | 2 | | `external-catalog.zod.ts` | 4 | | `field-value.zod.ts` | 4 | -| `field.zod.ts` | 13 | +| `field.zod.ts` | 14 | | `filter.zod.ts` | 12 | | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 7 | @@ -56,7 +56,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 1 | | `validation.zod.ts` | 6 | -| **total** | **157** | +| **total** | **158** | ## `data/` — open @@ -64,7 +64,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**75 strip of 157**, in 10 file(s). +**75 strip of 158**, in 10 file(s). | File | Strip | Sites | |---|---|---| @@ -73,12 +73,12 @@ over it is here. | `driver-sql.zod.ts` | 2 | 2 | | `driver.zod.ts` | 9 | 9 | | `external-catalog.zod.ts` | 4 | 4 | -| `field.zod.ts` | 2 | 13 | +| `field.zod.ts` | 2 | 14 | | `filter.zod.ts` | 11 | 12 | | `hook.zod.ts` | 5 | 7 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **75** | **157** | +| **total** | **75** | **158** | | Bucket | Sites | |---|---| diff --git a/packages/drivers/driver-sql/src/builtin-column-collision.ts b/packages/drivers/driver-sql/src/builtin-column-collision.ts index 01b451f653f..3aee45540cb 100644 --- a/packages/drivers/driver-sql/src/builtin-column-collision.ts +++ b/packages/drivers/driver-sql/src/builtin-column-collision.ts @@ -123,6 +123,7 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly> = dueLike: 'presentation', // declares a date a deadline (#22227) — read by the renderer's date cells, never by `createColumn`: the column is the date the type already sizes settledWhen: 'presentation', // the deadline's settle predicate (#22227), display only like `visibleWhen` — no DDL reads it conditionalFormatting: 'presentation', // a field's cell formatting rules (#22228), display only — no DDL reads it + referenceDeletedWhen: 'presentation', // a pointer's platform-deleted predicate (#23735), read by the renderer to draw a tombstone — never by `createColumn`: the column is the text id `referenceVia` already sizes mailAssociation: 'presentation', // marks an email field as a correspondent address for inbound-mail matching (#22870) — read by the mailbox service, never by `createColumn`: the column is the email string the type already sizes requiredPermissions: 'presentation', maskingRule: 'presentation', diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 23bbf65a391..7ca9f2f5df6 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -674,7 +674,10 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // `
::::`, 87 → 88, adds exactly // `field :: mailAssociation :: data.type == 'email'` and removes none. It // does reach the literal-comparison census below. - expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(88); + // It is 89 today, an ADDITION of ONE: #23735 offered the field form's + // `referenceDeletedWhen` composite row, gated like `referenceVia` on + // `data.type == 'text'`. It reaches the literal-comparison census below. + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(89); const findings = validatePredicatePathRefs(corrupted); expect(findings).toHaveLength(predicates); @@ -783,7 +786,9 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // two more single-quoted literal comparisons. // It is 65 today: #22870's `field :: mailAssociation` row is gated on // `data.type == 'email'`, a single-quoted literal comparison. - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(65); + // It is 66 today: #23735's `field :: referenceDeletedWhen` row is gated on + // `data.type == 'text'`, a single-quoted literal comparison. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(66); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED); diff --git a/packages/metadata-core/src/object-schema-fls-references.test.ts b/packages/metadata-core/src/object-schema-fls-references.test.ts index d1c6a57937e..46fb65ca5c2 100644 --- a/packages/metadata-core/src/object-schema-fls-references.test.ts +++ b/packages/metadata-core/src/object-schema-fls-references.test.ts @@ -351,6 +351,24 @@ describe('[ADR-0106 D1] references to a denied field — inside a READABLE field expect(served.fields.title.inlineAmountField).toBe('id'); }); + it('deletes a `referenceDeletedWhen` predicate whose sibling is denied, and keeps one over a readable sibling', () => { + // #23735 — `field` names a sibling `select` of this object; `value` is an + // option value, never a field, so a value spelled like a denied field + // costs nothing. + const denied = mask({ + name: 'thing', + fields: fieldsWith({ referenceVia: 'id', referenceDeletedWhen: { field: 'secret_score', value: 'gone' } }), + }); + expect(denied.fields.title).not.toHaveProperty('referenceDeletedWhen'); + expect(denied.fields.title.referenceVia).toBe('id'); + + const readable = mask({ + name: 'thing', + fields: fieldsWith({ referenceVia: 'id', referenceDeletedWhen: { field: 'id', value: 'secret_score' } }), + }); + expect(readable.fields.title.referenceDeletedWhen).toEqual({ field: 'id', value: 'secret_score' }); + }); + it('leaves names of ANOTHER object\'s fields alone — they are that object\'s projection', () => { const served = mask({ name: 'thing', diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index cf72436b0ef..def5e480468 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -574,6 +574,18 @@ export const DEPENDS_ON_ENTRY_POSITIONS: Readonly> = { param: keep, }; +/** + * [ADR-0106 D1] A field's `referenceDeletedWhen: { field, value }` (#23735). + * `field` names a sibling `select` of THIS object; `value` is one of that + * field's option values, never a field name. The predicate is deleted whole + * when `field` names a denied field — a bare `value` would read nothing. + */ +const deletedWhen: Scrub = (value, denied) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return unclassified(value, denied); + const { field } = value as { field?: unknown }; + return typeof field === 'string' && namesDeniedField(field, denied) ? REMOVE : value; +}; + /** * [ADR-0106 D1] Every `FieldSchema` key, classified. Pinned against the live * schema shape by `object-schema-fls-references.test.ts`. @@ -608,6 +620,11 @@ export const FIELD_REFERENCE_POSITIONS: Readonly> = { // child's `master_detail` field and its columns ARE the child's own fields — // this object, not the parent the field points at. referenceVia: pointer, + // The pointer's platform-deleted predicate (#23735): its `field` names a + // sibling `select` of this object (its `value` is that field's option, not + // a field). Deleted when the sibling is denied: the caller is not served + // the column the predicate reads, so it can never hold for them. + referenceDeletedWhen: deletedWhen, inlineAmountField: pointer, inlineColumns: arrayOf(inlineColumn), relatedListColumns: names, diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index 66e99ad4515..18fb08022d3 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -312,14 +312,16 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * deadline and when it is settled), 77 → 78 when it gained * `conditionalFormatting` (#22228, a field's cell formatting rules), and * 78 → 79 when it gained `mailAssociation` (#22870, the inbound-mail - * correspondent flag on an email field). + * correspondent flag on an email field), and 79 → 80 when it gained + * `referenceDeletedWhen` (#23735, a polymorphic pointer's platform-deleted + * predicate). * `dashboard` moved 21 → 22 the same way when it gained * `requiredPermissions` (#22611, the audience gate). `position` moved * 12 → 13 the same way when it gained `permissionSets` (ADR-0131 D3/D4, * the sets a position distributes). */ const CARD_PROPERTY_COUNTS: Record = { - agent: 26, app: 30, dashboard: 22, dataset: 16, field: 79, flow: 23, + agent: 26, app: 30, dashboard: 22, dataset: 16, field: 80, flow: 23, hook: 22, object: 45, page: 25, position: 13, report: 21, skill: 17, tool: 14, }; diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 6cbf48c9ccc..8cc4ebb15ec 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -632,6 +632,18 @@ export const enMetadataForms: NonNullable = { label: "Reference Via", helpText: "Makes this text field the id half of a polymorphic pointer: names the SIBLING field on the same object that holds the target object name, per row (ADR-0052 §5). snake_case; text fields only, and mutually exclusive with `reference`." }, + referenceDeletedWhen: { + label: "Reference Deleted When", + helpText: "Only beside referenceVia: on rows where a sibling select field holds one of its options, the record this pointer names was deleted by the platform, and the pointer is shown as a tombstone labelled by that option. Unset: no row is marked deleted." + }, + "referenceDeletedWhen.field": { + label: "Field", + helpText: "The sibling select field on this object (snake_case), e.g. cancel_reason. It must be a single-value select declaring inline options." + }, + "referenceDeletedWhen.value": { + label: "Value", + helpText: "The option value of that select field meaning the target was deleted, e.g. record_deleted. It must be one of its declared option values." + }, mailAssociation: { label: "Match Inbound Mail", helpText: "Links inbound mail to records of this object whose value in this field matches the message's sender or a recipient (case-insensitive; the mailbox's own address excluded). Email fields only. Takes effect only where a mailbox service is set up." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 53c0064d19a..e6c06e5c690 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -632,6 +632,18 @@ export const esESMetadataForms: NonNullable = label: "Referencia mediante", helpText: "Convierte este campo de texto en la mitad de id de un puntero polimórfico: nombra el campo HERMANO del mismo objeto que guarda, fila a fila, el nombre del objeto destino (ADR-0052 §5). En snake_case; solo campos de texto y excluyente con `reference`." }, + referenceDeletedWhen: { + label: "Referencia eliminada cuando", + helpText: "Solo junto a referenceVia: en las filas donde un campo de selección hermano tiene una de sus opciones, el registro al que apunta este puntero fue eliminado por la plataforma, y el puntero se muestra como eliminado con la etiqueta de esa opción. Sin definir: ninguna fila se marca como eliminada." + }, + "referenceDeletedWhen.field": { + label: "Campo", + helpText: "El campo de selección hermano de este objeto (snake_case), p. ej. cancel_reason. Debe ser una selección de un solo valor que declare opciones en línea." + }, + "referenceDeletedWhen.value": { + label: "Valor", + helpText: "El valor de opción de ese campo de selección que indica que el destino fue eliminado, p. ej. record_deleted. Debe ser uno de sus valores de opción declarados." + }, mailAssociation: { label: "Asociar correo entrante", helpText: "Vincula el correo entrante a los registros de este objeto cuyo valor en este campo coincide con el remitente o con algún destinatario del mensaje (sin distinguir mayúsculas; se excluye la dirección del propio buzón). Solo campos de email. Solo surte efecto donde hay un servicio de buzón configurado." diff --git a/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts index e0bc52710f1..07fdadda627 100644 --- a/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts @@ -703,8 +703,13 @@ describe('#19403 round 4 — the blind spot, FOURTH shape: a type-level coverage // pin exactly as these five are, and the extractor emitted a key for each — // but that flight authored all six of their string leaves in all three // locales, so the echo rule above reads them translated, not undecided. - expect(UNWALKED_CHILDREN.length).toBe(8); - for (const child of ['currencyConfig.currencyMode', 'currencyConfig.defaultCurrency', 'storage.notNull']) { + // 10 since #23735: the `referenceDeletedWhen` composite (`field`, `value`), + // whose four string leaves that card authored in all three locales. + expect(UNWALKED_CHILDREN.length).toBe(10); + for (const child of [ + 'currencyConfig.currencyMode', 'currencyConfig.defaultCurrency', 'storage.notNull', + 'referenceDeletedWhen.field', 'referenceDeletedWhen.value', + ]) { expect(UNWALKED_CHILDREN).toContain(child); } for (const child of ['object', 'function', 'field', 'relationshipField', 'filter']) { @@ -742,7 +747,9 @@ describe('#19403 round 4 — the blind spot, FOURTH shape: a type-level coverage // `currencyConfig` and `storage` joined in #19332 (flight G1b), in form // order around it; their children were authored by that flight, so the // broken five still hang off `summaryOperations` alone. - expect([...skippedParents]).toEqual(['currencyConfig', 'summaryOperations', 'storage']); + // `referenceDeletedWhen` joined in #23735, beside `referenceVia`; its + // children were authored by that card. + expect([...skippedParents]).toEqual(['currencyConfig', 'referenceDeletedWhen', 'summaryOperations', 'storage']); // The type is "carried" — the control the other pin reads — yet none of the // leaves this round had to decide is inside its derivation. expect(ROW_PROPERTIES.length, 'field DOES carry repeater row properties, so the other pin counts it covered').toBeGreaterThan(0); diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 31188af32fa..018c1341696 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -632,6 +632,18 @@ export const jaJPMetadataForms: NonNullable = label: "ポリモーフィック参照の相棒項目", helpText: "このテキスト項目をポリモーフィック参照の id 側にします。ここには、対象オブジェクト名を行ごとに保持する同一オブジェクト上の別項目名を指定します(ADR-0052 §5)。snake_case で記述し、テキスト項目のみ、`reference` とは併用できません。" }, + referenceDeletedWhen: { + label: "参照先削除条件", + helpText: "referenceVia と併用する場合のみ有効:同じオブジェクトの選択(select)項目が特定の選択肢の値を持つ行では、このポインターが指すレコードはプラットフォームにより削除済みであり、ポインターはその選択肢のラベルで削除済み表示されます。未設定:どの行も削除済みとして扱われません。" + }, + "referenceDeletedWhen.field": { + label: "項目", + helpText: "このオブジェクト上の兄弟の選択項目(snake_case)。例:cancel_reason。インラインの選択肢を宣言した単一値の選択項目である必要があります。" + }, + "referenceDeletedWhen.value": { + label: "値", + helpText: "参照先が削除されたことを示す、その選択項目の選択肢の値。例:record_deleted。宣言済みの選択肢の値のいずれかである必要があります。" + }, mailAssociation: { label: "受信メールの照合", helpText: "この項目の値がメッセージの送信者またはいずれかの受信者のアドレスと一致する、このオブジェクトのレコードに受信メールを関連付けます(大文字と小文字は区別しません。メールボックス自身のアドレスは除きます)。メール項目のみ。メールボックスサービスが設定されている環境でのみ有効です。" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index cf97ae972b7..6fd8fdb2560 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1193,7 +1193,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 669 since the page form offers `interfaceConfig.emptyState`, the list // page's own empty state, once the console list page draws it — one new // row label, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(669); + // 672 since the field form offers the `referenceDeletedWhen` composite + // beside `referenceVia`, with its `field` and `value` sub-rows — three new + // row labels, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(672); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 0e33d65b3b7..77df4c29041 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -632,6 +632,18 @@ export const zhCNMetadataForms: NonNullable = label: "多态指针伴随字段", helpText: "把该文本字段声明为多态指针的 id 半边:此处填写同一对象上保存目标对象名的另一个字段,目标逐行决定(ADR-0052 §5)。使用 snake_case;仅限文本字段,且与 `reference` 互斥。" }, + referenceDeletedWhen: { + label: "多态指针目标已删除条件", + helpText: "仅与 referenceVia 同用:当同一对象上某个选择(select)字段取其某个选项值时,表示该指针指向的记录已被平台删除,指针以该选项的标签显示为已删除标记。未设置:不会把任何行标记为已删除。" + }, + "referenceDeletedWhen.field": { + label: "字段", + helpText: "本对象上的同级选择字段(snake_case),例如 cancel_reason。必须是声明了内联选项的单选字段。" + }, + "referenceDeletedWhen.value": { + label: "取值", + helpText: "该选择字段中表示目标已被删除的选项值,例如 record_deleted。必须是其已声明的选项值之一。" + }, mailAssociation: { label: "匹配入站邮件", helpText: "把入站邮件关联到本对象中该字段值与邮件发件人或某位收件人地址相同的记录(不区分大小写;不含邮箱自身的地址)。仅限邮箱字段。只在配置了邮箱服务的环境中生效。" diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 495553c5e89..70496f66d54 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -369,6 +369,7 @@ "data/Field:readonly", "data/Field:readonlyWhen", "data/Field:reference", + "data/Field:referenceDeletedWhen", "data/Field:referenceVia", "data/Field:relatedList", "data/Field:relatedListColumns", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 0d4853e13c3..2fd5a9afad2 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -245,6 +245,25 @@ "evidence": "packages/metadata-protocol/src/seed-loader.ts (load step 4.5 collects declared pointer pairs; loadDataset derives a per-row reference through the sibling column and routes it through the shared resolution loop — same probes, pass-2 deferral, loud refusal); packages/plugins/plugin-audit/src/objects/sys-activity.object.ts (record_id via object_name, source_id via source_object)", "note": "[#11339] Polymorphic pointer pair (ADR-0052 §5 ActivityPointer): marks a text field as the id half whose target object the SIBLING column names per row. Consumer (seed-time natural-key resolution + loud refusal of unresolvable/un-addressable pointers) lands in the same PR (ADR-0049 declare = enforce; the maskingRule precedent). Authoring contradictions refused at parse (text-only, exclusive with reference) and create (sibling must be declared). Proven in packages/metadata-protocol/src/seed-loader-pointer-pair.test.ts and packages/spec/src/data/field.test.ts." }, + "referenceDeletedWhen": { + "status": "planned", + "verifiedAt": "2026-10-15", + "evidenceScope": "in-repo", + "evidence": "packages/spec/src/data/field.zod.ts#referenceDeletedWhen (the parse-time door, field half: the FieldSchema superRefine refuses the key on a field that declares no `referenceVia`); packages/spec/src/data/object.zod.ts#refuseDishonestReferenceDeletedWhen (the parse-time door, sibling half: the ObjectSchema superRefine refuses a `field` that is not a declared single-value `select` with inline `options` on the same object, and a `value` that is not one of its option values); packages/spec/src/data/field-reference-deleted-when.test.ts (the accept and refuse pins)", + "note": "PLANNED — declared ahead of both carriers by the ruling's own sequencing (objectui#12489 ruling 6203737086, letter A; this key is its spec half). PRODUCER: objectstack #23736 (Blocked-by this key) declares it on `sys_approval_request.record_id` as `{ field: 'cancel_reason', value: 'record_deleted' }` — the cancel reason plugin-approvals already writes when a request's record is deleted. CONSUMER: objectui#12489 step 4, the record pointer renderer, which draws the tombstone from the option's own label, reads nothing and links nothing; dispatched once the spec is installable at objectui's pin. Measured in this repo at 3b46855fb: no author of the key and no reader outside packages/spec (it is new). objectui was not walked from this seat, so the scope is in-repo. No `authorWarn`, on the `field.settledWhen` precedent: a display refinement with named, blocked carriers, whose authoring half (parse, sibling and option checks) is already enforced; an author who sets it today loses nothing and re-authors nothing when the renderer lands. Flip to `live` on the objectui#12489 step 4 landing, citing its renderer at the pin that carries it, with #23736's object declaration as `producer`.", + "children": { + "field": { + "status": "planned", + "verifiedAt": "2026-10-15", + "note": "The sibling `select` field whose value marks the target deleted. Judged at parse by the container's evidence (snake_case; a declared single-value `select` with inline `options` on the same object, never the declaring field itself); read by nothing until the objectui#12489 step 4 renderer lands. Shares the container's verdict and its flip." + }, + "value": { + "status": "planned", + "verifiedAt": "2026-10-15", + "note": "The option value meaning 'deleted by the platform'; its option's label is the tombstone's text. Judged at parse by the container's evidence (one of the sibling's declared option values); read by nothing until the objectui#12489 step 4 renderer lands. Shares the container's verdict and its flip." + } + } + }, "mailAssociation": { "status": "live", "verifiedAt": "2026-10-13", diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 72f2e5392d5..b264a817cb0 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 95 | 0 | 0 | 1 | 3 | 99 | +| `field` | 95 | 0 | 0 | 1 | 5 | 101 | diff --git a/packages/spec/src/data/field-reference-deleted-when.test.ts b/packages/spec/src/data/field-reference-deleted-when.test.ts new file mode 100644 index 00000000000..8f3f2b0b222 --- /dev/null +++ b/packages/spec/src/data/field-reference-deleted-when.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { FieldSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { ObjectStackDefinitionSchema } from '../stack.zod'; + +// --------------------------------------------------------------------------- +// [objectui#12489 ruling 6203737086, letter A] `referenceDeletedWhen: +// { field, value }` rides beside `referenceVia`: on rows where the sibling +// `select` field `field` holds the option `value`, the record the pointer names +// was deleted by the platform. Any other shape is refused at parse: +// +// - field half (`FieldSchema`): the key without `referenceVia`, an unknown +// key inside the predicate, a non-snake_case `field`; +// - sibling half (`ObjectSchema`): `field` naming itself, nothing, a +// non-`select`, a `multiple: true` select, a select with no inline +// `options`; `value` naming no declared option. +// +// The pins bear weight in both directions: the producer's own shape +// (`sys_approval_request.record_id` with `cancel_reason` / `record_deleted`) +// parses clean, and each refused shape is ONE located `custom` issue naming +// the field and the option an author greps for — never the prose around them. +// --------------------------------------------------------------------------- + +const CANCEL_OPTIONS = [{ label: 'Related record deleted', value: 'record_deleted' }]; + +const request = ( + predicate: unknown, + overrides: Record = {}, +) => ({ + name: 'sys_approval_request', + fields: { + object_name: { type: 'text', label: 'Object' }, + record_id: { + type: 'text', + label: 'Record ID', + referenceVia: 'object_name', + ...(predicate === undefined ? {} : { referenceDeletedWhen: predicate }), + }, + cancel_reason: { type: 'select', label: 'Cancellation Reason', options: CANCEL_OPTIONS }, + status: { type: 'text', label: 'Status' }, + ...overrides, + }, +}); + +/** The one issue a refused object must carry, located under the predicate. */ +function refusedAt(input: unknown, leaf: 'field' | 'value') { + const parsed = ObjectSchema.safeParse(input); + expect(parsed.success, 'the declaration must NOT parse clean').toBe(false); + if (parsed.success) throw new Error('unreachable'); + expect(parsed.error.issues.map((i) => [i.code, i.path])).toEqual([ + ['custom', ['fields', 'record_id', 'referenceDeletedWhen', leaf]], + ]); + return parsed.error.issues[0]; +} + +describe('`referenceDeletedWhen` — field half (FieldSchema)', () => { + it('accepts the predicate beside `referenceVia` (control)', () => { + const parsed = FieldSchema.safeParse({ + type: 'text', + referenceVia: 'object_name', + referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' }, + }); + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); + if (!parsed.success) return; + expect(parsed.data.referenceDeletedWhen).toEqual({ field: 'cancel_reason', value: 'record_deleted' }); + }); + + it('leaves the key absent when it is not authored (control)', () => { + const parsed = FieldSchema.safeParse({ type: 'text', referenceVia: 'object_name' }); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + expect('referenceDeletedWhen' in parsed.data).toBe(false); + }); + + it('refuses the predicate on a field that declares no `referenceVia`, naming the field and the option', () => { + const parsed = FieldSchema.safeParse({ + type: 'text', + referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' }, + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.map((i) => [i.code, i.path])).toEqual([['custom', ['referenceDeletedWhen']]]); + expect(parsed.error.issues[0].message).toContain('referenceVia'); + expect(parsed.error.issues[0].message).toContain('cancel_reason'); + expect(parsed.error.issues[0].message).toContain('record_deleted'); + }); + + it('refuses an unknown key inside the predicate (closed shape)', () => { + const parsed = FieldSchema.safeParse({ + type: 'text', + referenceVia: 'object_name', + referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted', label: 'Deleted' }, + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.map((i) => i.path)).toEqual([['referenceDeletedWhen']]); + expect(parsed.error.issues[0].message).toContain('label'); + }); + + it('refuses a predicate missing `value`, and a non-snake_case `field`', () => { + const missing = FieldSchema.safeParse({ + type: 'text', + referenceVia: 'object_name', + referenceDeletedWhen: { field: 'cancel_reason' }, + }); + expect(missing.success).toBe(false); + if (!missing.success) expect(missing.error.issues.map((i) => i.path)).toEqual([['referenceDeletedWhen', 'value']]); + + const badName = FieldSchema.safeParse({ + type: 'text', + referenceVia: 'object_name', + referenceDeletedWhen: { field: 'CancelReason', value: 'record_deleted' }, + }); + expect(badName.success).toBe(false); + if (!badName.success) expect(badName.error.issues.map((i) => i.path)).toEqual([['referenceDeletedWhen', 'field']]); + }); +}); + +describe('`referenceDeletedWhen` — sibling half (ObjectSchema)', () => { + it("accepts the producer's own shape: `cancel_reason` / `record_deleted` (control)", () => { + const parsed = ObjectSchema.safeParse(request({ field: 'cancel_reason', value: 'record_deleted' })); + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); + if (!parsed.success) return; + expect(parsed.data.fields.record_id.referenceDeletedWhen).toEqual({ + field: 'cancel_reason', + value: 'record_deleted', + }); + }); + + it('refuses a `field` naming the declaring field itself', () => { + const issue = refusedAt(request({ field: 'record_id', value: 'record_deleted' }), 'field'); + expect(issue.message).toContain('record_id'); + }); + + it('refuses a `field` the object does not declare, naming it', () => { + const issue = refusedAt(request({ field: 'cancel_reasn', value: 'record_deleted' }), 'field'); + expect(issue.message).toContain('cancel_reasn'); + }); + + it('refuses a sibling that is not a `select`, naming its type', () => { + const issue = refusedAt(request({ field: 'status', value: 'record_deleted' }), 'field'); + expect(issue.message).toContain('status'); + expect(issue.message).toContain('`text`'); + }); + + it('refuses a `multiple: true` select sibling', () => { + const issue = refusedAt( + request({ field: 'cancel_reason', value: 'record_deleted' }, { + cancel_reason: { type: 'select', label: 'Reasons', multiple: true, options: CANCEL_OPTIONS }, + }), + 'field', + ); + expect(issue.message).toContain('multiple: true'); + }); + + it('refuses a `picklist`-backed select sibling with no inline options', () => { + const issue = refusedAt( + request({ field: 'cancel_reason', value: 'record_deleted' }, { + cancel_reason: { type: 'select', label: 'Reasons', picklist: 'cancel_reasons' }, + }), + 'field', + ); + expect(issue.message).toContain('options'); + expect(issue.message).toContain('record_deleted'); + }); + + it('refuses a `value` that is not one of the sibling options, naming the option and the declared set', () => { + const issue = refusedAt(request({ field: 'cancel_reason', value: 'deleted' }), 'value'); + expect(issue.message).toContain('`deleted`'); + expect(issue.message).toContain('cancel_reason'); + expect(issue.message).toContain('`record_deleted`'); + }); + + it('`create()` refuses it too — the builder parses through the same door', () => { + expect(() => ObjectSchema.create(request({ field: 'cancel_reason', value: 'deleted' }) as never)).toThrow( + /referenceDeletedWhen/, + ); + const created = ObjectSchema.create(request({ field: 'cancel_reason', value: 'record_deleted' }) as never) as { + fields: Record; + }; + expect(created.fields.record_id.referenceDeletedWhen).toEqual({ field: 'cancel_reason', value: 'record_deleted' }); + }); + + it('the save door (`object` metadata schema) refuses it at the same path', () => { + const schema = getMetadataTypeSchema('object'); + expect(schema).toBeDefined(); + const bad = schema!.safeParse(request({ field: 'cancel_reason', value: 'deleted' })); + expect(bad.success).toBe(false); + if (bad.success) return; + expect(bad.error.issues.map((i) => [i.code, i.path])).toEqual([ + ['custom', ['fields', 'record_id', 'referenceDeletedWhen', 'value']], + ]); + expect(schema!.safeParse(request({ field: 'cancel_reason', value: 'record_deleted' })).success).toBe(true); + }); + + it('the stack door refuses it inside `objects`, located at the predicate', () => { + const manifest = { + id: 'com.example.refdeleted', + name: 'reference-deleted-when-test', + version: '1.0.0', + type: 'app' as const, + namespace: 'crm', + }; + const bad = ObjectStackDefinitionSchema.safeParse({ + manifest, + objects: [request({ field: 'status', value: 'record_deleted' })], + }); + expect(bad.success).toBe(false); + if (bad.success) return; + expect(bad.error.issues.map((i) => [i.code, i.path])).toEqual([ + ['custom', ['objects', 0, 'fields', 'record_id', 'referenceDeletedWhen', 'field']], + ]); + }); +}); diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 6843367fefe..6d3c140582f 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -131,6 +131,22 @@ export const fieldForm = defineForm({ { field: 'language', visibleWhen: "data.type == 'code'", helpText: 'Editor language for syntax highlighting (e.g. javascript, python, sql).' }, { field: 'autonumberFormat', visibleWhen: "data.type == 'autonumber'", helpText: 'Literal text plus a {0000} counter, {YYYY}/{MM}/{DD}/{YYYYMMDD} date tokens in the business time zone, and {field_name} interpolation. The counter resets per rendered prefix. Omitted on an autonumber field it defaults to {0000}.' }, { field: 'referenceVia', visibleWhen: "data.type == 'text'", helpText: 'Makes this text field the id half of a polymorphic pointer: names the SIBLING field on the same object that holds the target object name, per row (ADR-0052 §5). snake_case; text fields only, and mutually exclusive with `reference`.' }, + // objectui#12489 ruling 6203737086, letter A — the platform-deleted + // predicate that rides beside `referenceVia`. A strict two-key object, + // so it takes the `currencyConfig` composite face (declared sub-rows + // reach the translation catalogs). Gated like `referenceVia`; the parse + // refuses it without one, and refuses a sibling that is not a declared + // single-value `select` or a value that is not one of its options. + { + field: 'referenceDeletedWhen', + type: 'composite', + visibleWhen: "data.type == 'text'", + helpText: 'Only beside referenceVia: on rows where a sibling select field holds one of its options, the record this pointer names was deleted by the platform, and the pointer is shown as a tombstone labelled by that option. Unset: no row is marked deleted.', + fields: [ + { field: 'field', type: 'text', helpText: 'The sibling select field on this object (snake_case), e.g. cancel_reason. It must be a single-value select declaring inline options.' }, + { field: 'value', type: 'text', helpText: 'The option value of that select field meaning the target was deleted, e.g. record_deleted. It must be one of its declared option values.' }, + ], + }, // #22870 (ruling 6116489638, Q1 → A) — inbound mail association. A // PARSE gate like the deadline pair below: `FieldSchema` refuses the // key on every type but `email`, so the row shows exactly where it diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index d0986c18966..f9095f554aa 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1472,6 +1472,46 @@ export const FieldSchema = lazySchema(() => { + 'natural key against the object the sibling column names, and an unresolvable pointer is refused ' + 'loudly instead of stored verbatim. Adds no referential integrity or $expand behavior.' ), + /** + * Platform-deleted target predicate for a polymorphic pointer (objectui#12489 + * ruling `6203737086`, letter A). Rides beside {@link referenceVia}. + * + * Declares that, on a row whose sibling `select` field `field` holds the + * option value `value`, the record this pointer names was DELETED — by the + * platform's own assertion, not a guess from a failed read. A renderer draws + * the tombstone from that option's own label, reads nothing and links + * nothing. `sys_approval_request.record_id` with + * `{ field: 'cancel_reason', value: 'record_deleted' }` is the producer the + * ruling names. + * + * Refused at parse unless the declaration is honest: + * - on a field that declares no `referenceVia` (this schema's superRefine) — + * there is no per-row pointer for the predicate to qualify; + * - when `field` is not a declared single-value `select` with inline + * `options` on the same object, or `value` is not one of those options + * (`ObjectSchema`'s superRefine — only the object holds the sibling map). + */ + referenceDeletedWhen: strictObject({ + surface: "this field's `referenceDeletedWhen` predicate", + history: + 'This shape was closed from the day it was declared — an unknown key here was never accepted, so a ' + + 'misspelled predicate cannot parse and silently never mark a target deleted.', + aliases: { option: 'value', equals: 'value', sibling: 'field' }, + }, { + field: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe( + 'The SIBLING `select` field on the same object whose value marks the target as deleted (snake_case).', + ), + value: z.string().describe( + 'The option value of that `select` field that means "the referenced record was deleted by the platform".', + ), + }).optional().describe( + 'Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` ' + + "field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: " + + "'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing " + + 'and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the ' + + 'same object declaring inline `options`, and `value` one of its option values — any other shape is ' + + 'refused at parse.' + ), /** * Inbound mail association — `email` fields only (#22870; ruling * `6116489638`, Q1 → A, under the inbound-mail design record on #8998). @@ -2245,6 +2285,24 @@ export const FieldSchema = lazySchema(() => { 'lookup/master_detail field) for a fixed relationship.', }); } + // [objectui#12489 ruling 6203737086, letter A] `referenceDeletedWhen` + // qualifies a polymorphic pointer: it says WHEN the record the pointer names + // was deleted by the platform. Without `referenceVia` there is no pointer to + // qualify, so the predicate would parse and mark nothing. The sibling half + // (a declared `select` and one of its options) needs the object's field map + // and is judged in `ObjectSchema`'s superRefine. + if (field.referenceDeletedWhen !== undefined && field.referenceVia === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['referenceDeletedWhen'], + message: + '`referenceDeletedWhen` is only valid beside `referenceVia`: it marks the record a polymorphic ' + + `pointer names as platform-deleted when the sibling \`select\` field \`${field.referenceDeletedWhen.field}\` ` + + `holds \`${field.referenceDeletedWhen.value}\`, and this field declares no pointer. Add ` + + '`referenceVia` (naming the sibling that holds the target object name) if this field is the id ' + + 'half of a pointer pair, or drop the key.', + }); + } // [#22870] `mailAssociation` marks a field as holding a correspondent's // address, matched against the participant addresses of inbound mail — and // only an `email` column stores an address to match. On any other type the diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 05ddb06ae7d..c7916c270c2 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1823,6 +1823,116 @@ function refuseNonPictureImageField( }); } +/** + * [objectui#12489 ruling 6203737086, letter A] A field's + * `referenceDeletedWhen: { field, value }` names a SIBLING `select` field on + * this object and one of that field's declared option values — the row state + * in which the record the field's `referenceVia` pointer names was deleted by + * the platform. A renderer draws the tombstone from that option's own label, + * so a predicate that names a missing field, a non-`select` field, or a value + * the field cannot hold would parse and never mark anything (ADR-0049 + * declared = enforced). + * + * Refused, one located issue per offending field at + * `['fields', , 'referenceDeletedWhen', 'field' | 'value']`, when: + * + * - `field` names the declaring field itself, or no declared field; + * - the sibling is not a `select`, or is a `multiple: true` select (its row + * value is an array, so "holds that value" has no single reading); + * - the sibling declares no inline `options` (a `picklist`-backed select's + * values live outside this object, so the option cannot be checked here); + * - `value` is not one of the sibling's option values. + * + * Judged here, in the object's `superRefine` beside + * {@link refuseNonPictureImageField}: `FieldSchema` sees one field at a time + * and refuses only the half it can see (the predicate without a + * `referenceVia`). Judged at parse, not only in `create()`, so every door — + * `defineStack`, a raw `.parse()`, the save door — refuses it; the key is new, + * so no metadata at rest carries it. Judged against the AUTHORED field map. + */ +function refuseDishonestReferenceDeletedWhen( + ownName: unknown, + fields: unknown, + ctx: z.RefinementCtx, +): void { + if (fields === null || typeof fields !== 'object') return; + const objectName = typeof ownName === 'string' ? ownName : ''; + const fieldMap = fields as Record; + for (const [fieldName, fieldDef] of Object.entries(fieldMap)) { + const predicate = (fieldDef as { referenceDeletedWhen?: unknown } | null)?.referenceDeletedWhen as + | { field?: unknown; value?: unknown } + | undefined; + if (predicate === null || typeof predicate !== 'object') continue; + const sibling = predicate.field; + const value = predicate.value; + if (typeof sibling !== 'string' || typeof value !== 'string') continue; + const at = (leaf: 'field' | 'value') => ['fields', fieldName, 'referenceDeletedWhen', leaf]; + const prescription = + '`referenceDeletedWhen.field` must name a single-value `select` field of this object that declares ' + + 'inline `options`, and `referenceDeletedWhen.value` one of its option values (e.g. `{ field: ' + + "'cancel_reason', value: 'record_deleted' }`)."; + if (sibling === fieldName) { + ctx.addIssue({ + code: 'custom', + path: at('field'), + message: + `Field \`${fieldName}\` on object \`${objectName}\` declares \`referenceDeletedWhen.field: ` + + `'${sibling}'\` — itself. The predicate reads a SIBLING \`select\` field. ${prescription}`, + }); + continue; + } + if (!Object.prototype.hasOwnProperty.call(fieldMap, sibling)) { + ctx.addIssue({ + code: 'custom', + path: at('field'), + message: + `Field \`${fieldName}\` on object \`${objectName}\` declares \`referenceDeletedWhen.field: ` + + `'${sibling}'\`, but the object declares no field \`${sibling}\`. ${prescription}`, + }); + continue; + } + const siblingDef = fieldMap[sibling] as + | { type?: unknown; multiple?: unknown; options?: unknown } + | null + | undefined; + const siblingType = siblingDef?.type; + if (siblingType !== 'select' || siblingDef?.multiple === true) { + const what = siblingType === 'select' ? 'a `multiple: true` `select`' : `a \`${String(siblingType)}\` field`; + ctx.addIssue({ + code: 'custom', + path: at('field'), + message: + `Field \`${fieldName}\` on object \`${objectName}\` declares \`referenceDeletedWhen.field: ` + + `'${sibling}'\`, which is ${what}, not a single-value \`select\`. ${prescription}`, + }); + continue; + } + const options = Array.isArray(siblingDef?.options) ? (siblingDef.options as Array<{ value?: unknown }>) : []; + if (options.length === 0) { + ctx.addIssue({ + code: 'custom', + path: at('field'), + message: + `Field \`${fieldName}\` on object \`${objectName}\` declares \`referenceDeletedWhen.field: ` + + `'${sibling}'\`, a \`select\` that declares no inline \`options\` (a \`picklist\`-backed select's ` + + `values are not declared on this object, so \`'${value}'\` cannot be checked). ${prescription}`, + }); + continue; + } + const declared = options.map((o) => o?.value).filter((v): v is string => typeof v === 'string'); + if (!declared.includes(value)) { + ctx.addIssue({ + code: 'custom', + path: at('value'), + message: + `Field \`${fieldName}\` on object \`${objectName}\` declares \`referenceDeletedWhen.value: ` + + `'${value}'\`, but \`select\` field \`${sibling}\` declares no option \`${value}\`. Its option ` + + `values: ${declared.map((v) => `\`${v}\``).join(', ')}. ${prescription}`, + }); + } + } +} + /** * The machine-name grammar an attached block and its leaves share with field * names — they are keys on the same served row (`record..`). @@ -2930,6 +3040,10 @@ const ObjectSchemaBase = strictObject( // object — the same door, for the same reason: only the object holds the // field map the pointer resolves against. refuseNonPictureImageField(object.name, object.imageField, object.fields, ctx); + // [objectui#12489 ruling 6203737086, letter A] A `referenceDeletedWhen` + // predicate names a declared single-value `select` sibling and one of its + // options — the same door: only the object holds the sibling map. + refuseDishonestReferenceDeletedWhen(object.name, object.fields, ctx); // [#22211 ruling A] A read attachment names at least one leaf, and is not a // field: its block names may not repeat a declared field name. refuseAttachedBlockConflicts(object.attachedOnRead, object.fields, ctx);