From 0504bbc45679e0a1042e0d2f31457c95aa32dd65 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 07:23:18 +0000 Subject: [PATCH 1/4] feat(spec): autonumber fields default to unique: 'organization'; explicit unique: false opts out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An auto-number is a business identifier, and an identifier that may repeat is not one. `FieldSchema.unique` loses its key-level `.default(false)` and is materialized type-conditionally in the `.overwrite()` tail (the deleteBehavior precedent): `autonumber` ⇒ 'organization' (the tenant-composite `case_number` template), every other type ⇒ false at the same key position. An authored `unique: false` on an autonumber field is the opt-out; every authored spelling parses verbatim. Adds the pins, the semantic migration entry, the authorable-defaults declaration and the changeset. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017xqRv7A8HiJdKxsVm1UCuM --- .changeset/autonumber-default-unique.md | 73 +++++++++ packages/spec/scripts/lib/default-changes.ts | 25 +++ .../field-autonumber-default-unique.test.ts | 146 ++++++++++++++++++ packages/spec/src/data/field.zod.ts | 143 +++++++++++++---- ....autonumber-default-unique-organization.ts | 40 +++++ packages/spec/src/migrations/registry.ts | 36 +++++ 6 files changed, 437 insertions(+), 26 deletions(-) create mode 100644 .changeset/autonumber-default-unique.md create mode 100644 packages/spec/src/data/field-autonumber-default-unique.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.autonumber-default-unique-organization.ts diff --git a/.changeset/autonumber-default-unique.md b/.changeset/autonumber-default-unique.md new file mode 100644 index 0000000000..f7af277ab6 --- /dev/null +++ b/.changeset/autonumber-default-unique.md @@ -0,0 +1,73 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): an `autonumber` field is `unique: 'organization'` by default; explicit `unique: false` opts out (#13894) + +**BREAKING** emitted-shape change on `FieldSchema` (the accept set is unchanged), +shipped as `minor` under the repo's launch-window convention for breaking changes. + +An auto-number is a business identifier — a contract number, a quote number, a +case number — and an identifier that may repeat is not one. Yet the platform +only ever materialized a unique index where the author had written `unique` +by hand: of hotcrm's nine auto-numbered identifiers, exactly one +(`crm_case.case_number`, `unique: true`) carried the tenant-composite unique +index, and the other eight could mint the same number twice (measured: +objectstack#12394 re-issued `ACC-000009`). Maintainer ruling 2026-08-31 +(hotcrm#1301): the default flips. + +- An `autonumber` field that **omits** `unique` now parses to + `unique: 'organization'` — one holder per organization, materialized by the + drivers exactly as `case_number`'s hand-written declaration was: the NULL-safe + tenant-composite index `(COALESCE(organization_id, '__global__'), )` on an + organization-scoped object, a plain unique index on an object with no + organization key. +- Every **other** field type keeps `unique: false` as its default, at the same + key position — parse output for non-autonumber fields is byte-identical. +- Every **authored** spelling (`true`, `'organization'`, `'global'`, `false`) + parses exactly as before, on every type. +- The default is materialized at parse time (the `.overwrite()` tail of + `FieldSchema`, the type-conditional precedent `deleteBehavior` set), because + the drivers read the parsed `unique` value-only; the published JSON Schema + therefore no longer carries `default: false` on `Field.unique` — the + description states the rule, and the authorable-defaults ratchet records the + move as `data/Field:unique = false → (none)`. + +**Opting out.** Write `unique: false` explicitly on the autonumber field. That +is the whole opt-out surface — no second key. It is legitimate only for a +display-only sequence that nothing uses to identify the record; note that the +platform's duplicate scan (`os migrate duplicates`) keeps treating every +autonumber field as an identifier regardless. + +**Migration — what an operator with existing duplicates sees.** A table that +already holds duplicate auto-numbers cannot take the index. On SQLite/Postgres/ +MySQL the SQL driver does not fail the boot and does not skip silently: it logs +on the `error` channel — + +``` +[sql-driver] cannot create NULL-safe unique index 'uniq_crm_quote_organization_id_quote_number' on "crm_quote" — existing rows violate it (duplicates the previous NULL-distinct index admitted, #5030). The constraint 'organization_id, quote_number' is NOT enforced until the data is deduplicated: run "os migrate plan" for the conflicting rows (ADR-0120 D4). +``` + +— and the same boot's drift pass names the conflicting key groups with their +row counts: + +``` +[schema-drift] crm_quote: cannot create 'uniq_crm_quote_organization_id_quote_number' as UNIQUE (COALESCE(organization_id, '__global__'), quote_number) — existing rows already violate the NULL-safe unique constraint (duplicates the old index wrongly admitted, #5030): (organization_id="__global__", quote_number="QUO-00009") × 2 rows; (organization_id="org_x", quote_number="QUO-00010") × 2 rows. The op is BLOCKED: apply re-probes and refuses, and the existing index stays in place (ADR-0120 D4). Deduplicate the listed rows, then re-run "os migrate plan". +``` + +`os migrate plan` reports the same blocked `create_index` with the same groups +until the rows are deduplicated; `os migrate duplicates` lists the holder row +ids of any value minted across organization partitions (the seed/API split). +Deduplicate — which duplicate keeps its number is a business decision — then +re-run `os migrate plan` / restart, and the index materializes. An object with +`tenancy.enabled: false` takes a plain unique index instead, and there the +driver raises the database's own unique-violation error at boot (it names the +index, not the rows) — run `os migrate duplicates` / a `GROUP BY HAVING +COUNT(*) > 1` to find them. + +Two landed defects change shape on purpose under the default: a counter that +re-issues a number after a burned reservation (#12394) and two counters minting +for one object (#8686) used to produce a *silent* duplicate; they now produce a +loud unique-violation refusal at the write. + + diff --git a/packages/spec/scripts/lib/default-changes.ts b/packages/spec/scripts/lib/default-changes.ts index b60a795868..464eb7de9c 100644 --- a/packages/spec/scripts/lib/default-changes.ts +++ b/packages/spec/scripts/lib/default-changes.ts @@ -288,5 +288,30 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly)` on its next serving boot (a plain " + + 'unique index on an object with no organization key), and a write that would repeat a ' + + 'number is refused. A table already holding duplicate auto-numbers cannot take the ' + + 'index: the SQL driver logs at `error` naming the index and the remedy, the same ' + + 'boot\'s drift pass names the conflicting key groups with row counts, and `os migrate ' + + 'plan` reports the blocked `create_index` until the rows are deduplicated (ADR-0120 ' + + 'D4) — never a silent skip. To keep an autonumber field NON-unique (a display-only ' + + 'sequence that never identifies a record), write `unique: false` explicitly; to keep ' + + 'what the schema now does for you, change nothing. Nothing moves for any other type.', + }, ], }; diff --git a/packages/spec/src/data/field-autonumber-default-unique.test.ts b/packages/spec/src/data/field-autonumber-default-unique.test.ts new file mode 100644 index 0000000000..bb67c6f23f --- /dev/null +++ b/packages/spec/src/data/field-autonumber-default-unique.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #13894 — an `autonumber` field is `unique: 'organization'` by default. + * + * Maintainer ruling 2026-08-31 (hotcrm#1301): an auto-number is a business + * identifier, and an identifier that may repeat is not one — so uniqueness is + * the platform default and opting out is the declaration. Before this card + * the platform only materialized a unique index where the author had written + * `unique` by hand: of hotcrm's nine auto-numbered identifiers exactly one + * (`crm_case.case_number`) carried the tenant-composite index, and the other + * eight could mint the same number twice (#12394 re-issued `ACC-000009`). + * + * Mechanism under test: `unique` is `.optional()` on the shape and the + * `.overwrite()` tail of `FieldSchema` materializes the default + * type-conditionally (the #9689 / #9784 `deleteBehavior` precedent), because + * a key-level `.default()` can neither see `type` nor tell an omitted key from + * an authored `false` — the one distinction the opt-out rests on — and because + * every driver reads the parsed `unique` value-only + * (`isUniqueScopeDeclared(field.unique)`), so the default must be PRESENT on + * the parsed field for an index to exist at all. + * + * These pins assert the substance, each from a different consumer's seat: + * what the parsed field carries, what the opt-out yields, that no other type + * moved (value AND key position — built artifacts are compared byte-wise), + * that re-parsing is stable (the #9689 class: `ObjectSchema.create()` → + * `defineStack` re-parses on the mainline app-build path), that the object + * path the driver reads through carries it, and that the driver-facing + * predicates read it as a declared per-organization scope. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { + Field, + FieldSchema, + FieldType, + isUniqueDeclared, + isGlobalUnique, + isOrganizationUnique, +} from './field.zod'; +import { ObjectSchema } from './object.zod'; + +/** Minimal valid authored input per field type (relationship types need a target). */ +function minimalField(type: string): Record { + const input: Record = { type }; + if (type === 'lookup' || type === 'master_detail' || type === 'tree') input.reference = 'account'; + if (type === 'summary') { + input.summaryObject = 'line'; + input.summaryField = 'amount'; + input.summaryType = 'sum'; + } + if (type === 'formula') input.expression = '1 + 1'; + return input; +} + +describe('#13894 — autonumber defaults to unique: organization', () => { + it("materializes 'organization' when the author omits `unique`", () => { + const parsed = FieldSchema.parse({ type: 'autonumber' }); + expect(parsed.unique).toBe('organization'); + // The value the drivers act on: declared, per-organization, not global. + expect(isUniqueDeclared(parsed.unique)).toBe(true); + expect(isOrganizationUnique(parsed.unique)).toBe(true); + expect(isGlobalUnique(parsed.unique)).toBe(false); + }); + + it('honours an explicit `unique: false` — the opt-out spelling, and the only one', () => { + const parsed = FieldSchema.parse({ type: 'autonumber', unique: false }); + expect(parsed.unique).toBe(false); + expect(isUniqueDeclared(parsed.unique)).toBe(false); + }); + + it('returns every authored spelling verbatim on an autonumber field', () => { + for (const unique of [true, 'organization', 'global'] as const) { + expect(FieldSchema.parse({ type: 'autonumber', unique }).unique).toBe(unique); + } + }); + + it('leaves every other field type at `unique: false` when omitted', () => { + const others = FieldType.options.filter((t) => t !== 'autonumber'); + expect(others.length).toBeGreaterThan(40); + for (const type of others) { + const result = FieldSchema.safeParse(minimalField(type)); + expect(result.success, `${type}: ${result.success ? '' : result.error.message}`).toBe(true); + if (!result.success) continue; + expect(result.data.unique, type).toBe(false); + } + }); + + it('keeps `unique` at its shape position on every type (byte-identity of parse output)', () => { + // Zod emits parse output in shape order; the overwrite re-inserts the + // materialized key at that position rather than appending it. Assert the + // parsed key order IS the shape order restricted to the keys present. + const shapeOrder = Object.keys(FieldSchema.shape); + for (const input of [{ type: 'text' }, { type: 'autonumber' }, { type: 'lookup', reference: 'account' }]) { + const parsed = FieldSchema.parse(input) as Record; + const keys = Object.keys(parsed); + expect(keys).toEqual(shapeOrder.filter((k) => k in parsed)); + expect(keys).toContain('unique'); + } + // And the position did not move relative to the pre-flip output: `unique` + // sits where the `.default(false)` era put it, right after `multiple`. + const text = Object.keys(FieldSchema.parse({ type: 'text' })); + expect(text.indexOf('unique')).toBe(text.indexOf('multiple') + 1); + }); + + it('is idempotent — parse(parse(x)) is byte-identical (the #9689 class)', () => { + for (const input of [{ type: 'autonumber' }, { type: 'autonumber', unique: false }, { type: 'text' }]) { + const once = FieldSchema.parse(input); + const twice = FieldSchema.parse(once); + expect(JSON.stringify(twice)).toBe(JSON.stringify(once)); + } + }); + + it('applies through ObjectSchema — the path the driver reads through `ObjectSchema.create()` / `defineStack`', () => { + const obj = ObjectSchema.parse({ + name: 'crm_quote', + fields: { + quote_number: { type: 'autonumber', autonumberFormat: 'QUO-{00000}' }, + line_no: { type: 'autonumber', unique: false }, + title: { type: 'text' }, + }, + }); + expect(obj.fields.quote_number.unique).toBe('organization'); + expect(obj.fields.line_no.unique).toBe(false); + expect(obj.fields.title.unique).toBe(false); + }); + + it('the `Field.autonumber()` builder lands on the default too', () => { + expect(FieldSchema.parse(Field.autonumber({ label: 'Quote No.' })).unique).toBe('organization'); + expect(FieldSchema.parse(Field.autonumber({ label: 'Line', unique: false })).unique).toBe(false); + }); + + it('the published JSON Schema carries no single `default` for `unique` and states the rule instead', () => { + // A single JSON-Schema `default` would be wrong for one of the two cases + // (false on 48 types, 'organization' on autonumber), so the emitter must + // not advertise one; the description is the machine-readable statement. + const schema = z.toJSONSchema(FieldSchema, { target: 'draft-2020-12' }) as { + properties: Record; + }; + expect(schema.properties.unique).toBeDefined(); + expect('default' in schema.properties.unique).toBe(false); + expect(schema.properties.unique.description).toMatch(/autonumber/); + expect(schema.properties.unique.description).toMatch(/unique: false/); + }); +}); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index d4e3c6b6df..f835aab767 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -183,6 +183,17 @@ const MULTILINE_EDITOR_FIELD_TYPES: ReadonlySet = new Set([ * and (c) never legitimately supplied by a caller. `formula` and `summary` are * deliberately NOT here: they are derived-on-read/roll-up, not stored values a * caller could forge into a sequence. + * + * A runtime-issued value is issued AS AN IDENTIFIER, and an identifier that + * may repeat is not one — so since #13894 (maintainer ruling 2026-08-31 on + * hotcrm#1301) a field of one of these types also defaults to + * `unique: 'organization'` when the author omits `unique` (materialized in + * `FieldSchema`'s `.overwrite()` tail, the `case_number` template); an + * authored `unique: false` opts out. This set is NOT what that default reads — + * it keys on `type === 'autonumber'` directly, the same test the platform's + * duplicate scan (`os migrate duplicates`) uses to call a field an identifier + * — but the two facts belong to the same ownership: the runtime mints it, the + * runtime keeps it unique. */ export const RUNTIME_OWNED_FIELD_TYPES: ReadonlySet = new Set(['autonumber']); @@ -936,7 +947,17 @@ export const FieldSchema = lazySchema(() => { // `true` = unique WITHIN the tenant on a tenant-scoped object (composite // `(tenantField, field)` index); `'global'` = platform-wide single-column // unique. See {@link UniqueScopeSchema} for the scope vocabulary (ADR-0120). - unique: UniqueScopeSchema.default(false).describe("Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'"), + // + // [#13894] No key-level `.default()` here, on purpose: the default is + // TYPE-CONDITIONAL — `autonumber` ⇒ `'organization'`, every other type ⇒ + // `false` — and a key-level default can neither see `type` nor tell an + // omitted key from an authored `false` (the opt-out spelling). It is + // materialized by the `.overwrite()` tail of this schema, at this shape + // position, so parse output for every non-autonumber type is byte-identical + // to the `.default(false)` era. The JSON Schema therefore carries NO + // `default` annotation (a single value would be wrong for one of the two + // cases) — the description states the rule. + unique: UniqueScopeSchema.optional().describe("Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (#13894: an auto-number is a business identifier, so the platform makes it unique per organization by default — the `case_number` template). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier."), defaultValue: z.unknown().optional().describe('Default applied on INSERT when the field is omitted or null (`\'\'` is a real value, not absence). Three legal shapes, discriminated in the engine\'s own order: a CEL Expression envelope `{ dialect: \'cel\', source: \'today()\' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: \'sys_user\'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field\'s own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message.'), /** Text/String Constraints */ @@ -1639,6 +1660,49 @@ export const FieldSchema = lazySchema(() => { * '{0000}'` on every `text`, `number` and `lookup` field ever parsed — a * format on a field that has no counter. The annotation states the default * to schema consumers and AI metadata authors without touching parse output. + * + * ## Uniqueness of the minted value — the contract default (#13894) + * + * An auto-number is a business identifier (a contract number, a quote + * number, a case number), and an identifier that may repeat is not one. So + * an `autonumber` field that omits `unique` is `unique: 'organization'` by + * contract (maintainer ruling 2026-08-31, hotcrm#1301): one holder per + * organization, materialized by the driver as the NULL-safe tenant-composite + * index `(COALESCE(organization_id, '__global__'), )` on an + * organization-scoped object and as a plain unique index where the object + * has no organization key — exactly what hotcrm's `crm_case.case_number` + * declared by hand, now the platform default for the other eight. Every + * other field type keeps `unique: false` as its default. + * + * Two things this default changes on purpose. A counter that re-issues a + * number after a burned reservation, or two counters minting for one object + * (the seed/API tenancy split), used to produce a SILENT duplicate; under the + * default they produce a loud unique-violation refusal at the write. And a + * table that ALREADY holds duplicate auto-numbers when the default arrives + * cannot take the index: the SQL driver logs that at `error` naming the index + * and the remedy, the same boot's drift pass names the conflicting key groups + * with their row counts, and `os migrate plan` reports the blocked + * `create_index` with the same groups until the rows are deduplicated — + * never a silent skip (`syncDeclaredIndexes` in driver-sql; ADR-0120 D4). + * + * **Opting out**: write `unique: false` explicitly. That is the whole + * opt-out surface — no second key. It is legitimate only for a display-only + * sequence that nothing uses to identify the record (an ordinal shown on a + * line, say), and note the platform's duplicate scan (`os migrate + * duplicates`) keeps treating every autonumber field as an identifier + * regardless. + * + * Why this default is a parse-time materialization while `autonumberFormat` + * above is a JSON-Schema annotation: the two are read at different seams. + * The format is resolved at MINT time by `resolveAutonumberFormat`, a helper + * every generator calls, so the declaration can stay absent. The unique + * scope is read off the DECLARED field by every driver's index sync + * (`isUniqueScopeDeclared(field.unique)` in driver-sql, the Mongo and memory + * drivers alike) — value-only reads that never see `type` — so the default + * has to be present on the parsed field for an index to exist at all. It is + * therefore materialized in the `.overwrite()` tail of this schema, the + * type-conditional precedent `deleteBehavior` set (#9689 / #9784), and only + * on `autonumber`, so no other type's parse output moves. */ autonumberFormat: z.string().optional().meta({ description: 'Auto-number format: literal text + {0000} counter, {YYYY}/{MM}/{DD}/{YYYYMMDD} date tokens (business tz), and {field_name} interpolation. Counter resets per rendered prefix (e.g. AD{YYYYMMDD}{0000} resets daily). Omitted on an `autonumber` field ⇒ the contract default `{0000}` (#6555).', @@ -1949,22 +2013,45 @@ export const FieldSchema = lazySchema(() => { }); } }).overwrite((field) => { - // #9689 — the relocated `.default('set_null')`, applied AFTER the checks - // above. `.overwrite()` rather than `.transform()` per the measured #6926 - // precedent (`CurrencyConfigSchema` in this file is the sibling): it keeps - // this schema a `ZodObject` (a pipe has no `.extend` and answers shape + // The TYPE-CONDITIONAL defaults of this schema — relocated key-level + // `.default()`s, applied AFTER the checks above. `.overwrite()` rather + // than `.transform()` per the measured #6926 precedent + // (`CurrencyConfigSchema` in this file is the sibling): it keeps this + // schema a `ZodObject` (a pipe has no `.extend` and answers shape // introspection with an empty set), and checks run in attachment order, so - // the superRefine above always sees the pre-materialized value. The key is - // re-inserted at its SHAPE position (Zod emits parse output in shape - // order), so output is byte-identical to the `.default('set_null')` era on - // the reference types that still materialize it (`lookup` / `tree`) — see - // the two rulings below for why `master_detail` and every non-reference - // type omit it instead. The one accepted cost, same as the currency - // precedent's: the INFERRED output type declares `deleteBehavior?` even - // though a parsed `lookup`/`tree` field always carries it (ADR-0122 - // forbids hand-narrowing the inferred type); the runtime contract is the - // enforced one. - if (field.deleteBehavior !== undefined) return field; + // the superRefine above always sees the pre-materialized value. Each key + // is re-inserted at its SHAPE position (Zod emits parse output in shape + // order), so output is byte-identical to the key-level `.default()` era + // wherever the value is unchanged. The one accepted cost, same as the + // currency precedent's: the INFERRED output type declares the key optional + // (`deleteBehavior?`, `unique?`) even though a parsed field carries it + // (ADR-0122 forbids hand-narrowing the inferred type); the runtime + // contract is the enforced one. + const patch: Record = {}; + + // [#13894] `unique` — the relocated `.default(false)`, made type-aware + // (maintainer ruling 2026-08-31 on hotcrm#1301: an auto-number is a + // business identifier, so the platform makes it unique per organization + // by default, the `case_number` template; explicit `unique: false` is the + // opt-out). A key-level `.default()` can neither see `type` nor tell an + // omitted key from an authored `false`, which is the one distinction the + // opt-out rests on — so the key is `.optional()` on the shape and + // materialized here: `autonumber` ⇒ `'organization'`, every other type ⇒ + // `false`, byte-identical to before. An authored value of any accepted + // spelling is returned verbatim. Idempotent by construction (#9689 class): + // `'organization'` is itself an accepted authored spelling, so + // `parse(parse(x))` is stable, and the drivers — which read the parsed + // `unique` value-only (`isUniqueScopeDeclared(field.unique)`) — see a + // declared scope where the author wrote none. + if (field.unique === undefined) { + patch.unique = field.type === 'autonumber' ? 'organization' : false; + } + + // #9689 — the relocated `.default('set_null')`, materialized only on the + // reference types that still carry it (`lookup` / `tree`) — see the two + // rulings below for why `master_detail` and every non-reference type omit + // it instead. An AUTHORED `deleteBehavior` on any type is returned verbatim. + // // #9689 (maintainer ruling 2026-08-24, idempotent materialization — // 「四维分析一致的,接手你的建议。」): NEVER materialize a default the // schema itself would refuse as authored. The superRefine above rejects an @@ -1981,7 +2068,7 @@ export const FieldSchema = lazySchema(() => { // carrying a value the schema itself refuses. Every other type keeps // byte-identity, and the #7918 currency `precision` twin of this landmine // is #11423 — same principle, its own card. - if (field.type === 'master_detail') return field; + // // #9784 — materialize the default ONLY on reference types. `deleteBehavior` // has no meaning on a non-reference field: the engine's // `cascadeDeleteRelations` reaches the key exclusively on @@ -1995,15 +2082,19 @@ export const FieldSchema = lazySchema(() => { // (ADR-0033 direction). Non-reference fields therefore parse to output // that OMITS the key. The accept-set is untouched: an AUTHORED // `deleteBehavior` on any type still parses exactly as before (the - // `!== undefined` early return above), so stored artifacts from the - // materializing era stay legal. `tree` (hierarchical reference) keeps - // materializing with `lookup`: it is in the relational family, where the - // key states delete semantics — the conservative byte-identity side of - // the line. `user` is stored identically to `lookup` but sits outside - // today's cascade guard exactly like `text` does, so it takes the - // non-reference side; an authored value there still round-trips. - if (field.type !== 'lookup' && field.type !== 'tree') return field; - const withDefault: Record = { ...field, deleteBehavior: 'set_null' }; + // `=== undefined` guard), so stored artifacts from the materializing era + // stay legal. `tree` (hierarchical reference) keeps materializing with + // `lookup`: it is in the relational family, where the key states delete + // semantics — the conservative byte-identity side of the line. `user` is + // stored identically to `lookup` but sits outside today's cascade guard + // exactly like `text` does, so it takes the non-reference side; an + // authored value there still round-trips. + if (field.deleteBehavior === undefined && (field.type === 'lookup' || field.type === 'tree')) { + patch.deleteBehavior = 'set_null'; + } + + if (Object.keys(patch).length === 0) return field; + const withDefault: Record = { ...field, ...patch }; const out: Record = {}; for (const key of shapeOrder) { if (key in withDefault) out[key] = withDefault[key]; diff --git a/packages/spec/src/migrations/entries/semantic/18.autonumber-default-unique-organization.ts b/packages/spec/src/migrations/entries/semantic/18.autonumber-default-unique-organization.ts new file mode 100644 index 0000000000..eac2da2b71 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.autonumber-default-unique-organization.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'autonumber-default-unique-organization', + surface: '`fields..unique` on a `type: \'autonumber\'` field when the author OMITS the key — ' + + 'the contract default moves from `false` (no index) to `\'organization\'` (one holder per ' + + 'organization; the NULL-safe tenant-composite unique index ' + + '`(COALESCE(organization_id, \'__global__\'), )` on an organization-scoped object, a ' + + 'plain unique index where the object has no organization key)', + replacement: 'keep the omission to take the default — an auto-number is a business identifier and ' + + 'is unique per organization from now on with zero application-side declaration; write ' + + '`unique: false` EXPLICITLY on the one autonumber field that is a display-only sequence and ' + + 'is never used to identify the record. Every other field type keeps `unique: false` as its ' + + 'default, and every authored spelling (`true` / `\'organization\'` / `\'global\'` / `false`) ' + + 'parses exactly as before', + reason: + 'Not losslessly convertible because the change is data-dependent, not textual: a table that ' + + 'already holds duplicate auto-numbers (a counter that re-issued a burned number, or the ' + + 'seed/API tenancy split running two counters for one object) cannot take the index the ' + + 'default now declares. The SQL driver refuses to silently degrade — it logs at `error` ' + + 'naming the index, the columns and the remedy, the same boot\'s drift pass names the ' + + 'conflicting key groups with row counts, and `os migrate plan` reports the blocked ' + + '`create_index` with the same groups (ADR-0120 D4) — but which of the duplicate rows keeps ' + + 'the number is a business decision no migration entry can make. Maintainer ruling ' + + '2026-08-31 (hotcrm#1301): an auto-number that may repeat is not an identifier, so unique ' + + 'is the platform default and opting out is the declaration, not the other way round ' + + '(#13894).', + acceptanceCriteria: + 'Every `autonumber` field without an authored `unique` parses to `unique: \'organization\'` ' + + '(`FieldSchema.parse({ type: \'autonumber\' }).unique === \'organization\'`, and through ' + + '`ObjectSchema` the same); an authored `unique: false` on an autonumber field parses to ' + + '`false`; every non-autonumber field type without an authored `unique` still parses to ' + + '`false` at the same key position. On a serving boot, each organization-scoped object with ' + + 'such a field carries `uniq__organization_id_`; a table whose data blocks it ' + + 'shows the blocked `create_index` with its conflicting groups in `os migrate plan` until ' + + 'the rows are deduplicated and the plan is re-run, and `os migrate duplicates` lists the ' + + 'holder rows of any value minted across organization partitions.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index cdbfd50a74..e140e85803 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5545,6 +5545,42 @@ const step18: MigrationStep = { '(invitation, admin create-user / import, SCIM, or an operator-registered identity provider) ' + 'and that anonymous sign-up now answers 403 SELF_REGISTRATION_CLOSED.', }, + { + id: 'autonumber-default-unique-organization', + surface: '`fields..unique` on a `type: \'autonumber\'` field when the author OMITS the key — ' + + 'the contract default moves from `false` (no index) to `\'organization\'` (one holder per ' + + 'organization; the NULL-safe tenant-composite unique index ' + + '`(COALESCE(organization_id, \'__global__\'), )` on an organization-scoped object, a ' + + 'plain unique index where the object has no organization key)', + replacement: 'keep the omission to take the default — an auto-number is a business identifier and ' + + 'is unique per organization from now on with zero application-side declaration; write ' + + '`unique: false` EXPLICITLY on the one autonumber field that is a display-only sequence and ' + + 'is never used to identify the record. Every other field type keeps `unique: false` as its ' + + 'default, and every authored spelling (`true` / `\'organization\'` / `\'global\'` / `false`) ' + + 'parses exactly as before', + reason: + 'Not losslessly convertible because the change is data-dependent, not textual: a table that ' + + 'already holds duplicate auto-numbers (a counter that re-issued a burned number, or the ' + + 'seed/API tenancy split running two counters for one object) cannot take the index the ' + + 'default now declares. The SQL driver refuses to silently degrade — it logs at `error` ' + + 'naming the index, the columns and the remedy, the same boot\'s drift pass names the ' + + 'conflicting key groups with row counts, and `os migrate plan` reports the blocked ' + + '`create_index` with the same groups (ADR-0120 D4) — but which of the duplicate rows keeps ' + + 'the number is a business decision no migration entry can make. Maintainer ruling ' + + '2026-08-31 (hotcrm#1301): an auto-number that may repeat is not an identifier, so unique ' + + 'is the platform default and opting out is the declaration, not the other way round ' + + '(#13894).', + acceptanceCriteria: + 'Every `autonumber` field without an authored `unique` parses to `unique: \'organization\'` ' + + '(`FieldSchema.parse({ type: \'autonumber\' }).unique === \'organization\'`, and through ' + + '`ObjectSchema` the same); an authored `unique: false` on an autonumber field parses to ' + + '`false`; every non-autonumber field type without an authored `unique` still parses to ' + + '`false` at the same key position. On a serving boot, each organization-scoped object with ' + + 'such a field carries `uniq__organization_id_`; a table whose data blocks it ' + + 'shows the blocked `create_index` with its conflicting groups in `os migrate plan` until ' + + 'the rows are deduplicated and the plan is re-run, and `os migrate duplicates` lists the ' + + 'holder rows of any value minted across organization partitions.', + }, { id: 'branded-identifier-schemas-retired', surface: From c4bd12e91f2ccc038ba1cd574476c6792b1127d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 07:33:15 +0000 Subject: [PATCH 2/4] chore(spec): regenerate references, authorable-defaults and api-surface for the autonumber unique default; fix the summary fixture in the pin Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017xqRv7A8HiJdKxsVm1UCuM --- content/docs/references/data/field.mdx | 2 +- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- packages/spec/authorable-defaults/data.json | 1 - .../field-autonumber-default-unique.test.ts | 18 ++++++++++-------- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index b594ed32a1..501a7cff37 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -61,7 +61,7 @@ const result = CurrencyConfigSchema.parse(data); | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | -| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | +| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (#13894: an auto-number is a business identifier, so the platform makes it unique per organization by default — the `case_number` template). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index de59cf7060..b650bcefd7 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -223,7 +223,7 @@ const result = ApiMethod.parse(data); | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | -| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | +| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (#13894: an auto-number is a business identifier, so the platform makes it unique per organization by default — the `case_number` template). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | @@ -552,7 +552,7 @@ const result = ApiMethod.parse(data); | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | -| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | +| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (#13894: an auto-number is a business identifier, so the platform makes it unique per organization by default — the `case_number` template). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index e5e79611d5..1e083ffaf7 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -61,7 +61,7 @@ Add a new field to an existing object | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | -| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | +| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (#13894: an auto-number is a business identifier, so the platform makes it unique per organization by default — the `case_number` template). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | @@ -479,7 +479,7 @@ Add a new field to an existing object | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | -| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | +| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (#13894: an auto-number is a business identifier, so the platform makes it unique per organization by default — the `case_number` template). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index 1cf33d2863..21ccd54454 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -45,7 +45,6 @@ "data/Field:required = false", "data/Field:searchable = false", "data/Field:sortable = true", - "data/Field:unique = false", "data/FilePersistenceConfig:autoSaveInterval = 2000", "data/FormatValidation:active = true", "data/FormatValidation:events = [\"insert\",\"update\"]", diff --git a/packages/spec/src/data/field-autonumber-default-unique.test.ts b/packages/spec/src/data/field-autonumber-default-unique.test.ts index bb67c6f23f..90d0757fc0 100644 --- a/packages/spec/src/data/field-autonumber-default-unique.test.ts +++ b/packages/spec/src/data/field-autonumber-default-unique.test.ts @@ -45,11 +45,7 @@ import { ObjectSchema } from './object.zod'; function minimalField(type: string): Record { const input: Record = { type }; if (type === 'lookup' || type === 'master_detail' || type === 'tree') input.reference = 'account'; - if (type === 'summary') { - input.summaryObject = 'line'; - input.summaryField = 'amount'; - input.summaryType = 'sum'; - } + if (type === 'summary') input.summaryOperations = { object: 'line', field: 'amount', function: 'sum' }; if (type === 'formula') input.expression = '1 + 1'; return input; } @@ -135,9 +131,15 @@ describe('#13894 — autonumber defaults to unique: organization', () => { // A single JSON-Schema `default` would be wrong for one of the two cases // (false on 48 types, 'organization' on autonumber), so the emitter must // not advertise one; the description is the machine-readable statement. - const schema = z.toJSONSchema(FieldSchema, { target: 'draft-2020-12' }) as { - properties: Record; - }; + // Same emitter call `scripts/build-schemas.ts` makes: output mode first, + // input mode when a transform elsewhere on the shape is unrepresentable. + type Emitted = { properties: Record }; + let schema: Emitted; + try { + schema = z.toJSONSchema(FieldSchema, { target: 'draft-2020-12' }) as Emitted; + } catch { + schema = z.toJSONSchema(FieldSchema, { target: 'draft-2020-12', io: 'input' }) as Emitted; + } expect(schema.properties.unique).toBeDefined(); expect('default' in schema.properties.unique).toBe(false); expect(schema.properties.unique.description).toMatch(/autonumber/); From fb96bafb2fe3c080444057336478607224d26aad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 08:42:54 +0000 Subject: [PATCH 3/4] docs(spec): drop the issue id from the customer-facing unique describe; regenerate references; re-anchor the system-context census Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017xqRv7A8HiJdKxsVm1UCuM --- content/docs/permissions/system-context.mdx | 2 +- 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.zod.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 7ac4dc3ded..0092b5746e 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -196,7 +196,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881`–`1883`, #3760), `flow.zod.ts:685` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | | "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10007`–`10024` | -| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) | +| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1537` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | | "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1522`, `:1551`; `domains/actions.ts:404` | diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 501a7cff37..83d7baa07a 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -61,7 +61,7 @@ const result = CurrencyConfigSchema.parse(data); | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | -| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (#13894: an auto-number is a business identifier, so the platform makes it unique per organization by default — the `case_number` template). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | +| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index b650bcefd7..b1dda25894 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -223,7 +223,7 @@ const result = ApiMethod.parse(data); | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | -| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (#13894: an auto-number is a business identifier, so the platform makes it unique per organization by default — the `case_number` template). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | +| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | @@ -552,7 +552,7 @@ const result = ApiMethod.parse(data); | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | -| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (#13894: an auto-number is a business identifier, so the platform makes it unique per organization by default — the `case_number` template). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | +| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 1e083ffaf7..3321d7c085 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -61,7 +61,7 @@ Add a new field to an existing object | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | -| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (#13894: an auto-number is a business identifier, so the platform makes it unique per organization by default — the `case_number` template). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | +| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | @@ -479,7 +479,7 @@ Add a new field to an existing object | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | -| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (#13894: an auto-number is a business identifier, so the platform makes it unique per organization by default — the `case_number` template). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | +| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index f835aab767..2922b05a32 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -957,7 +957,7 @@ export const FieldSchema = lazySchema(() => { // to the `.default(false)` era. The JSON Schema therefore carries NO // `default` annotation (a single value would be wrong for one of the two // cases) — the description states the rule. - unique: UniqueScopeSchema.optional().describe("Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (#13894: an auto-number is a business identifier, so the platform makes it unique per organization by default — the `case_number` template). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier."), + unique: UniqueScopeSchema.optional().describe("Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier."), defaultValue: z.unknown().optional().describe('Default applied on INSERT when the field is omitted or null (`\'\'` is a real value, not absence). Three legal shapes, discriminated in the engine\'s own order: a CEL Expression envelope `{ dialect: \'cel\', source: \'today()\' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: \'sys_user\'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field\'s own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message.'), /** Text/String Constraints */ From 8591cccc12d6f361252afa8e119c50af8b3a8e66 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 08:47:02 +0000 Subject: [PATCH 4/4] chore: regenerate the system-context census page on the merged tree Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017xqRv7A8HiJdKxsVm1UCuM --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0092b5746e..177ceecf49 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4716`, `:6079`, `:6327`, `:6758`, `:6951` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4715`, `:6078`, `:6326`, `:6757`, `:6950` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:326`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |