From 5c41d26d32867316a87816db5581722dec38211a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:56:43 +0000 Subject: [PATCH 01/11] feat(spec): dueLike and settledWhen on date and datetime fields (wip) The deadline semantic on the field definition: `dueLike` declares the date a deadline, `settledWhen` is a per-record CEL predicate in the visibleWhen family that settles it. Both refused off the date/datetime types, and settledWhen refused without dueLike: true. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- packages/spec/src/data/field.zod.ts | 95 +++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index ce46d67dd28..d376e408d29 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -239,6 +239,25 @@ const MULTILINE_EDITOR_FIELD_TYPES: ReadonlySet = new Set([ 'textarea', 'markdown', 'html', 'richtext', ] as const satisfies readonly FieldType[]); +/** + * Field types whose value is a calendar instant that can PASS — the set on + * which the deadline semantic (`dueLike` / `settledWhen`) is authorable + * (objectui#11815, maintainer ruling D: declared-only overdue). + * + * The two types the overdue affordance is drawn for: objectui's date and + * datetime cells are the one consumer, and they share one overdue predicate + * at day granularity. Deliberately NOT here: `time` (a time of day has no + * calendar day to fall behind), and every type that merely CARRIES a date + * somewhere — a `formula` returning `date` is computed per read, so a deadline + * belongs on the stored column it derives from. `FieldSchema` refuses both + * keys outside this set (the superRefine below, the #11566 template), so a + * deadline declared on a `text` field is refused loudly instead of parsing and + * painting nothing. + */ +const DEADLINE_FIELD_TYPES: ReadonlySet = new Set([ + 'date', 'datetime', +] as const satisfies readonly FieldType[]); + /** * Field types whose stored value the RUNTIME owns outright — issued by the * engine (or the driver's persistent sequence), never supplied by a caller on @@ -1842,6 +1861,34 @@ export const FieldSchema = lazySchema(() => { 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', ), + /** + * Deadline semantic — `date` / `datetime` only (DEADLINE_FIELD_TYPES). + * objectui#11815, maintainer ruling D: overdue is DECLARED, never guessed. + * + * `dueLike` says this date is a deadline, so a renderer may show relative + * overdue wording and an overdue colour once it has passed; absent means not + * a deadline, and nothing is inferred from the field's name. `settledWhen` + * says when the deadline stops mattering for one record — a per-record CEL + * predicate in the `visibleWhen` / `readonlyWhen` / `requiredWhen` family, + * judged at authoring by the same field-rule pass in `@objectstack/lint` + * (`validate-expressions.ts`: it parses, it reads `record.` and never + * a bare field, and only the roots that family binds). While it holds, no + * overdue wording or colour applies — "finished" stays local to the deadline + * that cares, so no select option carries a terminal marker for it. + * + * Declared = enforced (ADR-0049), both halves at this seam: either key on a + * type outside the set is refused, and `settledWhen` without `dueLike: true` + * is refused — a settle condition on a field that is no deadline would parse + * and settle nothing. Neither key has a default, so `undefined` always means + * "not authored" and a field without them never fires either check. + * + * Display only: nothing on the write path reads either key. The consumer is + * the renderer — objectui's date and datetime cells, where the host + * evaluates `settledWhen` against the row and hands the cell one boolean. + */ + dueLike: z.boolean().optional().describe("Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording (\"Overdue 3d\") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it."), + settledWhen: EvaluatedExpressionInputSchema.optional().describe("Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it."), + /** * Form widget override. Names a registered field/UI component to render this * field with, overriding the default widget derived from `type`. Honored by @@ -2422,6 +2469,54 @@ export const FieldSchema = lazySchema(() => { }); } + // objectui#11815 (maintainer ruling D — declared-only overdue): the deadline + // semantic is authorable only on the types whose value can PASS + // (DEADLINE_FIELD_TYPES — its docblock carries the measured consumer), and + // `settledWhen` only beside `dueLike: true`. ADR-0049: a declared key is + // enforced, so each shape that would parse and change nothing is refused at + // the authoring seam, where the fix is one keystroke away. Neither key has a + // schema default, so `undefined` here always means "not authored" — a field + // without them can never fire these. Off-type `settledWhen` gets the TYPE + // refusal alone: telling that author to add `dueLike: true` would send them + // straight into the `dueLike` refusal. The messages enumerate the set ITSELF + // rather than a prose copy of it (#12017 two-copies failure shape). + const deadlineTypes = [...DEADLINE_FIELD_TYPES].map((t) => `'${t}'`).join(', '); + if (field.dueLike !== undefined && !DEADLINE_FIELD_TYPES.has(field.type)) { + ctx.addIssue({ + code: 'custom', + path: ['dueLike'], + message: + `\`dueLike\` is only valid on field types whose value is a calendar date that can pass — ` + + `${deadlineTypes} — and this field is \`${field.type}\`: it declares the field a deadline ` + + 'whose passing a renderer shows as overdue, and this type holds no date to fall behind, so ' + + 'the declaration would parse and change nothing. Drop the key, or declare the deadline on a ' + + '`date` / `datetime` field.', + }); + } + if (field.settledWhen !== undefined) { + if (!DEADLINE_FIELD_TYPES.has(field.type)) { + ctx.addIssue({ + code: 'custom', + path: ['settledWhen'], + message: + `\`settledWhen\` is only valid on field types whose value is a calendar date that can pass — ` + + `${deadlineTypes} — and this field is \`${field.type}\`: it says when a deadline declared ` + + 'by `dueLike` is settled, and only those types carry one. Drop the key, or move the deadline ' + + 'and its `settledWhen` to a `date` / `datetime` field.', + }); + } else if (field.dueLike !== true) { + ctx.addIssue({ + code: 'custom', + path: ['settledWhen'], + message: + `\`settledWhen\` says when this field's deadline is settled, but the field declares no ` + + `deadline (\`dueLike\` is ${field.dueLike === false ? '`false`' : 'absent'}), so the ` + + 'predicate would parse and settle nothing (ADR-0049: a declared key is enforced). Add ' + + '`dueLike: true` if this date is a deadline, or delete `settledWhen`.', + }); + } + } + // #19629 (maintainer ruling 5791803339 — batch #215 item 1, letter B, // 「215 同意」): `scale` is RETIRED from the `currency` type and refused at // this seam. On a currency field the key was three-faced: the field From 013f5ab06cb8a59a5d504d6cece549eeb2c16b16 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:00:52 +0000 Subject: [PATCH 02/11] test(spec,lint): pins for the deadline keys; form rows; settledWhen in the field-rule pass (wip) Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../lint/src/validate-expressions.test.ts | 99 +++++++++- packages/lint/src/validate-expressions.ts | 19 +- packages/spec/authorable-surface/data.json | 2 + packages/spec/src/data/field-due-like.test.ts | 181 ++++++++++++++++++ packages/spec/src/data/field.form.ts | 9 + 5 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/data/field-due-like.test.ts diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 54faad26e0c..fc739ee306e 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -22,6 +22,7 @@ import { import { validateStackExpressions, + runStackExpressionPasses, FIELD_RULE_BOUND_ROOTS, FIELD_RULE_NOWHERE_BOUND_ROOTS, FIELD_RULE_JUDGED_ROOTS, @@ -3406,7 +3407,7 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t const fieldKeys = /for \(const key of \[([^\]]*)\] as const\)/.exec(RULE_CODE); expect(fieldKeys, 'the field-predicate loop moved — update this guard').not.toBeNull(); const slots = [...fieldKeys![1].matchAll(/'([^']+)'/g)].map((m) => m[1]); - expect(slots).toEqual(['requiredWhen', 'readonlyWhen', 'conditionalRequired', 'visibleWhen']); + expect(slots).toEqual(['requiredWhen', 'readonlyWhen', 'conditionalRequired', 'visibleWhen', 'settledWhen']); expect(slots.filter((k) => !Object.keys(FieldSchema.shape).includes(k))).toEqual([]); }); @@ -5184,3 +5185,99 @@ describe('field-level predicates refuse a read THROUGH a reference (#20078)', () }); }); }); + +/** + * [#22227] `settledWhen` — a `date` / `datetime` deadline's settle predicate + * (objectui#11815 ruling D) — joins the field-rule family on the `visibleWhen` + * side, and this authoring gate judges it exactly as it judges `visibleWhen`: + * the `record`-scoped check (it parses, it reads `record.`, the field + * exists) plus the root verdict over FIELD_RULE_BOUND_ROOTS. The spec half — + * the type set, and `settledWhen` without `dueLike: true` — is pinned in the + * spec's `field-due-like.test.ts`. + * + * The SCOPE NAMES are pinned as measured, not invented: the family binds + * `record` (plus `previous`, and `parent` on a master-detail line item) and no + * other root; `today()` is a stdlib FUNCTION available in every CEL env, not a + * scope name; and `value` is no root at all at this surface, so it reads as a + * bare field reference. + */ +describe('[#22227] settledWhen — the deadline settle predicate in the field-rule family', () => { + const WHERE = "object 'showcase_task' · field 'due_date' settledWhen"; + const stackWith = (settledWhen: unknown) => ({ + objects: [{ + name: 'showcase_task', + fields: { + status: { type: 'select', options: [{ label: 'Done', value: 'done' }] }, + done: { type: 'boolean' }, + due_date: { type: 'date', dueLike: true, settledWhen }, + }, + }], + }); + const at = (issues: ExprIssue[]) => issues.filter((i) => i.where === WHERE); + const errorsAt = (issues: ExprIssue[]) => at(issues).filter((i) => (i.severity ?? 'error') === 'error'); + + it('control: a record-scoped predicate over a declared field earns no finding', () => { + expect(at(validateStackExpressions(stackWith("record.status == 'done'")))).toEqual([]); + expect(at(validateStackExpressions(stackWith('record.done == true')))).toEqual([]); + }); + + it('refuses a settledWhen that does not parse', () => { + const errors = errorsAt(validateStackExpressions(stackWith("record.status == 'done' &&"))); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.source).toBe("record.status == 'done' &&"); + }); + + it('refuses a bare field reference — the slot is `record`-scoped, like its siblings', () => { + const errors = errorsAt(validateStackExpressions(stackWith("status == 'done'"))); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toMatch(/`status`/); + }); + + it('refuses a read of a field the object does not declare', () => { + const errors = errorsAt(validateStackExpressions(stackWith("record.state == 'done'"))); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toMatch(/`state`/); + }); + + describe('the scope names the gate gives the family — pinned as measured', () => { + it('binds exactly FIELD_RULE_BOUND_ROOTS, the family\'s own set', () => { + expect([...FIELD_RULE_BOUND_ROOTS]).toEqual(['record', 'previous', 'parent']); + }); + + it.each([...FIELD_RULE_BOUND_ROOTS])('admits `%s` — no root verdict on a bound root', (root) => { + const source = root === 'record' ? "record.status == 'done'" : `${root}.status == 'done'`; + const issues = at(validateStackExpressions(stackWith(source))); + expect(issues.filter((i) => /a field-level conditional rule binds only/.test(i.message))).toEqual([]); + }); + + it('refuses `current_user` with the family\'s root verdict, naming this slot', () => { + const errors = errorsAt(validateStackExpressions(stackWith("'admin' in current_user.positions"))); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toMatch(/^`settledWhen` reads `current_user`/); + }); + + it('`today()` is a stdlib function, not a scope name — it applies', () => { + const errors = errorsAt(validateStackExpressions(stackWith('record.done == true || today() > timestamp("2020-01-01T00:00:00Z")'))); + expect(errors).toEqual([]); + }); + + it('`value` is no scope name here — it reads as a bare field reference and is refused', () => { + const errors = errorsAt(validateStackExpressions(stackWith('value == true'))); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toMatch(/`value`/); + }); + }); + + it('the object save door judges the slot too — the same pass, at the same position', () => { + const errors = errorsAt(runStackExpressionPasses(stackWith("status == 'done'"), { runtimeWriteType: 'object' })); + expect(errors).toHaveLength(1); + }); + + it('the server-side gates stay off it, as for `visibleWhen`: no `parent` gate on an object with no master', () => { + // `parent` is bound by the family's root verdict; the `parent` GATE (an + // object without exactly one master_detail) belongs to the two write-path + // slots only, and this slot has no write path. + const issues = at(validateStackExpressions(stackWith("parent.status == 'paid'"))); + expect(issues.filter((i) => /master_detail/.test(i.message))).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 3edaa4b67ac..1f1aff8bd57 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -900,6 +900,14 @@ const FIELD_RULE_SLOT_CONSEQUENCE: Record = { // there is no fourth runtime to measure — the honest clause is the generic // one, not a fabricated fourth cell (#6716). conditionalRequired: FIELD_RULE_SLOT_CONSEQUENCE_GENERIC, + // [#22227] The deadline's settle predicate (objectui#11815 ruling D) joins + // the family on the `visibleWhen` side: display only, nothing on the write + // path reads it. Its one consumer — objectui's date cells, the host + // evaluating it per row — lands after the spec publishes, so there is no + // runtime here to measure a fault direction against. The generic clause is + // the honest cell until it does; re-measure and write the slot's own cell + // then, as #6716 did for the other three. + settledWhen: FIELD_RULE_SLOT_CONSEQUENCE_GENERIC, }; /** @@ -1630,6 +1638,7 @@ export interface StackExpressionOptions { * then faulted on every write the rule judged; * - [#22032, pass 2] the field-rule-slot pass over `fields[]` — each of * `requiredWhen` / `readonlyWhen` / `conditionalRequired` / `visibleWhen` + * (and, since #22227, a deadline's `settledWhen`) * as a `record`-scoped predicate with its root verdict, plus the `parent` * gate (a `readonlyWhen` / `requiredWhen` reading `parent` on an object * without exactly one `master_detail`), the #4811 null-guard gate over @@ -2513,7 +2522,15 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // Field-level conditional rules are server-enforced (rule-validator) and // record-scoped — a bare ref silently fails the rule (required/readonly // not enforced = data-integrity hole). #1928 class, same as actions. - for (const key of ['requiredWhen', 'readonlyWhen', 'conditionalRequired', 'visibleWhen'] as const) { + // + // [#22227] `settledWhen` — a `date` / `datetime` deadline's settle + // predicate (objectui#11815 ruling D) — is the fifth slot, judged exactly + // as `visibleWhen` is: the `record`-scoped `check` (it parses, it reads + // `record.` and never a bare field, the field exists) and the + // root verdict over FIELD_RULE_BOUND_ROOTS. Like `visibleWhen` it is + // display only, so the server-side gates further down (the `parent` + // gate, the null guard, the traversal refusal) do not apply to it. + for (const key of ['requiredWhen', 'readonlyWhen', 'conditionalRequired', 'visibleWhen', 'settledWhen'] as const) { const where = `object '${objectName}' · field '${fname}' ${key}`; const raw = (f as AnyRec)[key]; // [#13935] Verdict FIRST, emitted second. `check` needs to know which diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 176becb3f82..f725c03bdde 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -333,6 +333,7 @@ "data/Field:descriptionField", "data/Field:dimensions", "data/Field:displayField", + "data/Field:dueLike", "data/Field:expression", "data/Field:externalId", "data/Field:format", @@ -376,6 +377,7 @@ "data/Field:rows", "data/Field:scale", "data/Field:searchable", + "data/Field:settledWhen", "data/Field:sortable", "data/Field:step", "data/Field:storage", diff --git a/packages/spec/src/data/field-due-like.test.ts b/packages/spec/src/data/field-due-like.test.ts new file mode 100644 index 00000000000..039468fe615 --- /dev/null +++ b/packages/spec/src/data/field-due-like.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22227] The deadline semantic on the field definition — the protocol half + * of objectui#11815's maintainer ruling D (declared-only overdue: no name + * guessing, the deadline declares when it is settled). + * + * - **`dueLike`** declares a `date` / `datetime` field a deadline, so a + * renderer may show relative overdue wording and an overdue colour once it + * has passed. Absent means not a deadline. + * - **`settledWhen`** is a per-record CEL predicate in the `visibleWhen` + * family; while it holds the deadline is settled and no overdue affordance + * applies. + * + * Declared = enforced (ADR-0049) at the parse seam, three ways: either key on + * a type outside `date` / `datetime` is refused, and `settledWhen` without + * `dueLike: true` is refused. The CEL half of the authoring gate (parse, + * `record.`, the bound roots) is `@objectstack/lint`'s field-rule pass, + * pinned in `validate-expressions.test.ts`. + * + * The type set is HARDCODED below on purpose (not iterated off the + * module-local `DEADLINE_FIELD_TYPES`), so these pins are an independent + * measurement of the set rather than a tautology — the #11949 discipline. + */ + +import { describe, it, expect } from 'vitest'; + +import { FieldSchema } from './field.zod'; +import { fieldForm } from './field.form'; + +const DEADLINE_TYPES = ['date', 'datetime'] as const; + +// ========================================================================= +// 1. The accepted shapes — full parse green, the authored values kept +// ========================================================================= + +describe('FieldSchema accepts the deadline semantic on date and datetime', () => { + for (const type of DEADLINE_TYPES) { + it(`accepts dueLike + settledWhen on type: '${type}', keeping both through parse`, () => { + const result = FieldSchema.safeParse({ + name: 'due_date', label: 'Due Date', type, + dueLike: true, settledWhen: "record.status == 'done'", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.dueLike).toBe(true); + // The bare string normalizes to the evaluated envelope, like every + // sibling in the family. + expect(result.data.settledWhen).toEqual({ dialect: 'cel', source: "record.status == 'done'" }); + } + }); + + it(`accepts dueLike alone on type: '${type}' — settledWhen is optional`, () => { + const result = FieldSchema.safeParse({ name: 'due_date', label: 'Due Date', type, dueLike: true }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.dueLike).toBe(true); + expect('settledWhen' in result.data).toBe(false); + } + }); + } + + it('control: a date field with neither key parses unchanged, and no default materializes', () => { + const result = FieldSchema.safeParse({ name: 'start_date', label: 'Start Date', type: 'date' }); + expect(result.success).toBe(true); + if (result.success) { + expect('dueLike' in result.data).toBe(false); + expect('settledWhen' in result.data).toBe(false); + } + }); + + it('accepts dueLike: false — an explicit "not a deadline" on a date field', () => { + const result = FieldSchema.safeParse({ name: 'start_date', label: 'Start Date', type: 'date', dueLike: false }); + expect(result.success).toBe(true); + }); +}); + +// ========================================================================= +// 2. The refusals — each at its own path, naming its own subject +// ========================================================================= + +const issuesAt = (input: Record, key: string) => { + const result = FieldSchema.safeParse(input); + expect(result.success).toBe(false); + return result.success ? [] : result.error.issues.filter((i) => i.path[0] === key); +}; + +describe('the deadline keys are refused off date and datetime', () => { + // One representative per family, plus `time` — the near miss: a time of day + // has no calendar day to fall behind — and `formula`, which may RETURN a date + // but is computed per read. + const wrongTypes = ['text', 'time', 'number', 'boolean', 'select', 'lookup', 'formula', 'autonumber'] as const; + for (const type of wrongTypes) { + it(`refuses dueLike on type: '${type}' with one custom issue at [dueLike]`, () => { + const issues = issuesAt({ name: 'f', label: 'F', type, dueLike: true }, 'dueLike'); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + // Named subjects: the legal set and the offending type. + expect(issues[0]!.message).toContain("'date', 'datetime'"); + expect(issues[0]!.message).toContain(`\`${type}\``); + }); + + it(`refuses settledWhen on type: '${type}' with ONE custom issue at [settledWhen] — the type refusal, not the missing-dueLike one`, () => { + const issues = issuesAt({ name: 'f', label: 'F', type, settledWhen: 'record.done == true' }, 'settledWhen'); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.message).toContain("'date', 'datetime'"); + expect(issues[0]!.message).toContain(`\`${type}\``); + // It must not send this author to add `dueLike: true`, which the type + // refusal above would then answer. + expect(issues[0]!.message).not.toContain('Add `dueLike: true`'); + }); + } + + it('refuses dueLike: false off-type too — an authored value on a type it describes nothing on', () => { + const issues = issuesAt({ name: 'f', label: 'F', type: 'text', dueLike: false }, 'dueLike'); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + }); +}); + +describe('settledWhen without dueLike: true is refused (ADR-0049: a declared key is enforced)', () => { + for (const type of DEADLINE_TYPES) { + it(`refuses settledWhen with dueLike absent on type: '${type}'`, () => { + const issues = issuesAt({ name: 'd', label: 'D', type, settledWhen: 'record.done == true' }, 'settledWhen'); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.message).toContain('`dueLike: true`'); + }); + + it(`refuses settledWhen with dueLike: false on type: '${type}'`, () => { + const issues = issuesAt( + { name: 'd', label: 'D', type, dueLike: false, settledWhen: 'record.done == true' }, + 'settledWhen', + ); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.message).toContain('`dueLike: true`'); + }); + } + + it('refuses a blank settledWhen with the evaluated-slot rule, as for every sibling', () => { + const issues = issuesAt({ name: 'd', label: 'D', type: 'date', dueLike: true, settledWhen: ' ' }, 'settledWhen'); + expect(issues.length).toBeGreaterThan(0); + expect(issues[0]!.code).toBe('invalid_union'); + }); + + it('refuses a non-CEL value shape (a boolean is not a predicate)', () => { + const issues = issuesAt({ name: 'd', label: 'D', type: 'date', dueLike: true, settledWhen: true }, 'settledWhen'); + expect(issues.length).toBeGreaterThan(0); + }); +}); + +// ========================================================================= +// 3. The offer side — the field form shows each key exactly where it parses +// ========================================================================= + +describe('field.form offers the deadline keys on exactly the types the door accepts', () => { + const rows = fieldForm.sections.flatMap((s) => s.fields ?? []) as Array<{ field?: string; visibleWhen?: unknown }>; + const rowFor = (key: string) => rows.find((r) => r.field === key); + // `defineForm` normalizes a bare predicate string to its `{ dialect, source }` + // envelope, so the gate is read off `source`. + const gateOf = (row: { visibleWhen?: unknown }) => { + const v = row.visibleWhen as string | { source?: string } | undefined; + return typeof v === 'string' ? v : String(v?.source); + }; + + it('offers dueLike, gated on date / datetime', () => { + const row = rowFor('dueLike'); + expect(row).toBeDefined(); + expect(gateOf(row!)).toContain("data.type in ['date','datetime']"); + }); + + it('offers settledWhen, gated on date / datetime AND a declared deadline', () => { + const row = rowFor('settledWhen'); + expect(row).toBeDefined(); + const gate = gateOf(row!); + expect(gate).toContain("data.type in ['date','datetime']"); + expect(gate).toContain('data.dueLike == true'); + }); +}); diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 33ef2d89147..05a3bc273a6 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -128,6 +128,15 @@ 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`.' }, + // #22227 (objectui#11815 ruling D) — the deadline semantic. Unlike the + // meaningfulness gates above, these two ARE parse gates: `FieldSchema` + // refuses both keys outside date / datetime (DEADLINE_FIELD_TYPES in + // field.zod.ts) and `settledWhen` without `dueLike: true`, so each row + // shows exactly where its key parses. `settledWhen` copies the + // field-level predicate rows' face below (`type: 'code'`, + // `language: 'expression'`) — it is the same CEL family. + { field: 'dueLike', visibleWhen: "data.type in ['date','datetime']", helpText: "Marks this date as a deadline: once it has passed, views may show it as overdue (relative wording and an overdue colour). Off or unset: not a deadline, and nothing is guessed from the field's name." }, + { field: 'settledWhen', type: 'code', language: 'expression', visibleWhen: "data.type in ['date','datetime'] && data.dueLike == true", helpText: "CEL predicate over the record (e.g. record.status == 'done') — while it is TRUE the deadline is settled, and no overdue wording or colour applies to that record. Display only." }, // Select field options { field: 'options', From 5a97688ef1647c910972105690f2923c822ea384 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:09:10 +0000 Subject: [PATCH 03/11] chore(spec,metadata-core,qa): classify dueLike and settledWhen in the liveness, FLS and expression ledgers (wip) Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../src/object-schema-fls-references.ts | 7 +++++++ .../dogfood/test/expression-conformance.ledger.ts | 14 ++++++++++++++ packages/spec/liveness/field.json | 14 ++++++++++++++ packages/spec/liveness/state-counts/field.md | 2 +- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index 77ccfd4f1ad..e7828b6c228 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -591,6 +591,9 @@ export const FIELD_REFERENCE_POSITIONS: Readonly> = { requiredPermissions: keep, maskingRule: keep, ackPlaintextMasking: keep, system: keep, sortable: keep, inlineHelpText: keep, placeholder: keep, externalId: keep, conditionalRequired: keep, + // A boolean facet of this date field: it declares the field a deadline + // (#22227) and names no field. + dueLike: keep, // Names of ANOTHER object's fields — that object's projection governs them. // `displayField` / `descriptionField` / `lookupColumns` / `lookupFilters` // name fields of the `reference` target (the lookup picker reads them); @@ -610,6 +613,10 @@ export const FIELD_REFERENCE_POSITIONS: Readonly> = { visibleWhen: expression, readonlyWhen: expression, requiredWhen: expression, + // The deadline's settle predicate (#22227), a `visibleWhen`-family CEL over + // this object's record. Deleted when it mentions a denied field: the caller + // could not evaluate it, so their view keeps the overdue affordance. + settledWhen: expression, relatedListFilter: fieldKeyed, defaultValue: expression, autonumberFormat: expression, diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index ae4d49fb45f..37057a5301c 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -200,6 +200,20 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 'data/field.zod.ts:FieldSchema.visibleWhen', ], }, + { + // #22227 — the deadline's settle predicate (objectui#11815 ruling D). A row + // of its own rather than a fourth `cel-field-rule` cover: that row is + // `enforced` and names the evaluators of its three slots, and neither reads + // this one. The CEL is judged at authoring as the family's is; what is + // missing is the reader, so the honest tier is `unevaluated`. + id: 'cel-field-deadline-settled', + summary: "a date / datetime deadline's settle predicate (Field.settledWhen) — display only, declared ahead of its renderer", + dialect: 'cel', mode: 'interpret', state: 'experimental', failPolicy: 'unevaluated', + enforcement: + 'NO EVALUATOR FOUND — no runtime consumer yet, in either repo: nothing in this checkout reads the slot, and objectui @55e90fd has no `settledWhen` read at all (measured by grep). PARSE ONLY at the spec seam: `FieldSchema` refuses it off `date` / `datetime` and without `dueLike: true`. Its build-time reader is lint\'s field-rule pass (`validate-expressions.ts`), which judges the CEL as it judges `visibleWhen` (it parses, `record.` only, the family\'s bound roots) at `os build`, `os validate` and the object save door — a reader of the source, not an evaluator of it', + covers: ['data/field.zod.ts:FieldSchema.settledWhen'], + note: 'EXPERIMENTAL until its one reader lands: objectui#11815 (Blocked-by #22227) evaluates the predicate per row in the host and hands the date cell one boolean. Re-state as `enforced`, naming that evaluator and its fail policy, when it lands. Display only by declaration: nothing on the write path will read it, so this row never carries a write-path refusal.', + }, { // Split out of `cel-field-rule` by #15500. Both keys used to be spelled // `data/field.zod.ts:visibleWhen` and were therefore ONE ratchet key, so diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 012fb2453ce..c59681be231 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -101,6 +101,20 @@ "verifiedAt": "2026-08-29", "note": "REMOVED 2026-07-28 in protocol 17 (#3855, PR #3883 — the commit that retired the last three deprecated authorable aliases) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and renamed out of sources by the protocol-17 conversion `field-conditionalRequired-to-requiredWhen`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); use `requiredWhen`, the only predicate slot — rename the key, the value (a CEL predicate) is unchanged, and `os migrate meta --from 16` lists the mechanical edits. The tombstone is packages/spec/src/data/field.zod.ts#conditionalRequired. The consumer-side refusal is packages/objectql/src/validation/rule-validator.ts#ConditionalFieldDef, which declares `requiredWhen` THE ONLY predicate slot and states PD #12 — no consumer-side dialect fallback returns here; that absence is PINNED by the case named `does NOT read the retired conditionalRequired alias (PD #12 — no dialect fallback)` in packages/objectql/src/validation/rule-validator.test.ts, which sits beside a positive case asserting the rule DOES fire once the stored-conversion chain has lowered the alias. LEDGER CORRECTED 2026-08-29 (commit e25e8397a) — the VERDICT was falsified, not the citation, and in BOTH halves of its evidence. (a) This row read `live` on the claim that the FieldSchema .transform lowers conditionalRequired -> requiredWhen and DROPS the alias. No such transform exists: field.zod.ts contains ZERO .transform calls, its only two occurrences of that token are comments recording that `.overwrite()` was chosen over `.transform()` per the measured #6926 precedent, and the describe() on `requiredWhen` says out loud that the alias was removed in protocol 17. (b) The note's own stated reason for the verdict — that objectql rule-validator.ts keeps its `requiredWhen ?? conditionalRequired` fallback on purpose because raw (unparsed) metadata still reaches evaluateValidationRules — was retired by #3903: every path that hands the validator a STORED field definition now replays the ADR-0087 conversion chain at rehydration (applyConversionsToStoredItem), so a pre-17 row arrives with the alias already lowered, and a caller handing raw unconverted legacy input is off-contract. WHY NOTHING SAW THE ROT: the old pointer packages/spec/src/data/field.zod.ts:639 was IN RANGE in a 2,132-line file that names the key five times, so the existence check, the line bound and the key-mention check were all green on it — what :639 holds today is the tail of FieldMaskingRuleSchema, unrelated in every respect — and the entry carried no verifiedAt, so the re-verification clock never asked (the #12516 class). It was also the LAST `path:NNN` citation left in any ledger, which is why retiring it here closes the anchor-adoption worklist. HISTORY PRESERVED, because it is the argument for `requiredWhen` being the single slot: #3754 folded the alias at parse time and removed it from the parsed output (same shape as action.execute -> target in #3742, agent.knowledge.topics -> sources in #1891); before that fix BOTH keys stayed live in the output with no canonicalization, so each consumer re-derived the precedence — the condition that produced the #3713 divergence. That note called removal of the input key the endgame, and protocol 17 is where the endgame landed. RETIREMENT CHAIN, COMPLETE — the close-out machinery is what remains, and it is not a consumer: the ADR-0087 conversion `field-conditionalRequired-to-requiredWhen` in packages/spec/src/conversions/registry.ts, its `RETIRED_KEYS_BY_MAJOR` entry and D3 chain step in packages/spec/src/migrations/registry.ts, and the explicit `NOTE: no entry for conditionalRequired` in packages/spec/src/data/authoring-key-lint.ts. Two INERT survivors are recorded so the next reader does not mistake either for a reader that gives the key meaning: packages/lint/src/validate-expressions.ts still walks the key, and its own docblock calls that branch inert on the parsed compile path because strict FieldSchema rejects the key by name (it gets a slot-agnostic consequence clause rather than a fabricated fourth measurement); packages/drivers/driver-sql/src/builtin-column-collision.ts lists it in a key-classification map as `presentation`, which classifies a name, not a value anyone reads. Re-closed by hand against cae2169cf." }, + "dueLike": { + "status": "live", + "verifiedAt": "2026-10-09", + "evidenceScope": "cross-repo", + "evidence": "objectui @55e90fd: packages/fields/src/index.tsx#resolveDueLike (`declared === true` on the cell's field, read by both the date and the datetime cell renderer: the relative overdue wording and the overdue colour once the date has passed); objectui @55e90fd: packages/plugin-detail/src/fieldEnrichment.ts#ENRICHED_FIELD_METADATA_KEYS (copies the object-schema field's `dueLike` onto the detail field, so the record detail sections hand it to that cell). The same two reads are present at the `.objectui-sha` pin f0268ad78", + "note": "Deadline semantic, `date` / `datetime` only (DEADLINE_FIELD_TYPES; the superRefine refuses it on every other type). Declared by #22227 as the protocol half of objectui#11815's maintainer ruling D (declared-only overdue). LIVE on the detail surfaces today: an authored TRUE paints the overdue affordance there even where the field name matches no pattern. PARTIAL until objectui#11815 (Blocked-by #22227) lands: objectui's grid copy set does not carry the key yet, and objectui still ALSO infers due-likeness from the field name (`DUE_LIKE_FIELD_NAME`), which that card retires — after it, absent means not a deadline everywhere, as the key's describe states. Display only: nothing on the write path reads it." + }, + "settledWhen": { + "status": "planned", + "verifiedAt": "2026-10-09", + "evidenceScope": "cross-repo", + "evidence": "packages/spec/src/data/field.zod.ts#DEADLINE_FIELD_TYPES (the parse-time door: refused off `date` / `datetime`, and refused without `dueLike: true`); packages/lint/src/validate-expressions.ts#runStackExpressionPasses (the authoring gate: the field-rule pass judges it as it judges `visibleWhen` — it parses, it reads `record.`, only the family's bound roots — at `os build` / `os validate` and the object save door)", + "note": "PLANNED — declared ahead of its one reader by the ruling's own sequencing (#22227, the protocol half of objectui#11815's maintainer ruling D): the host evaluates the predicate per row and hands the date cell one boolean, and that read is objectui#11815, Blocked-by #22227, which lands once the spec publishes. No reader in either repo today — objectui @55e90fd has no `settledWhen` read at all — so until then a settled deadline still paints overdue exactly as it does without the key; nothing is stored or enforced differently. No `authorWarn`: the gap is a display refinement with a named, blocked consumer card, and the authoring half (parse, scope, the `dueLike` pairing) is already enforced. Flip to `live` on the objectui#11815 landing, citing its host helper." + }, "readonly": { "status": "live", "evidence": "packages/objectql/src/validation/rule-validator.ts (stripReadonlyFields — the one strip, UPDATE and INSERT); packages/objectql/src/engine.ts (INSERT call site, isSystem-gated, since the 2026-09-03 ruling)", diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 4beaad39889..9cc48455a2a 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` | 93 | 0 | 0 | 1 | 0 | 94 | +| `field` | 94 | 0 | 0 | 1 | 1 | 96 | From d50aea0efa965bb75249707a702c7dea3ecfee5e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:10:55 +0000 Subject: [PATCH 04/11] feat(examples,docs): declare the deadline on the three example objects; teach dueLike and settledWhen; changeset (wip) Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .changeset/22227-field-deadline-due-like.md | 15 +++++++ content/docs/data-modeling/fields.mdx | 39 +++++++++++++++++++ .../app-crm/src/objects/activity.object.ts | 5 +++ .../src/data/objects/task.object.ts | 6 ++- examples/app-todo/src/objects/task.object.ts | 6 +++ .../src/validate-predicate-path-refs.test.ts | 13 ++++++- 6 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 .changeset/22227-field-deadline-due-like.md diff --git a/.changeset/22227-field-deadline-due-like.md b/.changeset/22227-field-deadline-due-like.md new file mode 100644 index 00000000000..9eb8c6718c6 --- /dev/null +++ b/.changeset/22227-field-deadline-due-like.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +A `date` or `datetime` field can declare that it is a deadline, and when that deadline is settled: `dueLike` and `settledWhen` + +Clause-②: yes + +- **`dueLike: boolean`** (optional, no default) declares the date a deadline. A renderer may then show relative overdue wording and an overdue colour once the date has passed. Absent means not a deadline: nothing is inferred from the field's name. +- **`settledWhen`** (optional) is a per-record CEL predicate in the `visibleWhen` / `readonlyWhen` / `requiredWhen` family. While it holds, the deadline is settled and no overdue wording or colour applies to that record, for example `record.status == 'done'`. +- **The doors that judge them.** `FieldSchema` refuses either key on any type other than `date` / `datetime`, and refuses `settledWhen` unless the field also declares `dueLike: true`. The CEL is judged by `@objectstack/lint`'s field-rule pass, the one `visibleWhen` goes through, at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no root but `record` (plus `previous`, and `parent` on a master-detail line item). +- **Display only.** Nothing on the write path reads either key. Every metadata that parsed before parses unchanged; a field without the keys behaves exactly as before. +- **Who reads them.** objectui's date and datetime cells read `dueLike` on the record detail surfaces today. Reading `settledWhen`, and retiring the field-name guess, follows in objectui once this spec version is published. +- **Where to author them.** The field designer offers both on date and datetime fields, `settledWhen` only once the field is a deadline. The example apps declare them on the showcase and todo `task` and the CRM `activity`. diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx index c12d4260484..f653e7946d4 100644 --- a/content/docs/data-modeling/fields.mdx +++ b/content/docs/data-modeling/fields.mdx @@ -99,6 +99,44 @@ start_date: Field.date({ label: 'Start Date', required: true }), created_at: Field.datetime({ label: 'Created At', readonly: true }), ``` +#### Deadlines: `dueLike` and `settledWhen` + +A date is a **deadline** only when its field says so. `dueLike: true` on a +`date` or `datetime` field declares one: once the date has passed, a renderer +may show it as overdue, with relative wording ("Overdue 3d") and an overdue +colour. Without the key the field is not a deadline, and nothing is inferred +from its name — a `due_date` that is not declared is just a date. + +A deadline usually stops mattering when the record is finished, and only the +object knows what finished means. `settledWhen` says it: a CEL predicate over +the record, in the same family as `visibleWhen`, and while it is `TRUE` no +overdue wording or colour applies to that record. + +```typescript +import { P } from '@objectstack/spec'; +import { Field } from '@objectstack/spec/data'; + +due_date: Field.date({ + label: 'Due Date', + dueLike: true, + settledWhen: P`record.status == 'done'`, +}), +``` + +Both keys are judged when the metadata is authored, not when a view renders it: + +- Either key on any other field type is refused, and so is `settledWhen` + without `dueLike: true` — a settle condition on a date that is no deadline + would change nothing. +- `settledWhen` is checked exactly like `visibleWhen`: it must parse, read + fields as `record.`, name fields the object declares, and read no root + but `record` (plus `previous`, and `parent` on a master-detail line item). + `objectstack validate`, `objectstack build` and saving the object all refuse + anything else, naming the field. + +Both are display semantics: nothing on the write path reads them, and a past +deadline never blocks a save. + ### Boolean Type ```typescript @@ -352,6 +390,7 @@ For values that must be encrypted at rest (API keys, tokens, DB passwords), use | `visibleWhen` | `string \| Expression` | CEL predicate; field is shown only when `TRUE` | | `readonlyWhen` | `string \| Expression` | CEL predicate; field is read-only when `TRUE` | | `requiredWhen` | `string \| Expression` | CEL predicate; field is required when `TRUE` | +| `settledWhen` | `string \| Expression` | CEL predicate on a `dueLike` date; the deadline is settled when `TRUE` — see [Deadlines](#deadlines-duelike-and-settledwhen) | | `multiple` | `boolean` | Allow multiple values (for select, lookup, file) | ```typescript diff --git a/examples/app-crm/src/objects/activity.object.ts b/examples/app-crm/src/objects/activity.object.ts index a2f940fb73d..3baf27b150d 100644 --- a/examples/app-crm/src/objects/activity.object.ts +++ b/examples/app-crm/src/objects/activity.object.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { P } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; export const Activity = ObjectSchema.create({ @@ -41,8 +42,12 @@ export const Activity = ObjectSchema.create({ { label: 'Cancelled', value: 'cancelled', color: '#EF4444' }, ], }), + // A deadline, declared (objectui#11815 ruling D): past it the activity + // reads as overdue until it is completed. due_date: Field.datetime({ label: 'Due Date / Time', + dueLike: true, + settledWhen: P`record.status == 'completed'`, }), contact: Field.lookup('crm_contact', { label: 'Contact', diff --git a/examples/app-showcase/src/data/objects/task.object.ts b/examples/app-showcase/src/data/objects/task.object.ts index e8f8b788029..760dfedcbe5 100644 --- a/examples/app-showcase/src/data/objects/task.object.ts +++ b/examples/app-showcase/src/data/objects/task.object.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { P } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; /** @@ -68,7 +69,10 @@ export const Task = ObjectSchema.create({ estimate_hours: Field.number({ label: 'Estimate (h)', min: 0, max: 1000 }), progress: { type: 'progress', label: 'Progress', min: 0, max: 100, defaultValue: 0 }, done: Field.boolean({ label: 'Done', defaultValue: false }), - due_date: Field.date({ label: 'Due Date' }), + // A deadline, declared (objectui#11815 ruling D): past it the date reads + // as overdue, until the task is done — `status` is the completion signal + // the board, the state machine and the milestone below all key on. + due_date: Field.date({ label: 'Due Date', dueLike: true, settledWhen: P`record.status == 'done'` }), start_date: Field.date({ label: 'Start Date' }), end_date: Field.date({ label: 'End Date' }), created_at: Field.datetime({ label: 'Created At' }), diff --git a/examples/app-todo/src/objects/task.object.ts b/examples/app-todo/src/objects/task.object.ts index bae175811d2..74dabd3f9ad 100644 --- a/examples/app-todo/src/objects/task.object.ts +++ b/examples/app-todo/src/objects/task.object.ts @@ -64,8 +64,14 @@ export const Task = ObjectSchema.create({ }), // Dates + // A deadline, declared (objectui#11815 ruling D): past it the date reads as + // overdue until the task is completed — the app's own overdue definition + // below (`due_date` past AND `status` not 'completed'), stated once on the + // field so every view that shows the date agrees with it. due_date: Field.date({ label: 'Due Date', + dueLike: true, + settledWhen: P`record.status == 'completed'`, }), reminder_date: Field.datetime({ diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 82c9798b58b..2723368fc0d 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -638,7 +638,18 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // `action :: outcomeMessages` and `action :: onSuccess`, both // `data.type == 'api' || data.type == 'script'`, and removes none. The two // `onSuccess` sub-rows carry no predicate. - expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(85); + // It is 87 today, an ADDITION of TWO: #22227 offered the field form's + // `dueLike` and `settledWhen` rows, the deadline semantic. These two are + // PARSE gates, not meaningfulness gates: `FieldSchema` refuses both keys off + // `date` / `datetime`, and `settledWhen` without `dueLike: true`. Measured, + // not inferred: the shipped `*.form.ts` corpus differenced against the + // merge base `dee7692f0b` by `
::::`, 85 → 87, adds + // exactly `field :: dueLike :: data.type in ['date','datetime']` and + // `field :: settledWhen :: data.type in ['date','datetime'] && data.dueLike + // == true`, and removes none. Neither reaches the literal-comparison census + // below: one is an `in`-list gate, and the other compares against the bare + // `true`, which is not a quoted literal. + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(87); const findings = validatePredicatePathRefs(corrupted); expect(findings).toHaveLength(predicates); From bd5da7ec03721b89363ca0c3b082941cf6372b15 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:21:56 +0000 Subject: [PATCH 05/11] chore(platform-objects): regenerate the metadata-form catalogs for the two deadline rows, generator output unedited (wip) Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../src/apps/translations/en.metadata-forms.generated.ts | 8 ++++++++ .../apps/translations/es-ES.metadata-forms.generated.ts | 8 ++++++++ .../apps/translations/es-ES.source-hashes.generated.ts | 4 ++++ .../apps/translations/ja-JP.metadata-forms.generated.ts | 8 ++++++++ .../apps/translations/ja-JP.source-hashes.generated.ts | 4 ++++ .../apps/translations/zh-CN.metadata-forms.generated.ts | 8 ++++++++ .../apps/translations/zh-CN.source-hashes.generated.ts | 4 ++++ packages/qa/dogfood/test/expression-conformance.ledger.ts | 3 ++- packages/spec/src/data/field.form.ts | 4 ++-- 9 files changed, 48 insertions(+), 3 deletions(-) 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 43b766265be..62e85d6d852 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 @@ -628,6 +628,14 @@ 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`." }, + dueLike: { + label: "Deadline", + helpText: "Marks this date as a deadline: once it has passed, views may show it as overdue (relative wording and an overdue colour). Off or unset: not a deadline, and nothing is guessed from the field's name." + }, + settledWhen: { + label: "Settled When", + helpText: "CEL predicate over the record (e.g. record.status == 'done') — while it is TRUE the deadline is settled, and no overdue wording or colour applies to that record. Display only." + }, options: { label: "Options", helpText: "Available options (label/value pairs)" 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 cf5cec815a4..ced15e0aee3 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 @@ -628,6 +628,14 @@ 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`." }, + dueLike: { + label: "Deadline", + helpText: "Marks this date as a deadline: once it has passed, views may show it as overdue (relative wording and an overdue colour). Off or unset: not a deadline, and nothing is guessed from the field's name." + }, + settledWhen: { + label: "Settled When", + helpText: "CEL predicate over the record (e.g. record.status == 'done') — while it is TRUE the deadline is settled, and no overdue wording or colour applies to that record. Display only." + }, options: { label: "Opciones", helpText: "Opciones disponibles (pares label/value)" diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index 73f2c7be6b3..13db8685e0c 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -18,6 +18,10 @@ */ export const esESGeneratedSourceHashes: Readonly> = { + "metadataForms.field.fields.dueLike.helpText": "cf1359aa166decfa", + "metadataForms.field.fields.dueLike.label": "a1e407ff686c2857", + "metadataForms.field.fields.settledWhen.helpText": "9356ce9d907a7f90", + "metadataForms.field.fields.settledWhen.label": "09cd9b3560d7756e", "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.error.label": "786fed84bd8d5a32", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", 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 5d3ae3db800..842ae4c12eb 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 @@ -628,6 +628,14 @@ export const jaJPMetadataForms: NonNullable = label: "ポリモーフィック参照の相棒項目", helpText: "このテキスト項目をポリモーフィック参照の id 側にします。ここには、対象オブジェクト名を行ごとに保持する同一オブジェクト上の別項目名を指定します(ADR-0052 §5)。snake_case で記述し、テキスト項目のみ、`reference` とは併用できません。" }, + dueLike: { + label: "Deadline", + helpText: "Marks this date as a deadline: once it has passed, views may show it as overdue (relative wording and an overdue colour). Off or unset: not a deadline, and nothing is guessed from the field's name." + }, + settledWhen: { + label: "Settled When", + helpText: "CEL predicate over the record (e.g. record.status == 'done') — while it is TRUE the deadline is settled, and no overdue wording or colour applies to that record. Display only." + }, options: { label: "選択肢", helpText: "使用可能な選択肢(label/value ペア)" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index e1553f4ae95..f772924870d 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -18,6 +18,10 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { + "metadataForms.field.fields.dueLike.helpText": "cf1359aa166decfa", + "metadataForms.field.fields.dueLike.label": "a1e407ff686c2857", + "metadataForms.field.fields.settledWhen.helpText": "9356ce9d907a7f90", + "metadataForms.field.fields.settledWhen.label": "09cd9b3560d7756e", "objects.sys_email.fields.bcc_addresses.label": "8674899b7b6d5126", "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", 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 b3974c3ba3e..13f950dfe3d 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 @@ -628,6 +628,14 @@ export const zhCNMetadataForms: NonNullable = label: "多态指针伴随字段", helpText: "把该文本字段声明为多态指针的 id 半边:此处填写同一对象上保存目标对象名的另一个字段,目标逐行决定(ADR-0052 §5)。使用 snake_case;仅限文本字段,且与 `reference` 互斥。" }, + dueLike: { + label: "Deadline", + helpText: "Marks this date as a deadline: once it has passed, views may show it as overdue (relative wording and an overdue colour). Off or unset: not a deadline, and nothing is guessed from the field's name." + }, + settledWhen: { + label: "Settled When", + helpText: "CEL predicate over the record (e.g. record.status == 'done') — while it is TRUE the deadline is settled, and no overdue wording or colour applies to that record. Display only." + }, options: { label: "选项", helpText: "可选项(label/value 对)" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 01317fb3fb6..85b89976a8a 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -18,6 +18,10 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { + "metadataForms.field.fields.dueLike.helpText": "cf1359aa166decfa", + "metadataForms.field.fields.dueLike.label": "a1e407ff686c2857", + "metadataForms.field.fields.settledWhen.helpText": "9356ce9d907a7f90", + "metadataForms.field.fields.settledWhen.label": "09cd9b3560d7756e", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 37057a5301c..6f8d9a1d443 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -212,7 +212,8 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ enforcement: 'NO EVALUATOR FOUND — no runtime consumer yet, in either repo: nothing in this checkout reads the slot, and objectui @55e90fd has no `settledWhen` read at all (measured by grep). PARSE ONLY at the spec seam: `FieldSchema` refuses it off `date` / `datetime` and without `dueLike: true`. Its build-time reader is lint\'s field-rule pass (`validate-expressions.ts`), which judges the CEL as it judges `visibleWhen` (it parses, `record.` only, the family\'s bound roots) at `os build`, `os validate` and the object save door — a reader of the source, not an evaluator of it', covers: ['data/field.zod.ts:FieldSchema.settledWhen'], - note: 'EXPERIMENTAL until its one reader lands: objectui#11815 (Blocked-by #22227) evaluates the predicate per row in the host and hands the date cell one boolean. Re-state as `enforced`, naming that evaluator and its fail policy, when it lands. Display only by declaration: nothing on the write path will read it, so this row never carries a write-path refusal.', + // The reader is objectui#11815, Blocked-by #22227. + note: 'EXPERIMENTAL until its one reader lands: objectui\'s date cell, whose host evaluates the predicate per row and hands the cell one boolean. Re-state as `enforced`, naming that evaluator and its fail policy, when it lands. Display only by declaration: nothing on the write path will read it, so this row never carries a write-path refusal.', }, { // Split out of `cel-field-rule` by #15500. Both keys used to be spelled diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 05a3bc273a6..b719b0797ef 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -135,8 +135,8 @@ export const fieldForm = defineForm({ // shows exactly where its key parses. `settledWhen` copies the // field-level predicate rows' face below (`type: 'code'`, // `language: 'expression'`) — it is the same CEL family. - { field: 'dueLike', visibleWhen: "data.type in ['date','datetime']", helpText: "Marks this date as a deadline: once it has passed, views may show it as overdue (relative wording and an overdue colour). Off or unset: not a deadline, and nothing is guessed from the field's name." }, - { field: 'settledWhen', type: 'code', language: 'expression', visibleWhen: "data.type in ['date','datetime'] && data.dueLike == true", helpText: "CEL predicate over the record (e.g. record.status == 'done') — while it is TRUE the deadline is settled, and no overdue wording or colour applies to that record. Display only." }, + { field: 'dueLike', label: 'Deadline', visibleWhen: "data.type in ['date','datetime']", helpText: "Marks this date as a deadline: once it has passed, views may show it as overdue (relative wording and an overdue colour). Off or unset: not a deadline, and nothing is guessed from the field's name." }, + { field: 'settledWhen', label: 'Settled When', type: 'code', language: 'expression', visibleWhen: "data.type in ['date','datetime'] && data.dueLike == true", helpText: "CEL predicate over the record (e.g. record.status == 'done') — while it is TRUE the deadline is settled, and no overdue wording or colour applies to that record. Display only." }, // Select field options { field: 'options', From 6e59d0db2553020639c2e28f23a4ebd8fb5297ea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:22:14 +0000 Subject: [PATCH 06/11] i18n(platform-objects): author the two deadline rows in zh-CN, ja-JP and es-ES (wip) Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../apps/translations/es-ES.metadata-forms.generated.ts | 8 ++++---- .../apps/translations/es-ES.source-hashes.generated.ts | 4 ---- .../apps/translations/ja-JP.metadata-forms.generated.ts | 8 ++++---- .../apps/translations/ja-JP.source-hashes.generated.ts | 4 ---- .../apps/translations/zh-CN.metadata-forms.generated.ts | 8 ++++---- .../apps/translations/zh-CN.source-hashes.generated.ts | 4 ---- 6 files changed, 12 insertions(+), 24 deletions(-) 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 ced15e0aee3..a0c5d13c7ad 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 @@ -629,12 +629,12 @@ export const esESMetadataForms: NonNullable = 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`." }, dueLike: { - label: "Deadline", - helpText: "Marks this date as a deadline: once it has passed, views may show it as overdue (relative wording and an overdue colour). Off or unset: not a deadline, and nothing is guessed from the field's name." + label: "Fecha límite", + helpText: "Marca esta fecha como fecha límite: una vez pasada, las vistas pueden mostrarla como vencida (texto relativo y un color de vencimiento). Desactivado o sin definir: no es una fecha límite, y no se deduce nada del nombre del campo." }, settledWhen: { - label: "Settled When", - helpText: "CEL predicate over the record (e.g. record.status == 'done') — while it is TRUE the deadline is settled, and no overdue wording or colour applies to that record. Display only." + label: "Condición de resolución", + helpText: "Predicado CEL sobre el registro (p. ej., record.status == 'done'): mientras sea TRUE, la fecha límite se considera resuelta y no se aplica a ese registro ningún texto ni color de vencimiento. Solo de visualización." }, options: { label: "Opciones", diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index 13db8685e0c..73f2c7be6b3 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -18,10 +18,6 @@ */ export const esESGeneratedSourceHashes: Readonly> = { - "metadataForms.field.fields.dueLike.helpText": "cf1359aa166decfa", - "metadataForms.field.fields.dueLike.label": "a1e407ff686c2857", - "metadataForms.field.fields.settledWhen.helpText": "9356ce9d907a7f90", - "metadataForms.field.fields.settledWhen.label": "09cd9b3560d7756e", "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.error.label": "786fed84bd8d5a32", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", 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 842ae4c12eb..e97480f186c 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 @@ -629,12 +629,12 @@ export const jaJPMetadataForms: NonNullable = helpText: "このテキスト項目をポリモーフィック参照の id 側にします。ここには、対象オブジェクト名を行ごとに保持する同一オブジェクト上の別項目名を指定します(ADR-0052 §5)。snake_case で記述し、テキスト項目のみ、`reference` とは併用できません。" }, dueLike: { - label: "Deadline", - helpText: "Marks this date as a deadline: once it has passed, views may show it as overdue (relative wording and an overdue colour). Off or unset: not a deadline, and nothing is guessed from the field's name." + label: "期限", + helpText: "この日付を期限としてマークします。日付を過ぎると、ビューで期限切れとして表示されることがあります(相対的な表現と期限切れの色)。オフまたは未設定の場合は期限ではなく、項目名から推測されることもありません。" }, settledWhen: { - label: "Settled When", - helpText: "CEL predicate over the record (e.g. record.status == 'done') — while it is TRUE the deadline is settled, and no overdue wording or colour applies to that record. Display only." + label: "期限解消条件", + helpText: "レコードに対する CEL 述語(例:record.status == 'done')。TRUE の間は期限が解消済みとなり、そのレコードには期限切れの表現や色が適用されません。表示専用です。" }, options: { label: "選択肢", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index f772924870d..e1553f4ae95 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -18,10 +18,6 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { - "metadataForms.field.fields.dueLike.helpText": "cf1359aa166decfa", - "metadataForms.field.fields.dueLike.label": "a1e407ff686c2857", - "metadataForms.field.fields.settledWhen.helpText": "9356ce9d907a7f90", - "metadataForms.field.fields.settledWhen.label": "09cd9b3560d7756e", "objects.sys_email.fields.bcc_addresses.label": "8674899b7b6d5126", "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", 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 13f950dfe3d..388e8d33a57 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 @@ -629,12 +629,12 @@ export const zhCNMetadataForms: NonNullable = helpText: "把该文本字段声明为多态指针的 id 半边:此处填写同一对象上保存目标对象名的另一个字段,目标逐行决定(ADR-0052 §5)。使用 snake_case;仅限文本字段,且与 `reference` 互斥。" }, dueLike: { - label: "Deadline", - helpText: "Marks this date as a deadline: once it has passed, views may show it as overdue (relative wording and an overdue colour). Off or unset: not a deadline, and nothing is guessed from the field's name." + label: "截止日期", + helpText: "将此日期标记为截止日期:过了这个日期后,视图可将其显示为逾期(相对时间措辞与逾期颜色)。关闭或未设置:不是截止日期,也不会根据字段名推测。" }, settledWhen: { - label: "Settled When", - helpText: "CEL predicate over the record (e.g. record.status == 'done') — while it is TRUE the deadline is settled, and no overdue wording or colour applies to that record. Display only." + label: "了结条件", + helpText: "基于记录的 CEL 谓词(例如 record.status == 'done')——为 TRUE 时该截止日期视为已了结,该记录不再显示逾期措辞或颜色。仅用于显示。" }, options: { label: "选项", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 85b89976a8a..01317fb3fb6 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -18,10 +18,6 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { - "metadataForms.field.fields.dueLike.helpText": "cf1359aa166decfa", - "metadataForms.field.fields.dueLike.label": "a1e407ff686c2857", - "metadataForms.field.fields.settledWhen.helpText": "9356ce9d907a7f90", - "metadataForms.field.fields.settledWhen.label": "09cd9b3560d7756e", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", From 832743b836173ece678fb0352f97ac4b91c35713 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:32:15 +0000 Subject: [PATCH 07/11] docs(spec): regenerate the field reference pages for dueLike and settledWhen; type the form-row read in the pin (wip) Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- content/docs/references/data/field.mdx | 2 ++ content/docs/references/data/object.mdx | 4 ++++ content/docs/references/system/migration.mdx | 4 ++++ packages/spec/src/data/field-due-like.test.ts | 2 +- 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index a422149caf1..ee24eb2fada 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -107,6 +107,8 @@ const result = CurrencyConfigSchema.parse(data); | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | +| **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 5ee0bcffd7d..90a48c66826 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -272,6 +272,8 @@ const result = ApiMethod.parse(data); | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | +| **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | @@ -607,6 +609,8 @@ const result = ApiMethod.parse(data); | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | +| **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index f9e83c7062d..5a599ebcab5 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -108,6 +108,8 @@ Add a new field to an existing object | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | +| **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | @@ -531,6 +533,8 @@ Add a new field to an existing object | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | +| **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | | **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | diff --git a/packages/spec/src/data/field-due-like.test.ts b/packages/spec/src/data/field-due-like.test.ts index 039468fe615..5635c6590b8 100644 --- a/packages/spec/src/data/field-due-like.test.ts +++ b/packages/spec/src/data/field-due-like.test.ts @@ -156,7 +156,7 @@ describe('settledWhen without dueLike: true is refused (ADR-0049: a declared key // ========================================================================= describe('field.form offers the deadline keys on exactly the types the door accepts', () => { - const rows = fieldForm.sections.flatMap((s) => s.fields ?? []) as Array<{ field?: string; visibleWhen?: unknown }>; + const rows = (fieldForm.sections ?? []).flatMap((s) => s.fields ?? []) as Array<{ field?: string; visibleWhen?: unknown }>; const rowFor = (key: string) => rows.find((r) => r.field === key); // `defineForm` normalizes a bare predicate string to its `{ dialect, source }` // envelope, so the gate is read off `source`. From a520b4eea5bc833531f48293ea6d40de046c6481 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:51:11 +0000 Subject: [PATCH 08/11] test(platform-objects): the translated-label positive control moves 665 to 667 for the two deadline rows (wip) Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../object-lifecycle-panel-echo-decisions.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 63748a94e4c..28f2644987c 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 @@ -1174,7 +1174,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 665 since the action form offers `outcomeMessages` and the `onSuccess` // composite (with its `navigate` and `openIn` sub-rows) beside // `successMessage` — four new row labels, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(665); + // 667 since the field form offers the deadline pair on date and datetime + // fields, `dueLike` and `settledWhen` — two new row labels, authored in + // all three locales. + expect(translated.length, `${locale} positive control`).toBe(667); } // ⭐ 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 From c064f4b3355a712bc63a900ae001db3b5209d8a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:53:37 +0000 Subject: [PATCH 09/11] fix(driver-sql,lint): classify dueLike and settledWhen in FIELD_KEY_STORAGE_CLASS; settledWhen joins the docs gate's field-rule slots and the consumer census's behaviour segments FIELD_KEY_STORAGE_CLASS is pinned against Object.keys(FieldSchema.shape) (builtin-column-collision.test.ts): both keys are presentation, read by the renderer's date cells and never by createColumn. The docs-corpus gate now root-judges a settledWhen example (slot list and text tripwire together, three self-test cases), and validate-field-consumers counts a settledWhen read as behaviour beside its three siblings. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- .../src/builtin-column-collision.ts | 2 ++ .../scripts/check-doc-formula-expressions.mjs | 26 +++++++++++++++++-- packages/lint/src/validate-field-consumers.ts | 4 ++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/drivers/driver-sql/src/builtin-column-collision.ts b/packages/drivers/driver-sql/src/builtin-column-collision.ts index 86ebb324c1b..bc85fac1dee 100644 --- a/packages/drivers/driver-sql/src/builtin-column-collision.ts +++ b/packages/drivers/driver-sql/src/builtin-column-collision.ts @@ -120,6 +120,8 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly> = readonlyWhen: 'presentation', requiredWhen: 'presentation', conditionalRequired: 'presentation', + 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 requiredPermissions: 'presentation', maskingRule: 'presentation', ackPlaintextMasking: 'presentation', diff --git a/packages/lint/scripts/check-doc-formula-expressions.mjs b/packages/lint/scripts/check-doc-formula-expressions.mjs index 30f5a9cdac4..93443a3f27a 100644 --- a/packages/lint/scripts/check-doc-formula-expressions.mjs +++ b/packages/lint/scripts/check-doc-formula-expressions.mjs @@ -635,7 +635,12 @@ function scan(files) { * same rule would fail the build on correct docs. The loudness requirement is * met by the skip list instead — printed, counted, and pinned by the self-test. */ -const FIELD_RULE_SLOTS = ['visibleWhen', 'readonlyWhen', 'requiredWhen']; +// `settledWhen` (#22227) is the deadline's settle predicate, judged by the +// metadata walk exactly as `visibleWhen` is — so a docs example of it gets the +// same verdict here. Keep this list and FIELD_RULE_TEXT_RE below in step: the +// text tripwire reconciles against the parse, so a slot in one and not the +// other is either an invisible site or a phantom skip. +const FIELD_RULE_SLOTS = ['visibleWhen', 'readonlyWhen', 'requiredWhen', 'settledWhen']; /** The property key of a property assignment, when it is a plain name. */ function keyText(p) { @@ -751,7 +756,7 @@ function fieldRuleSkipReason(prop) { * `docs/adr/0089-unify-visibility-predicate-naming.md` quotes the SCHEMA rather * than authoring a predicate — three text hits that are not sites at all. */ -const FIELD_RULE_TEXT_RE = /\b(visibleWhen|readonlyWhen|requiredWhen)\s*:\s*(?:[A-Za-z_$][\w$]*\s*)?["'`]/g; +const FIELD_RULE_TEXT_RE = /\b(visibleWhen|readonlyWhen|requiredWhen|settledWhen)\s*:\s*(?:[A-Za-z_$][\w$]*\s*)?["'`]/g; export function textFieldRuleSites(code) { const out = []; @@ -1742,6 +1747,23 @@ const FIELD_RULE_SELF_TEST_CASES = [ code: "ObjectSchema.create({ fields: { due_date: { type: 'date', requiredWhen: 'stage == \"closed\"' } } })", expect: { admitted: 1, errors: 1, match: /bare reference `stage`/ }, }, + { + // #22227 — the deadline's settle predicate is a field-rule slot, so a docs + // example of it is root-judged exactly as `visibleWhen` is. + name: 'RED — `settledWhen` on a deadline is the same field-rule slot: a bare reference is refused', + code: "due_date: Field.date({ dueLike: true, settledWhen: P`status == 'done'` }),", + expect: { admitted: 1, errors: 1, match: /bare reference `status`/ }, + }, + { + name: 'RED — `settledWhen` reading `current_user` gets the family\'s root verdict', + code: "due_date: Field.date({ dueLike: true, settledWhen: \"current_user.profile == 'admin'\" }),", + expect: { admitted: 1, errors: 1, match: /`settledWhen` reads `current_user`/ }, + }, + { + name: 'GREEN — the `fields.mdx` deadline example: a record-scoped `settledWhen`', + code: "due_date: Field.date({ label: 'Due Date', dueLike: true, settledWhen: P`record.status == 'done'` }),", + expect: { admitted: 1, errors: 0 }, + }, { name: 'GREEN — the canonical record-scoped predicate', code: "x: Field.text({ visibleWhen: \"record.status == 'closed'\" }),", diff --git a/packages/lint/src/validate-field-consumers.ts b/packages/lint/src/validate-field-consumers.ts index 5d5fce81124..4d5b2c23501 100644 --- a/packages/lint/src/validate-field-consumers.ts +++ b/packages/lint/src/validate-field-consumers.ts @@ -310,7 +310,9 @@ const BEHAVIOUR_SEGMENTS: ReadonlySet = new Set([ 'titleField', 'colorField', 'latitudeField', 'longitudeField', 'locationField', 'addressField', 'parentField', 'statusField', 'kanban', 'calendar', 'gantt', 'timeline', 'map', 'tree', 'rowColor', 'rowTint', 'conditionalFormatting', - 'expression', 'formula', 'visibleWhen', 'readonlyWhen', 'requiredWhen', 'validations', + // `settledWhen` (#22227) is the fourth field-rule predicate slot, a deadline's + // settle condition — a read of the fields it names, like its three siblings. + 'expression', 'formula', 'visibleWhen', 'readonlyWhen', 'requiredWhen', 'settledWhen', 'validations', 'rules', 'summaryOperations', 'dimensions', 'measures', 'handler', 'body', 'script', 'nameField', 'displayNameField', 'externalId', 'upsertKey', ]); From d0af72c6f85893391fc0bfa907bf2f455d74114c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:17:12 +0000 Subject: [PATCH 10/11] test(metadata-protocol,lint): the served `field` schema counts 77 top-level properties; the field-level alias verdict covers settledWhen protocol.meta-types-degenerate-derivation.test.ts holds the per-type served property counts as a positive control; `field` moves 75 to 77 with the two declared keys, the `picklist` precedent. The #6585 user-alias table over the field-rule slots gains settledWhen, which binds the same roots. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- packages/lint/src/validate-expressions.test.ts | 4 +++- .../src/protocol.meta-types-degenerate-derivation.test.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index fc739ee306e..a381c72fb64 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -1215,7 +1215,9 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { * stay accepted. */ describe('`user` / `ctx.user` aliases at field level (#6585)', () => { - const slots = ['visibleWhen', 'readonlyWhen', 'requiredWhen'] as const; + // #22227: a deadline's `settledWhen` is the fourth field-rule slot and + // binds the same roots, so the alias verdict covers it too. + const slots = ['visibleWhen', 'readonlyWhen', 'requiredWhen', 'settledWhen'] as const; it.each(slots)('rejects `user` on %s — same object as `current_user`, same unbound surface', (slot) => { const issues = validateStackExpressions({ 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 3e9771b0de5..76c24f50107 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 @@ -307,10 +307,12 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * record's picture, beside `nameField`). `page` moved 24 → 25 the same way * when it gained `print` (#22158, the print-page declaration). `object` * moved 44 → 45 the same way when it gained `attachedOnRead` (#22386, the - * blocks a service attaches per caller on read). + * blocks a service attaches per caller on read). `field` moved 75 → 77 the + * same way when it gained `dueLike` and `settledWhen` (#22227, a date's + * deadline and when it is settled). */ const CARD_PROPERTY_COUNTS: Record = { - agent: 26, app: 30, dashboard: 21, dataset: 16, field: 75, flow: 23, + agent: 26, app: 30, dashboard: 21, dataset: 16, field: 77, flow: 23, hook: 22, object: 45, page: 25, position: 12, report: 21, skill: 17, tool: 14, }; From c73e779f9a04e78461220274f2d0b5356f1d99aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:18:36 +0000 Subject: [PATCH 11/11] docs(lint): fieldRuleRootIssue's docblock counts the docs gate's slots as four, settledWhen included Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- packages/lint/src/validate-expressions.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 1f1aff8bd57..9cee79348d1 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -915,9 +915,10 @@ const FIELD_RULE_SLOT_CONSEQUENCE: Record = { * every surface that judges a field-level `*When` stands on the same verdict * AND the same message (Prime Directive #12). Two consumers today: the * metadata walk in {@link validateStackExpressions} below, and the docs-corpus - * gate `scripts/check-doc-formula-expressions.mjs`, which judges the same three - * slots where a fenced example's enclosing structure identifies the field layer - * (#11407). Before that gate existed this lived as a closure inside the walk — + * gate `scripts/check-doc-formula-expressions.mjs`, which judges the same + * field-rule slots (the three siblings, and since #22227 a deadline's + * `settledWhen`) where a fenced example's enclosing structure identifies the + * field layer (#11407). Before that gate existed this lived as a closure inside the walk — * fine while there was one caller, and exactly how a second caller comes to own * a DIALECT of the rule instead of the rule. *