Skip to content

feat(spec): autonumber fields default to unique: 'organization'; explicit unique: false opts out (#13894) - #14904

Draft
os-sam wants to merge 5 commits into
mainfrom
claude/issue-13894-autonumber-default-unique
Draft

feat(spec): autonumber fields default to unique: 'organization'; explicit unique: false opts out (#13894)#14904
os-sam wants to merge 5 commits into
mainfrom
claude/issue-13894-autonumber-default-unique

Conversation

@os-sam

@os-sam os-sam commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #13894

Contract change under clause ② (packages/spec/src/** + a published-contract behaviour change) — opened as a DRAFT with needs:contract-review; the PM flips it after the contract review.

Ruling (card body, verbatim)

autonumber 字段默认 unique,租户复合形状照 case_number 既有样板;显式声明退出才不唯一。

What changes

  • FieldSchema.unique loses 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.
  • An authored unique: false on 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.
  • Docblocks: the unique key, the autonumber block (why this default is a parse-time materialization while autonumberFormat stays a JSON-Schema annotation), RUNTIME_OWNED_FIELD_TYPES.
  • Pins (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) at origin/main 2263ca4d6: table crm_quote with rows q1/q2 sharing QUO-00009 in the NULL-organization partition, q3/q4 sharing QUO-00010 in org_x, then initObjects with quote_number: { type: 'autonumber', unique: true }.

M1-A — organization-scoped object (the shape the default resolves to). initObjects does NOT throw. The driver 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).

(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 the warn channel:

[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".

PRAGMA index_list(crm_quote) afterwards: only the PK autoindex — the constraint is NOT enforced, and that is said out loud. detectManagedDrift() (what os migrate plan renders) returns the create_index op with severity: 'error', category: 'destructive' and the same group report. Identical under autoMigrate: 'safe' (dev). Postgres is not live in this container; its shape is pinned by sql-driver-unique-violation-predicate.test.ts (23505, detail Key (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). initObjects THROWS the raw driver error, names no rows and no remedy, logs nothing; detectManagedDrift() calls the op safe ("run os migrate apply to create it") with no pre-flight:

create unique index `uniq_crm_quote_quote_number` on `crm_quote` (`quote_number`) - UNIQUE constraint failed: crm_quote.quote_number   (code=SQLITE_CONSTRAINT_UNIQUE)

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: classifies quote_number as identifier: '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, q2 in __global__ + q5 in org_y, holderCount: 3). By the #8928 ruling it deliberately does NOT list same-partition repeats (QUO-00010 in org_x); those are the D4 channel's (os migrate plan, above). So the operator's remedy channel is: os migrate plan for the blocked index + key groups, os migrate duplicates for row ids of cross-partition mints. Both are named in the changeset and the docblock.

H1 — mechanism, and why it differs from the autonumberFormat block

The drivers read the PARSED field value-only: driver-sql registerManagedObjectMetadata gates the index sync on isUniqueScopeDeclared(f?.unique) (→ spec isUniqueDeclared), uniqueIndexesFromFields reads field.unique; driver-memory isUniqueDeclared(field.unique); driver-mongodb if (field.unique). None sees type, and no driver resolves through a helper that takes the field. So a runtime resolver (the autonumberFormat pattern, where every generator calls resolveAutonumberFormat at 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 sees type nor distinguishes an omitted key from an authored false (the opt-out). Hence .optional() on the key + the .overwrite() tail — the measured type-conditional precedent deleteBehavior already set (#9689 / #9784; keeps a ZodObject, runs after the superRefine, re-inserts at shape position). The autonumberFormat block's reason for avoiding a parse-time default (it would materialize a format on all ~49 types) does not apply: only autonumber's output moves.

Reach (measured): the parse-time default arrives wherever the field is parsed — ObjectSchema.create() / defineStack (the showcase's field_zoo measured below), ObjectSchema.parse, the write seam. The objectql registry's validate() is a diagnostic (ObjectSchema.parse result discarded, "registered anyway"), so a definition handed RAW to registerObject without any parse would not carry it; every in-repo producer measured parses (.object.ts via ObjectSchema.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 + the unique describe) says it is legitimate only for a display-only sequence that nothing uses to identify the record, and that os migrate duplicates keeps treating every autonumber field as an identifier regardless. No second key.

H3 — the case_number template

unique: 'organization' is what crm_case.case_number's hand-written unique: true resolves 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 (isUniqueDeclared true, isOrganizationUnique true, isGlobalUnique false); 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 = false row removed, declared in DEFAULT_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.json untouched (never a side effect; the build says so). check:generated: all 15 up to date.

H5 — consumers of the parsed unique

consumer after the flip evidence
driver-sql registerManagedObjectMetadata / uniqueIndexesFromFields / syncDeclaredIndexes changed — every autonumber field without an authored unique now gets uniq_OBJECT_organization_id_FIELD (NULL-safe composite), plain uniq_OBJECT_FIELD where no tenant column M1 above; sql-driver-unique-tenancy.test.ts DDL pins unchanged
driver-sql schema-drift.ts (detectManagedDrift, D4 pre-flight) changed — reports the missing/blocked index for those fields M1-A drift entry
driver-memory memory-unique-constraint.ts changed — in-memory unique enforcement now covers autonumber fields (isUniqueDeclared, organization-scoped) read; dogfood suite green
driver-mongodb mongodb-schema.ts:266 changedidx_FIELD_unique (single-column, sparse) on autonumber fields; single-column is the settled shape there (#3724: the driver is explicitly single-tenant) read
cli migrate/duplicates.ts:428/:474 unchangedtype === 'autonumber' is tested first; uniqueScope is reported from the authored/parsed value M2: identifier: 'autonumber', uniqueScope: null on an unparsed def
metadata-protocol seed-tenancy-backfill.ts:1276/:1351 unchanged — "can mint the same "unique" identifier twice" is now literally true by contract; the cross-partition mechanism it describes (two counters, one per partition) is unaffected by an index that partitions by the same key read; not in scope
objectql tenancy/system-write-organization.ts:302 unchanged — the refusal text already assumes the partitioned unique index read; not in scope
objectql util.ts:313 (introspection → field.unique = true) unchanged — writes an authored true read
types unique-scope-install-gate.ts:186 unchanged — reads only the 'global' spelling read
lint data-model-rules.ts:339–354 unchanged — reads the authored def (uniqueDeclared(def?.unique)), i.e. pre-parse read
forms field.form.ts (unique helpText) unchanged — a form hint read
objectui PM's reading — not attached

H6 — corpus

Every type: 'autonumber' without an explicit unique in examples/**, packages/platform-objects/**, packages/apps/**, dogfood/qa fixtures: exactly one — examples/app-showcase field_zoo.f_autonumber (Field.autonumber({ label: 'Auto Number' })). Measured through defineStack: showcase_field_zoo.f_autonumber unique="organization". The showcase action param p_reference (type: 'autonumber') is an ActionParamSchema param, not a FieldSchema field — untouched. packages/platform-objects declares no autonumber field. Fixtures seeding duplicate autonumber values: none found (the dogfood matrix reads f_autonumber as "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 remains os migrate duplicates' inventory (#8928) and the seed-tenancy backfill's repair, both unchanged here. No pin in either fix flips: duplicates.pre-repair.test.ts and 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 unique must 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.ts default-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:655 states unique | 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/spec minor, 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, and adr-0087: registered autonumber-default-unique-organization (the semantic entry above). check:adr-0087-registration and check:changeset-no-major pass.

Out of scope, filed

Verification record

Worktree ../objectstack-issue-13894, base 2263ca4d6; all build/test runs through scripts/pm/os-verify-lock.sh (verdict lines quoted from the lock).

  • pnpm --filter @objectstack/spec buildVERDICT command-exit 0; check:generated✓ All 15 generated artifacts are up to date.
  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2Test Files 459 passed (459) · Tests 12287 passed (12287), VERDICT command-exit 0.
  • Pins: field-autonumber-default-unique.test.tsTests 9 passed (9).
  • Reverse verification (fix committed first): git restore --source=2263ca4d6 -- packages/spec/src/data/field.zod.ts (confirmed on disk: patch.unique count 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 no default), green on the five invariance pins (opt-out, verbatim spellings, other types false, key position, idempotency). Restored with git checkout HEAD -- FILE: git diff HEAD empty, git hash-object = HEAD blob (2922b05a…).
  • Typecheck (downstream consumers of @objectstack/spec, by name): spec, objectql, driver-sql, lint, types, driver-mongodb, driver-memory, cli, metadata-protocol, rest, metadata → all typecheck: Done, VERDICT command-exit 0 (two lock runs).
  • Dogfood (@objectstack/dogfood, closure built): Test Files 130 passed | 1 skipped (131) · Tests 1012 passed | 3 skipped (1015), VERDICT command-exit 0.
  • Derived gates (node scripts/pm/dispatch-gates.mjs --commands, 75 commands at 0504bbc, 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 before client-react was 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 the unique describe → stripped, 0 after).
  • Repo-wide pnpm lint not run (CI-owned).
  • After merging origin/main (2b9f581, 10 commits, none touching packages/spec or the drivers; landed via scripts/pm/os-regen-merge.sh, then gen:system-context-census on the merged tree as its own commit) — re-run on the final head 8591ccc: pnpm install --frozen-lockfile (lockfile moved on main's side) → pnpm --filter @objectstack/spec buildcheck:generated ✓ All 15 generated artifacts are up to date. → the four unique/field pin files Tests 254 passed (254)pnpm --filter @objectstack/spec typecheckcheck-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 head fb96baf; 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

…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
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 4 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-defaults/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-defaults/data.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bdpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 42ddc5a7aaf8782be3c47c59d0e9dd6fe90e15bc — the merge of head 8591cccc12d6f361252afa8e119c50af8b3a8e66 into base 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

autonumber 业务标识字段默认不唯一,可铸重号 —— 裁定:默认唯一(租户复合形状),显式退出才不唯一

2 participants