Uh oh!
There was an error while loading. Please reload this page.
feat(spec): autonumber fields default to unique: 'organization'; explicit unique: false opts out (#13894) - #14904
Conversation
…icit unique: false opts out 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xqRv7A8HiJdKxsVm1UCuM
…ce for the autonumber unique default; fix the summary fixture in the pin Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xqRv7A8HiJdKxsVm1UCuM
…e; regenerate references; re-anchor the system-context census Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xqRv7A8HiJdKxsVm1UCuM
…tonumber-default-unique
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xqRv7A8HiJdKxsVm1UCuM
📓 Docs Drift CheckThis PR changes 1 package(s): 27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 42ddc5a7aaf8782be3c47c59d0e9dd6fe90e15bc && git checkout 42ddc5a7aaf8782be3c47c59d0e9dd6fe90e15bc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd 8591cccc12d6f361252afa8e119c50af8b3a8e66 && git checkout -B drift-repro 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd && git merge --no-ff 8591cccc12d6f361252afa8e119c50af8b3a8e66
node scripts/docs-audit/affected-docs.mjs --json 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd
|
hotlong
commented
Sep 3, 2026
Contract review: PASS — head |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13894
Contract change under clause ② (
packages/spec/src/**+ a published-contract behaviour change) — opened as a DRAFT withneeds:contract-review; the PM flips it after the contract review.Ruling (card body, verbatim)
What changes
FieldSchema.uniqueloses its key-level.default(false). The default is now materialized type-conditionally in the schema's.overwrite()tail:autonumber⇒'organization', every other type ⇒false, re-inserted at the same shape position, so parse output for every non-autonumber field is byte-identical to before.unique: falseon an autonumber field is the opt-out — the only opt-out surface, no new key (constraint 1). Every authored spelling (true/'organization'/'global'/false) parses verbatim on every type.uniquekey, the autonumber block (why this default is a parse-time materialization whileautonumberFormatstays a JSON-Schema annotation),RUNTIME_OWNED_FIELD_TYPES.field-autonumber-default-unique.test.ts, 9 cases), the ADR-0087 semantic migration entry (autonumber-default-unique-organization), the authorable-defaults declaration (data/Field:unique = false → (none)), the changeset, and every regenerated artifact.M1 / M2 — measurement first (constraint 2), verbatim
Reproduced on sqlite (
better-sqlite3) atorigin/main2263ca4d6: tablecrm_quotewith rowsq1/q2sharingQUO-00009in the NULL-organization partition,q3/q4sharingQUO-00010inorg_x, theninitObjectswithquote_number: { type: 'autonumber', unique: true }.M1-A — organization-scoped object (the shape the default resolves to).
initObjectsdoes NOT throw. The driver logs on theerrorchannel:(meta:
CREATE UNIQUE INDEX `uniq_crm_quote_organization_id_quote_number` ON `crm_quote` (COALESCE(`organization_id`, '__global__'), `quote_number`) - UNIQUE constraint failed: index 'uniq_crm_quote_organization_id_quote_number'), and the SAME boot's drift pass (reconcileAndWarnDrift) names the conflicting key groups with row counts on thewarnchannel:PRAGMA index_list(crm_quote)afterwards: only the PK autoindex — the constraint is NOT enforced, and that is said out loud.detectManagedDrift()(whatos migrate planrenders) returns thecreate_indexop withseverity: 'error',category: 'destructive'and the same group report. Identical underautoMigrate: 'safe'(dev). Postgres is not live in this container; its shape is pinned bysql-driver-unique-violation-predicate.test.ts(23505, detailKey (COALESCE(organization_id, '__global__'::text), code)=(__global__, DUP) is duplicated.), absorbed by the same branch.Reading: (a)-shaped — loud, names the index AND the duplicate key groups with counts AND the remedy; the boot continues with the constraint unenforced, non-silently. Per the decision rule ⇒ implement the flip. The liveness-ledger row
props/unique("DDL-only; NOT validated on the write path") is confirmed: enforcement is the index, violations surface as driver errors.M1-B —
tenancy: { enabled: false }(plain unique).initObjectsTHROWS the raw driver error, names no rows and no remedy, logs nothing;detectManagedDrift()calls the opsafe("run os migrate apply to create it") with no pre-flight:Reading: (b) — loud, index-only. Filed as the engine-lane finding #14902 (unassigned); not implemented here.
M2 —
os migrate duplicates(collectDuplicateIdentifierReport) on the same table: classifiesquote_numberasidentifier: 'autonumber'(uniqueScope: null,type === 'autonumber'is tested FIRST — unchanged by the flip) and lists holder ROW IDS with partitions for a value held across partitions (QUO-00009:q1,q2in__global__+q5inorg_y,holderCount: 3). By the #8928 ruling it deliberately does NOT list same-partition repeats (QUO-00010inorg_x); those are the D4 channel's (os migrate plan, above). So the operator's remedy channel is:os migrate planfor the blocked index + key groups,os migrate duplicatesfor row ids of cross-partition mints. Both are named in the changeset and the docblock.H1 — mechanism, and why it differs from the
autonumberFormatblockThe drivers read the PARSED field value-only: driver-sql
registerManagedObjectMetadatagates the index sync onisUniqueScopeDeclared(f?.unique)(→ specisUniqueDeclared),uniqueIndexesFromFieldsreadsfield.unique; driver-memoryisUniqueDeclared(field.unique); driver-mongodbif (field.unique). None seestype, and no driver resolves through a helper that takes the field. So a runtime resolver (theautonumberFormatpattern, where every generator callsresolveAutonumberFormatat mint time) cannot reach them without a driver edit — fenced out here. The default therefore has to be PRESENT on the parsed field, and a key-level.default()cannot do it: it neither seestypenor distinguishes an omitted key from an authoredfalse(the opt-out). Hence.optional()on the key + the.overwrite()tail — the measured type-conditional precedentdeleteBehavioralready set (#9689 / #9784; keeps aZodObject, runs after the superRefine, re-inserts at shape position). TheautonumberFormatblock's reason for avoiding a parse-time default (it would materialize a format on all ~49 types) does not apply: onlyautonumber's output moves.Reach (measured): the parse-time default arrives wherever the field is parsed —
ObjectSchema.create()/defineStack(the showcase'sfield_zoomeasured below),ObjectSchema.parse, the write seam. The objectql registry'svalidate()is a diagnostic (ObjectSchema.parseresult discarded, "registered anyway"), so a definition handed RAW toregisterObjectwithout any parse would not carry it; every in-repo producer measured parses (.object.tsviaObjectSchema.create(),defineStack, the dogfood boot). hotcrm is not attached to this session — PM's reading.H2 — opt-out spelling and docblock
unique: false, written by the author, on the autonumber field. The docblock (autonumber block + theuniquedescribe) says it is legitimate only for a display-only sequence that nothing uses to identify the record, and thatos migrate duplicateskeeps treating every autonumber field as an identifier regardless. No second key.H3 — the
case_numbertemplateunique: 'organization'is whatcrm_case.case_number's hand-writtenunique: trueresolves to (uniqueIndexesFromFields:(COALESCE(organization_id, '__global__'), case_number)on an organization-scoped table; the listed column alone on a table with no tenant column — S11). The pins assert the PARSED default and the driver-facing predicates (isUniqueDeclaredtrue,isOrganizationUniquetrue,isGlobalUniquefalse); the DDL stays the driver's test (sql-driver-unique-tenancy.test.ts, unchanged).H4 — generated artifacts
content/docs/references/data/field.mdx(row:optional (default: false)→optional, description),object.mdx,system/migration.mdx(the new semantic entry),packages/spec/authorable-defaults/data.json(data/Field:unique = falserow removed, declared inDEFAULT_CHANGES_BY_MAJOR[17]),packages/spec/src/migrations/registry.ts(generated block),content/docs/permissions/system-context.mdx(one census anchor re-pointed after the line shift —check:system-context-census --fix).api-surface/unchanged (no export moved).authorable-surface.base.jsonuntouched (never a side effect; the build says so).check:generated: all 15 up to date.H5 — consumers of the parsed
uniqueregisterManagedObjectMetadata/uniqueIndexesFromFields/syncDeclaredIndexesuniquenow getsuniq_OBJECT_organization_id_FIELD(NULL-safe composite), plainuniq_OBJECT_FIELDwhere no tenant columnsql-driver-unique-tenancy.test.tsDDL pins unchangedschema-drift.ts(detectManagedDrift, D4 pre-flight)memory-unique-constraint.tsisUniqueDeclared, organization-scoped)mongodb-schema.ts:266idx_FIELD_unique(single-column, sparse) on autonumber fields; single-column is the settled shape there (#3724: the driver is explicitly single-tenant)migrate/duplicates.ts:428/:474type === 'autonumber'is tested first;uniqueScopeis reported from the authored/parsed valueidentifier: 'autonumber',uniqueScope: nullon an unparsed defseed-tenancy-backfill.ts:1276/:1351tenancy/system-write-organization.ts:302util.ts:313(introspection →field.unique = true)trueunique-scope-install-gate.ts:186'global'spellingdata-model-rules.ts:339–354uniqueDeclared(def?.unique)), i.e. pre-parsefield.form.ts(uniquehelpText)H6 — corpus
Every
type: 'autonumber'without an explicituniqueinexamples/**,packages/platform-objects/**,packages/apps/**, dogfood/qa fixtures: exactly one —examples/app-showcasefield_zoo.f_autonumber(Field.autonumber({ label: 'Auto Number' })). Measured throughdefineStack:showcase_field_zoo.f_autonumber unique="organization". The showcase action paramp_reference(type: 'autonumber') is anActionParamSchemaparam, not aFieldSchemafield — untouched.packages/platform-objectsdeclares no autonumber field. Fixtures seeding duplicate autonumber values: none found (the dogfood matrix readsf_autonumberas "present", minted by the sequence). Dogfood regression suite on the built closure: 130 files / 1012 tests green.Constraint 3 — interaction with #12394 and #8686 (both landed)
#12394 (burned reservation re-issued
ACC-000009, fixed in PR #12554) and #8686 (seed/API tenancy split running two counters for one object, closed) were both "silent duplicate" defects: with no index on the field, a re-issued or twice-minted number was stored. Under the default, the first class becomes a loud unique-violation refusal at the write (the partitioned index bites inside the partition); the second class is cross-partition by construction (__global__vs an organization), which the partitioned index deliberately does not bite — that remainsos migrate duplicates' inventory (#8928) and the seed-tenancy backfill's repair, both unchanged here. No pin in either fix flips:duplicates.pre-repair.test.tsand its siblings pass an UNPARSED{ type: 'autonumber' }to the scan, whose classification is type-first.H7 — pin sweep
Repo-wide grep for assertions that an autonumber may repeat or that
uniquemust be authored on identifiers:unique-scope.test.ts"defaults to false" (text field — still true, kept),unique-scope-message.test.ts"still defaults the same way on each surface" (text vs index — still true, kept),field.test.tsdefault-values pin (text — kept). No pin asserted the OLD autonumber default. The card's "boot log calls autonumber identifiers unique" wording is the seed-tenancy backfill warn (seed-tenancy-backfill.ts:1276) — now accurate by contract, left as is (out of fence).content/docs/data-modeling/field-types.mdx:655statesunique | boolean | false— hand-written page, filed as #14901 (sub-issue of the card, unassigned) per the dispatch's fence. Spec suite: 459 files / 12287 tests green; nothing else moved.H8 — changeset
.changeset/autonumber-default-unique.md:@objectstack/specminor, opens with the BREAKING banner (emitted-shape change, accept set unchanged), carries the M1 messages verbatim, the M2 channel, the opt-out spelling, the #12394 / #8686 paragraph, andadr-0087: registered autonumber-default-unique-organization(the semantic entry above).check:adr-0087-registrationandcheck:changeset-no-majorpass.Out of scope, filed
os migrate planclassifies the same opsafewith no duplicate pre-flight #14902 (unassigned, engine lane): plain unique index over existing duplicates fails the boot with the raw error and no D4 pre-flight (M1-B). Not addressed here.uniqueasboolean, defaultfalse— wrong scope vocabulary, and wrong default for autonumber after #13894 #14901 (unassigned, sub-issue): the hand-written field-types page'suniquerow.Verification record
Worktree
../objectstack-issue-13894, base2263ca4d6; all build/test runs throughscripts/pm/os-verify-lock.sh(verdict lines quoted from the lock).pnpm --filter @objectstack/spec build→VERDICT command-exit 0;check:generated→✓ All 15 generated artifacts are up to date.pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2→Test Files 459 passed (459) · Tests 12287 passed (12287),VERDICT command-exit 0.field-autonumber-default-unique.test.ts→Tests 9 passed (9).git restore --source=2263ca4d6 -- packages/spec/src/data/field.zod.ts(confirmed on disk:patch.uniquecount 0,UniqueScopeSchema.default(false)count 1) → the same pin file:Tests 4 failed | 5 passed (9)— red exactly on the four substance pins (default materialized; ObjectSchema path; builder; JSON Schema carries nodefault), green on the five invariance pins (opt-out, verbatim spellings, other typesfalse, key position, idempotency). Restored withgit checkout HEAD -- FILE:git diff HEADempty,git hash-object=HEADblob (2922b05a…).@objectstack/spec, by name): spec, objectql, driver-sql, lint, types, driver-mongodb, driver-memory, cli, metadata-protocol, rest, metadata → alltypecheck: Done,VERDICT command-exit 0(two lock runs).@objectstack/dogfood, closure built):Test Files 130 passed | 1 skipped (131) · Tests 1012 passed | 3 skipped (1015),VERDICT command-exit 0.node scripts/pm/dispatch-gates.mjs --commands, 75 commands at0504bbc, re-run on the final tree): all exit 0 except —check-test-completeness(exit 3: NOT MEASURED locally by design, needs a turbo test log),check-dev-prereqs(exit 1: 12 workspace packages unbuilt in this container — a prerequisite, not a finding; CI builds fresh),check:dual-build-cjs-loads(exit 3 before the closure build, 0 after),check:skill-examples(exit 1 beforeclient-reactwas built, 0 after: 256 examples type-check),check-system-context-census(2 rotted anchors from the line shift →--fix, 0 after),check:doc-authoring(flagged the issue id in theuniquedescribe → stripped, 0 after).pnpm lintnot run (CI-owned).origin/main(2b9f581, 10 commits, none touchingpackages/specor the drivers; landed viascripts/pm/os-regen-merge.sh, thengen:system-context-censuson the merged tree as its own commit) — re-run on the final head8591ccc:pnpm install --frozen-lockfile(lockfile moved on main's side) →pnpm --filter @objectstack/spec build→check:generated✓ All 15 generated artifacts are up to date.→ the fourunique/field pin filesTests 254 passed (254)→pnpm --filter @objectstack/spec typecheck→check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.→check:changeset-no-major;VERDICT command-exit 0. The full spec suite, consumer typechecks, dogfood and the derived gate sweep above were run on the pre-merge headfb96baf; the merge brought nothing into the packages they cover (scoped re-check per AGENTS.md §10, declared here).🤖 Generated with Claude Code
https://claude.ai/code/session_017xqRv7A8HiJdKxsVm1UCuM
Generated by Claude Code
Generated by Claude Code