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

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13894-autonumber-default-unique
Sep 3, 2026
Merged

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

Conversation

@os-sam

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/main2263ca4d6: 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

consumerafter the flipevidence
driver-sql registerManagedObjectMetadata / uniqueIndexesFromFields / syncDeclaredIndexeschanged — every autonumber field without an authored unique now gets uniq_OBJECT_organization_id_FIELD (NULL-safe composite), plain uniq_OBJECT_FIELD where no tenant columnM1 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 fieldsM1-A drift entry
driver-memory memory-unique-constraint.tschanged — in-memory unique enforcement now covers autonumber fields (isUniqueDeclared, organization-scoped)read; dogfood suite green
driver-mongodb mongodb-schema.ts:266changedidx_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/:474unchangedtype === 'autonumber' is tested first; uniqueScope is reported from the authored/parsed valueM2: identifier: 'autonumber', uniqueScope: null on an unparsed def
metadata-protocol seed-tenancy-backfill.ts:1276/:1351unchanged — "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 keyread; not in scope
objectql tenancy/system-write-organization.ts:302unchanged — the refusal text already assumes the partitioned unique indexread; not in scope
objectql util.ts:313 (introspection → field.unique = true)unchanged — writes an authored trueread
types unique-scope-install-gate.ts:186unchanged — reads only the 'global' spellingread
lint data-model-rules.ts:339–354unchanged — reads the authored def (uniqueDeclared(def?.unique)), i.e. pre-parseread
forms field.form.ts (unique helpText)unchanged — a form hintread
objectuiPM'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-showcasefield_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/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, 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

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.

@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review: PASS — head 8591cccc1, CONTRACT_REVIEW_TIER review by the director seat (take-over of a stalled in-seat review)

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The dispatching domain:spec seat went off shift after dispatch with no collection or review on record; take-over per the 2026-08-31 ruling. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments — the emitted shape moves, the accept set does not

  • Emitted shape: an autonumber field that omits unique now parses to unique: 'organization'; every other type still parses to unique: false at the same shape position (verified in the .overwrite() tail: the patch is applied only when field.unique === undefined, keyed on type === 'autonumber', re-inserted in shapeOrder). Idempotent by construction — 'organization' is itself an accepted spelling.
  • Accept set: unchanged. true / 'organization' / 'global' / false all parse verbatim on every type; false written by the author is the whole opt-out surface, as the ruling's constraint 1 requires.
  • Published JSON Schema:Field.unique loses default: false (a single value would be wrong for one of the two cases). Recorded where the ratchet looks: the data/Field:unique = false → (none) row in DEFAULT_CHANGES_BY_MAJOR, the authorable-defaults shard, the ADR-0087 semantic entry autonumber-default-unique-organization, and the regenerated reference pages. Inferred output type declares unique? — the accepted ADR-0122 cost, same as deleteBehavior; consumer typechecks are green in CI.
  • Deployed behaviour: drivers read the parsed unique value-only, so the tenant-composite NULL-safe index materialises on the next serving boot. Existing duplicates meet ADR-0120 D4's loud degrade (driver error naming the index and remedy, drift pass naming the key groups, os migrate plan blocked) — measured, not inferred (M1-A). Constraint 2 forbids silent degrade; this one is on the error channel with rows and remedy.
  • Ruling fidelity: card body verbatim 「autonumber 字段默认 unique,租户复合形状照 case_number 既有样板;显式声明退出才不唯一。」 — met on all three clauses.

② Semver vs changeset

@objectstack/specminor with the BREAKING banner and the ADR-0087 registration marker — the repo's launch-window convention for a breaking emitted-shape change (check:changeset-no-major and check-adr-0087-registration green).

③ Boundary flags (the two open_questions in the dev report)

  1. Reconsider the D4 "do not take the boot down" posture for the autonumber default?No. ADR-0120 D4 / driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 is an accepted maintainer decision, and the ruling's constraint 2 is satisfied by a loud, remedied degrade. Not reopened; driver-sql: a PLAIN unique index (no organization key part) over existing duplicate rows fails the boot with the raw driver error naming no rows and no remedy, and os migrate plan classifies the same op safe with no duplicate pre-flight #14902 (the plain-unique boot failure with no pre-flight) stays the engine lane's card.
  2. Does the parse-time default reach hotcrm?Measured by this seat on hotcrm origin/main0ed728e: ten autonumber fields, every one declared through Field.autonumber({...}) inside ObjectSchema.create(), i.e. parsed — the default applies. hotcrm pins the published @objectstack/spec 17.2.0 (not a git SHA), so the cross-repo unlock is a spec release plus a hotcrm bump, not this merge; nothing further for this PR.

#14901 (hand-written field-types row) and #14902 filed unassigned — correct routing.

CI on 8591cccc1: every check green (38 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403). Card-side carrier: #13894 never received the label (seat off shift) — nothing to strip on the card.

Landing:needs:contract-review stripped from this PR, marked ready, auto-merge armed. Not governed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants

@os-sam@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13894-autonumber-default-unique
Sep 3, 2026
Merged

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

Conversation

@os-sam

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/main2263ca4d6: 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

consumerafter the flipevidence
driver-sql registerManagedObjectMetadata / uniqueIndexesFromFields / syncDeclaredIndexeschanged — every autonumber field without an authored unique now gets uniq_OBJECT_organization_id_FIELD (NULL-safe composite), plain uniq_OBJECT_FIELD where no tenant columnM1 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 fieldsM1-A drift entry
driver-memory memory-unique-constraint.tschanged — in-memory unique enforcement now covers autonumber fields (isUniqueDeclared, organization-scoped)read; dogfood suite green
driver-mongodb mongodb-schema.ts:266changedidx_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/:474unchangedtype === 'autonumber' is tested first; uniqueScope is reported from the authored/parsed valueM2: identifier: 'autonumber', uniqueScope: null on an unparsed def
metadata-protocol seed-tenancy-backfill.ts:1276/:1351unchanged — "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 keyread; not in scope
objectql tenancy/system-write-organization.ts:302unchanged — the refusal text already assumes the partitioned unique indexread; not in scope
objectql util.ts:313 (introspection → field.unique = true)unchanged — writes an authored trueread
types unique-scope-install-gate.ts:186unchanged — reads only the 'global' spellingread
lint data-model-rules.ts:339–354unchanged — reads the authored def (uniqueDeclared(def?.unique)), i.e. pre-parseread
forms field.form.ts (unique helpText)unchanged — a form hintread
objectuiPM'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-showcasefield_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/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, 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

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.

@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review: PASS — head 8591cccc1, CONTRACT_REVIEW_TIER review by the director seat (take-over of a stalled in-seat review)

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The dispatching domain:spec seat went off shift after dispatch with no collection or review on record; take-over per the 2026-08-31 ruling. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments — the emitted shape moves, the accept set does not

  • Emitted shape: an autonumber field that omits unique now parses to unique: 'organization'; every other type still parses to unique: false at the same shape position (verified in the .overwrite() tail: the patch is applied only when field.unique === undefined, keyed on type === 'autonumber', re-inserted in shapeOrder). Idempotent by construction — 'organization' is itself an accepted spelling.
  • Accept set: unchanged. true / 'organization' / 'global' / false all parse verbatim on every type; false written by the author is the whole opt-out surface, as the ruling's constraint 1 requires.
  • Published JSON Schema:Field.unique loses default: false (a single value would be wrong for one of the two cases). Recorded where the ratchet looks: the data/Field:unique = false → (none) row in DEFAULT_CHANGES_BY_MAJOR, the authorable-defaults shard, the ADR-0087 semantic entry autonumber-default-unique-organization, and the regenerated reference pages. Inferred output type declares unique? — the accepted ADR-0122 cost, same as deleteBehavior; consumer typechecks are green in CI.
  • Deployed behaviour: drivers read the parsed unique value-only, so the tenant-composite NULL-safe index materialises on the next serving boot. Existing duplicates meet ADR-0120 D4's loud degrade (driver error naming the index and remedy, drift pass naming the key groups, os migrate plan blocked) — measured, not inferred (M1-A). Constraint 2 forbids silent degrade; this one is on the error channel with rows and remedy.
  • Ruling fidelity: card body verbatim 「autonumber 字段默认 unique,租户复合形状照 case_number 既有样板;显式声明退出才不唯一。」 — met on all three clauses.

② Semver vs changeset

@objectstack/specminor with the BREAKING banner and the ADR-0087 registration marker — the repo's launch-window convention for a breaking emitted-shape change (check:changeset-no-major and check-adr-0087-registration green).

③ Boundary flags (the two open_questions in the dev report)

  1. Reconsider the D4 "do not take the boot down" posture for the autonumber default?No. ADR-0120 D4 / driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 is an accepted maintainer decision, and the ruling's constraint 2 is satisfied by a loud, remedied degrade. Not reopened; driver-sql: a PLAIN unique index (no organization key part) over existing duplicate rows fails the boot with the raw driver error naming no rows and no remedy, and os migrate plan classifies the same op safe with no duplicate pre-flight #14902 (the plain-unique boot failure with no pre-flight) stays the engine lane's card.
  2. Does the parse-time default reach hotcrm?Measured by this seat on hotcrm origin/main0ed728e: ten autonumber fields, every one declared through Field.autonumber({...}) inside ObjectSchema.create(), i.e. parsed — the default applies. hotcrm pins the published @objectstack/spec 17.2.0 (not a git SHA), so the cross-repo unlock is a spec release plus a hotcrm bump, not this merge; nothing further for this PR.

#14901 (hand-written field-types row) and #14902 filed unassigned — correct routing.

CI on 8591cccc1: every check green (38 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403). Card-side carrier: #13894 never received the label (seat off shift) — nothing to strip on the card.

Landing:needs:contract-review stripped from this PR, marked ready, auto-merge armed. Not governed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants

@os-sam@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13894-autonumber-default-unique
Sep 3, 2026
Merged

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

Conversation

@os-sam

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/main2263ca4d6: 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

consumerafter the flipevidence
driver-sql registerManagedObjectMetadata / uniqueIndexesFromFields / syncDeclaredIndexeschanged — every autonumber field without an authored unique now gets uniq_OBJECT_organization_id_FIELD (NULL-safe composite), plain uniq_OBJECT_FIELD where no tenant columnM1 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 fieldsM1-A drift entry
driver-memory memory-unique-constraint.tschanged — in-memory unique enforcement now covers autonumber fields (isUniqueDeclared, organization-scoped)read; dogfood suite green
driver-mongodb mongodb-schema.ts:266changedidx_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/:474unchangedtype === 'autonumber' is tested first; uniqueScope is reported from the authored/parsed valueM2: identifier: 'autonumber', uniqueScope: null on an unparsed def
metadata-protocol seed-tenancy-backfill.ts:1276/:1351unchanged — "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 keyread; not in scope
objectql tenancy/system-write-organization.ts:302unchanged — the refusal text already assumes the partitioned unique indexread; not in scope
objectql util.ts:313 (introspection → field.unique = true)unchanged — writes an authored trueread
types unique-scope-install-gate.ts:186unchanged — reads only the 'global' spellingread
lint data-model-rules.ts:339–354unchanged — reads the authored def (uniqueDeclared(def?.unique)), i.e. pre-parseread
forms field.form.ts (unique helpText)unchanged — a form hintread
objectuiPM'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-showcasefield_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/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, 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

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.

@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review: PASS — head 8591cccc1, CONTRACT_REVIEW_TIER review by the director seat (take-over of a stalled in-seat review)

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The dispatching domain:spec seat went off shift after dispatch with no collection or review on record; take-over per the 2026-08-31 ruling. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments — the emitted shape moves, the accept set does not

  • Emitted shape: an autonumber field that omits unique now parses to unique: 'organization'; every other type still parses to unique: false at the same shape position (verified in the .overwrite() tail: the patch is applied only when field.unique === undefined, keyed on type === 'autonumber', re-inserted in shapeOrder). Idempotent by construction — 'organization' is itself an accepted spelling.
  • Accept set: unchanged. true / 'organization' / 'global' / false all parse verbatim on every type; false written by the author is the whole opt-out surface, as the ruling's constraint 1 requires.
  • Published JSON Schema:Field.unique loses default: false (a single value would be wrong for one of the two cases). Recorded where the ratchet looks: the data/Field:unique = false → (none) row in DEFAULT_CHANGES_BY_MAJOR, the authorable-defaults shard, the ADR-0087 semantic entry autonumber-default-unique-organization, and the regenerated reference pages. Inferred output type declares unique? — the accepted ADR-0122 cost, same as deleteBehavior; consumer typechecks are green in CI.
  • Deployed behaviour: drivers read the parsed unique value-only, so the tenant-composite NULL-safe index materialises on the next serving boot. Existing duplicates meet ADR-0120 D4's loud degrade (driver error naming the index and remedy, drift pass naming the key groups, os migrate plan blocked) — measured, not inferred (M1-A). Constraint 2 forbids silent degrade; this one is on the error channel with rows and remedy.
  • Ruling fidelity: card body verbatim 「autonumber 字段默认 unique,租户复合形状照 case_number 既有样板;显式声明退出才不唯一。」 — met on all three clauses.

② Semver vs changeset

@objectstack/specminor with the BREAKING banner and the ADR-0087 registration marker — the repo's launch-window convention for a breaking emitted-shape change (check:changeset-no-major and check-adr-0087-registration green).

③ Boundary flags (the two open_questions in the dev report)

  1. Reconsider the D4 "do not take the boot down" posture for the autonumber default?No. ADR-0120 D4 / driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 is an accepted maintainer decision, and the ruling's constraint 2 is satisfied by a loud, remedied degrade. Not reopened; driver-sql: a PLAIN unique index (no organization key part) over existing duplicate rows fails the boot with the raw driver error naming no rows and no remedy, and os migrate plan classifies the same op safe with no duplicate pre-flight #14902 (the plain-unique boot failure with no pre-flight) stays the engine lane's card.
  2. Does the parse-time default reach hotcrm?Measured by this seat on hotcrm origin/main0ed728e: ten autonumber fields, every one declared through Field.autonumber({...}) inside ObjectSchema.create(), i.e. parsed — the default applies. hotcrm pins the published @objectstack/spec 17.2.0 (not a git SHA), so the cross-repo unlock is a spec release plus a hotcrm bump, not this merge; nothing further for this PR.

#14901 (hand-written field-types row) and #14902 filed unassigned — correct routing.

CI on 8591cccc1: every check green (38 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403). Card-side carrier: #13894 never received the label (seat off shift) — nothing to strip on the card.

Landing:needs:contract-review stripped from this PR, marked ready, auto-merge armed. Not governed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants

@os-sam@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13894-autonumber-default-unique
Sep 3, 2026
Merged

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

Conversation

@os-sam

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/main2263ca4d6: 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

consumerafter the flipevidence
driver-sql registerManagedObjectMetadata / uniqueIndexesFromFields / syncDeclaredIndexeschanged — every autonumber field without an authored unique now gets uniq_OBJECT_organization_id_FIELD (NULL-safe composite), plain uniq_OBJECT_FIELD where no tenant columnM1 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 fieldsM1-A drift entry
driver-memory memory-unique-constraint.tschanged — in-memory unique enforcement now covers autonumber fields (isUniqueDeclared, organization-scoped)read; dogfood suite green
driver-mongodb mongodb-schema.ts:266changedidx_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/:474unchangedtype === 'autonumber' is tested first; uniqueScope is reported from the authored/parsed valueM2: identifier: 'autonumber', uniqueScope: null on an unparsed def
metadata-protocol seed-tenancy-backfill.ts:1276/:1351unchanged — "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 keyread; not in scope
objectql tenancy/system-write-organization.ts:302unchanged — the refusal text already assumes the partitioned unique indexread; not in scope
objectql util.ts:313 (introspection → field.unique = true)unchanged — writes an authored trueread
types unique-scope-install-gate.ts:186unchanged — reads only the 'global' spellingread
lint data-model-rules.ts:339–354unchanged — reads the authored def (uniqueDeclared(def?.unique)), i.e. pre-parseread
forms field.form.ts (unique helpText)unchanged — a form hintread
objectuiPM'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-showcasefield_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/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, 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

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.

@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review: PASS — head 8591cccc1, CONTRACT_REVIEW_TIER review by the director seat (take-over of a stalled in-seat review)

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The dispatching domain:spec seat went off shift after dispatch with no collection or review on record; take-over per the 2026-08-31 ruling. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments — the emitted shape moves, the accept set does not

  • Emitted shape: an autonumber field that omits unique now parses to unique: 'organization'; every other type still parses to unique: false at the same shape position (verified in the .overwrite() tail: the patch is applied only when field.unique === undefined, keyed on type === 'autonumber', re-inserted in shapeOrder). Idempotent by construction — 'organization' is itself an accepted spelling.
  • Accept set: unchanged. true / 'organization' / 'global' / false all parse verbatim on every type; false written by the author is the whole opt-out surface, as the ruling's constraint 1 requires.
  • Published JSON Schema:Field.unique loses default: false (a single value would be wrong for one of the two cases). Recorded where the ratchet looks: the data/Field:unique = false → (none) row in DEFAULT_CHANGES_BY_MAJOR, the authorable-defaults shard, the ADR-0087 semantic entry autonumber-default-unique-organization, and the regenerated reference pages. Inferred output type declares unique? — the accepted ADR-0122 cost, same as deleteBehavior; consumer typechecks are green in CI.
  • Deployed behaviour: drivers read the parsed unique value-only, so the tenant-composite NULL-safe index materialises on the next serving boot. Existing duplicates meet ADR-0120 D4's loud degrade (driver error naming the index and remedy, drift pass naming the key groups, os migrate plan blocked) — measured, not inferred (M1-A). Constraint 2 forbids silent degrade; this one is on the error channel with rows and remedy.
  • Ruling fidelity: card body verbatim 「autonumber 字段默认 unique,租户复合形状照 case_number 既有样板;显式声明退出才不唯一。」 — met on all three clauses.

② Semver vs changeset

@objectstack/specminor with the BREAKING banner and the ADR-0087 registration marker — the repo's launch-window convention for a breaking emitted-shape change (check:changeset-no-major and check-adr-0087-registration green).

③ Boundary flags (the two open_questions in the dev report)

  1. Reconsider the D4 "do not take the boot down" posture for the autonumber default?No. ADR-0120 D4 / driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 is an accepted maintainer decision, and the ruling's constraint 2 is satisfied by a loud, remedied degrade. Not reopened; driver-sql: a PLAIN unique index (no organization key part) over existing duplicate rows fails the boot with the raw driver error naming no rows and no remedy, and os migrate plan classifies the same op safe with no duplicate pre-flight #14902 (the plain-unique boot failure with no pre-flight) stays the engine lane's card.
  2. Does the parse-time default reach hotcrm?Measured by this seat on hotcrm origin/main0ed728e: ten autonumber fields, every one declared through Field.autonumber({...}) inside ObjectSchema.create(), i.e. parsed — the default applies. hotcrm pins the published @objectstack/spec 17.2.0 (not a git SHA), so the cross-repo unlock is a spec release plus a hotcrm bump, not this merge; nothing further for this PR.

#14901 (hand-written field-types row) and #14902 filed unassigned — correct routing.

CI on 8591cccc1: every check green (38 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403). Card-side carrier: #13894 never received the label (seat off shift) — nothing to strip on the card.

Landing:needs:contract-review stripped from this PR, marked ready, auto-merge armed. Not governed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants

@os-sam@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13894-autonumber-default-unique
Sep 3, 2026
Merged

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

Conversation

@os-sam

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/main2263ca4d6: 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

consumerafter the flipevidence
driver-sql registerManagedObjectMetadata / uniqueIndexesFromFields / syncDeclaredIndexeschanged — every autonumber field without an authored unique now gets uniq_OBJECT_organization_id_FIELD (NULL-safe composite), plain uniq_OBJECT_FIELD where no tenant columnM1 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 fieldsM1-A drift entry
driver-memory memory-unique-constraint.tschanged — in-memory unique enforcement now covers autonumber fields (isUniqueDeclared, organization-scoped)read; dogfood suite green
driver-mongodb mongodb-schema.ts:266changedidx_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/:474unchangedtype === 'autonumber' is tested first; uniqueScope is reported from the authored/parsed valueM2: identifier: 'autonumber', uniqueScope: null on an unparsed def
metadata-protocol seed-tenancy-backfill.ts:1276/:1351unchanged — "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 keyread; not in scope
objectql tenancy/system-write-organization.ts:302unchanged — the refusal text already assumes the partitioned unique indexread; not in scope
objectql util.ts:313 (introspection → field.unique = true)unchanged — writes an authored trueread
types unique-scope-install-gate.ts:186unchanged — reads only the 'global' spellingread
lint data-model-rules.ts:339–354unchanged — reads the authored def (uniqueDeclared(def?.unique)), i.e. pre-parseread
forms field.form.ts (unique helpText)unchanged — a form hintread
objectuiPM'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-showcasefield_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/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, 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

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.

@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review: PASS — head 8591cccc1, CONTRACT_REVIEW_TIER review by the director seat (take-over of a stalled in-seat review)

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The dispatching domain:spec seat went off shift after dispatch with no collection or review on record; take-over per the 2026-08-31 ruling. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments — the emitted shape moves, the accept set does not

  • Emitted shape: an autonumber field that omits unique now parses to unique: 'organization'; every other type still parses to unique: false at the same shape position (verified in the .overwrite() tail: the patch is applied only when field.unique === undefined, keyed on type === 'autonumber', re-inserted in shapeOrder). Idempotent by construction — 'organization' is itself an accepted spelling.
  • Accept set: unchanged. true / 'organization' / 'global' / false all parse verbatim on every type; false written by the author is the whole opt-out surface, as the ruling's constraint 1 requires.
  • Published JSON Schema:Field.unique loses default: false (a single value would be wrong for one of the two cases). Recorded where the ratchet looks: the data/Field:unique = false → (none) row in DEFAULT_CHANGES_BY_MAJOR, the authorable-defaults shard, the ADR-0087 semantic entry autonumber-default-unique-organization, and the regenerated reference pages. Inferred output type declares unique? — the accepted ADR-0122 cost, same as deleteBehavior; consumer typechecks are green in CI.
  • Deployed behaviour: drivers read the parsed unique value-only, so the tenant-composite NULL-safe index materialises on the next serving boot. Existing duplicates meet ADR-0120 D4's loud degrade (driver error naming the index and remedy, drift pass naming the key groups, os migrate plan blocked) — measured, not inferred (M1-A). Constraint 2 forbids silent degrade; this one is on the error channel with rows and remedy.
  • Ruling fidelity: card body verbatim 「autonumber 字段默认 unique,租户复合形状照 case_number 既有样板;显式声明退出才不唯一。」 — met on all three clauses.

② Semver vs changeset

@objectstack/specminor with the BREAKING banner and the ADR-0087 registration marker — the repo's launch-window convention for a breaking emitted-shape change (check:changeset-no-major and check-adr-0087-registration green).

③ Boundary flags (the two open_questions in the dev report)

  1. Reconsider the D4 "do not take the boot down" posture for the autonumber default?No. ADR-0120 D4 / driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 is an accepted maintainer decision, and the ruling's constraint 2 is satisfied by a loud, remedied degrade. Not reopened; driver-sql: a PLAIN unique index (no organization key part) over existing duplicate rows fails the boot with the raw driver error naming no rows and no remedy, and os migrate plan classifies the same op safe with no duplicate pre-flight #14902 (the plain-unique boot failure with no pre-flight) stays the engine lane's card.
  2. Does the parse-time default reach hotcrm?Measured by this seat on hotcrm origin/main0ed728e: ten autonumber fields, every one declared through Field.autonumber({...}) inside ObjectSchema.create(), i.e. parsed — the default applies. hotcrm pins the published @objectstack/spec 17.2.0 (not a git SHA), so the cross-repo unlock is a spec release plus a hotcrm bump, not this merge; nothing further for this PR.

#14901 (hand-written field-types row) and #14902 filed unassigned — correct routing.

CI on 8591cccc1: every check green (38 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403). Card-side carrier: #13894 never received the label (seat off shift) — nothing to strip on the card.

Landing:needs:contract-review stripped from this PR, marked ready, auto-merge armed. Not governed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants

@os-sam@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13894-autonumber-default-unique
Sep 3, 2026
Merged

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

Conversation

@os-sam

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/main2263ca4d6: 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

consumerafter the flipevidence
driver-sql registerManagedObjectMetadata / uniqueIndexesFromFields / syncDeclaredIndexeschanged — every autonumber field without an authored unique now gets uniq_OBJECT_organization_id_FIELD (NULL-safe composite), plain uniq_OBJECT_FIELD where no tenant columnM1 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 fieldsM1-A drift entry
driver-memory memory-unique-constraint.tschanged — in-memory unique enforcement now covers autonumber fields (isUniqueDeclared, organization-scoped)read; dogfood suite green
driver-mongodb mongodb-schema.ts:266changedidx_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/:474unchangedtype === 'autonumber' is tested first; uniqueScope is reported from the authored/parsed valueM2: identifier: 'autonumber', uniqueScope: null on an unparsed def
metadata-protocol seed-tenancy-backfill.ts:1276/:1351unchanged — "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 keyread; not in scope
objectql tenancy/system-write-organization.ts:302unchanged — the refusal text already assumes the partitioned unique indexread; not in scope
objectql util.ts:313 (introspection → field.unique = true)unchanged — writes an authored trueread
types unique-scope-install-gate.ts:186unchanged — reads only the 'global' spellingread
lint data-model-rules.ts:339–354unchanged — reads the authored def (uniqueDeclared(def?.unique)), i.e. pre-parseread
forms field.form.ts (unique helpText)unchanged — a form hintread
objectuiPM'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-showcasefield_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/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, 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

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.

@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review: PASS — head 8591cccc1, CONTRACT_REVIEW_TIER review by the director seat (take-over of a stalled in-seat review)

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The dispatching domain:spec seat went off shift after dispatch with no collection or review on record; take-over per the 2026-08-31 ruling. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments — the emitted shape moves, the accept set does not

  • Emitted shape: an autonumber field that omits unique now parses to unique: 'organization'; every other type still parses to unique: false at the same shape position (verified in the .overwrite() tail: the patch is applied only when field.unique === undefined, keyed on type === 'autonumber', re-inserted in shapeOrder). Idempotent by construction — 'organization' is itself an accepted spelling.
  • Accept set: unchanged. true / 'organization' / 'global' / false all parse verbatim on every type; false written by the author is the whole opt-out surface, as the ruling's constraint 1 requires.
  • Published JSON Schema:Field.unique loses default: false (a single value would be wrong for one of the two cases). Recorded where the ratchet looks: the data/Field:unique = false → (none) row in DEFAULT_CHANGES_BY_MAJOR, the authorable-defaults shard, the ADR-0087 semantic entry autonumber-default-unique-organization, and the regenerated reference pages. Inferred output type declares unique? — the accepted ADR-0122 cost, same as deleteBehavior; consumer typechecks are green in CI.
  • Deployed behaviour: drivers read the parsed unique value-only, so the tenant-composite NULL-safe index materialises on the next serving boot. Existing duplicates meet ADR-0120 D4's loud degrade (driver error naming the index and remedy, drift pass naming the key groups, os migrate plan blocked) — measured, not inferred (M1-A). Constraint 2 forbids silent degrade; this one is on the error channel with rows and remedy.
  • Ruling fidelity: card body verbatim 「autonumber 字段默认 unique,租户复合形状照 case_number 既有样板;显式声明退出才不唯一。」 — met on all three clauses.

② Semver vs changeset

@objectstack/specminor with the BREAKING banner and the ADR-0087 registration marker — the repo's launch-window convention for a breaking emitted-shape change (check:changeset-no-major and check-adr-0087-registration green).

③ Boundary flags (the two open_questions in the dev report)

  1. Reconsider the D4 "do not take the boot down" posture for the autonumber default?No. ADR-0120 D4 / driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 is an accepted maintainer decision, and the ruling's constraint 2 is satisfied by a loud, remedied degrade. Not reopened; driver-sql: a PLAIN unique index (no organization key part) over existing duplicate rows fails the boot with the raw driver error naming no rows and no remedy, and os migrate plan classifies the same op safe with no duplicate pre-flight #14902 (the plain-unique boot failure with no pre-flight) stays the engine lane's card.
  2. Does the parse-time default reach hotcrm?Measured by this seat on hotcrm origin/main0ed728e: ten autonumber fields, every one declared through Field.autonumber({...}) inside ObjectSchema.create(), i.e. parsed — the default applies. hotcrm pins the published @objectstack/spec 17.2.0 (not a git SHA), so the cross-repo unlock is a spec release plus a hotcrm bump, not this merge; nothing further for this PR.

#14901 (hand-written field-types row) and #14902 filed unassigned — correct routing.

CI on 8591cccc1: every check green (38 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403). Card-side carrier: #13894 never received the label (seat off shift) — nothing to strip on the card.

Landing:needs:contract-review stripped from this PR, marked ready, auto-merge armed. Not governed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants

@os-sam@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13894-autonumber-default-unique
Sep 3, 2026
Merged

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

Conversation

@os-sam

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/main2263ca4d6: 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

consumerafter the flipevidence
driver-sql registerManagedObjectMetadata / uniqueIndexesFromFields / syncDeclaredIndexeschanged — every autonumber field without an authored unique now gets uniq_OBJECT_organization_id_FIELD (NULL-safe composite), plain uniq_OBJECT_FIELD where no tenant columnM1 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 fieldsM1-A drift entry
driver-memory memory-unique-constraint.tschanged — in-memory unique enforcement now covers autonumber fields (isUniqueDeclared, organization-scoped)read; dogfood suite green
driver-mongodb mongodb-schema.ts:266changedidx_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/:474unchangedtype === 'autonumber' is tested first; uniqueScope is reported from the authored/parsed valueM2: identifier: 'autonumber', uniqueScope: null on an unparsed def
metadata-protocol seed-tenancy-backfill.ts:1276/:1351unchanged — "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 keyread; not in scope
objectql tenancy/system-write-organization.ts:302unchanged — the refusal text already assumes the partitioned unique indexread; not in scope
objectql util.ts:313 (introspection → field.unique = true)unchanged — writes an authored trueread
types unique-scope-install-gate.ts:186unchanged — reads only the 'global' spellingread
lint data-model-rules.ts:339–354unchanged — reads the authored def (uniqueDeclared(def?.unique)), i.e. pre-parseread
forms field.form.ts (unique helpText)unchanged — a form hintread
objectuiPM'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-showcasefield_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/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, 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

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.

@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review: PASS — head 8591cccc1, CONTRACT_REVIEW_TIER review by the director seat (take-over of a stalled in-seat review)

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The dispatching domain:spec seat went off shift after dispatch with no collection or review on record; take-over per the 2026-08-31 ruling. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments — the emitted shape moves, the accept set does not

  • Emitted shape: an autonumber field that omits unique now parses to unique: 'organization'; every other type still parses to unique: false at the same shape position (verified in the .overwrite() tail: the patch is applied only when field.unique === undefined, keyed on type === 'autonumber', re-inserted in shapeOrder). Idempotent by construction — 'organization' is itself an accepted spelling.
  • Accept set: unchanged. true / 'organization' / 'global' / false all parse verbatim on every type; false written by the author is the whole opt-out surface, as the ruling's constraint 1 requires.
  • Published JSON Schema:Field.unique loses default: false (a single value would be wrong for one of the two cases). Recorded where the ratchet looks: the data/Field:unique = false → (none) row in DEFAULT_CHANGES_BY_MAJOR, the authorable-defaults shard, the ADR-0087 semantic entry autonumber-default-unique-organization, and the regenerated reference pages. Inferred output type declares unique? — the accepted ADR-0122 cost, same as deleteBehavior; consumer typechecks are green in CI.
  • Deployed behaviour: drivers read the parsed unique value-only, so the tenant-composite NULL-safe index materialises on the next serving boot. Existing duplicates meet ADR-0120 D4's loud degrade (driver error naming the index and remedy, drift pass naming the key groups, os migrate plan blocked) — measured, not inferred (M1-A). Constraint 2 forbids silent degrade; this one is on the error channel with rows and remedy.
  • Ruling fidelity: card body verbatim 「autonumber 字段默认 unique,租户复合形状照 case_number 既有样板;显式声明退出才不唯一。」 — met on all three clauses.

② Semver vs changeset

@objectstack/specminor with the BREAKING banner and the ADR-0087 registration marker — the repo's launch-window convention for a breaking emitted-shape change (check:changeset-no-major and check-adr-0087-registration green).

③ Boundary flags (the two open_questions in the dev report)

  1. Reconsider the D4 "do not take the boot down" posture for the autonumber default?No. ADR-0120 D4 / driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 is an accepted maintainer decision, and the ruling's constraint 2 is satisfied by a loud, remedied degrade. Not reopened; driver-sql: a PLAIN unique index (no organization key part) over existing duplicate rows fails the boot with the raw driver error naming no rows and no remedy, and os migrate plan classifies the same op safe with no duplicate pre-flight #14902 (the plain-unique boot failure with no pre-flight) stays the engine lane's card.
  2. Does the parse-time default reach hotcrm?Measured by this seat on hotcrm origin/main0ed728e: ten autonumber fields, every one declared through Field.autonumber({...}) inside ObjectSchema.create(), i.e. parsed — the default applies. hotcrm pins the published @objectstack/spec 17.2.0 (not a git SHA), so the cross-repo unlock is a spec release plus a hotcrm bump, not this merge; nothing further for this PR.

#14901 (hand-written field-types row) and #14902 filed unassigned — correct routing.

CI on 8591cccc1: every check green (38 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403). Card-side carrier: #13894 never received the label (seat off shift) — nothing to strip on the card.

Landing:needs:contract-review stripped from this PR, marked ready, auto-merge armed. Not governed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants

@os-sam@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13894-autonumber-default-unique
Sep 3, 2026
Merged

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

Conversation

@os-sam

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/main2263ca4d6: 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

consumerafter the flipevidence
driver-sql registerManagedObjectMetadata / uniqueIndexesFromFields / syncDeclaredIndexeschanged — every autonumber field without an authored unique now gets uniq_OBJECT_organization_id_FIELD (NULL-safe composite), plain uniq_OBJECT_FIELD where no tenant columnM1 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 fieldsM1-A drift entry
driver-memory memory-unique-constraint.tschanged — in-memory unique enforcement now covers autonumber fields (isUniqueDeclared, organization-scoped)read; dogfood suite green
driver-mongodb mongodb-schema.ts:266changedidx_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/:474unchangedtype === 'autonumber' is tested first; uniqueScope is reported from the authored/parsed valueM2: identifier: 'autonumber', uniqueScope: null on an unparsed def
metadata-protocol seed-tenancy-backfill.ts:1276/:1351unchanged — "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 keyread; not in scope
objectql tenancy/system-write-organization.ts:302unchanged — the refusal text already assumes the partitioned unique indexread; not in scope
objectql util.ts:313 (introspection → field.unique = true)unchanged — writes an authored trueread
types unique-scope-install-gate.ts:186unchanged — reads only the 'global' spellingread
lint data-model-rules.ts:339–354unchanged — reads the authored def (uniqueDeclared(def?.unique)), i.e. pre-parseread
forms field.form.ts (unique helpText)unchanged — a form hintread
objectuiPM'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-showcasefield_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/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, 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

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.

@hotlongClaude

Copy link
Copy Markdown
Contributor

Contract review: PASS — head 8591cccc1, CONTRACT_REVIEW_TIER review by the director seat (take-over of a stalled in-seat review)

Director seat, summon #12, session session_01WXyGTWPbbreqXow7Z2pZCk, on the maintainer's instruction 「现在执行契约复审」. The dispatching domain:spec seat went off shift after dispatch with no collection or review on record; take-over per the 2026-08-31 ruling. Fuse: served claude-fable-5-1 = CONTRACT_REVIEW_TIER.

① Derived judgments — the emitted shape moves, the accept set does not

  • Emitted shape: an autonumber field that omits unique now parses to unique: 'organization'; every other type still parses to unique: false at the same shape position (verified in the .overwrite() tail: the patch is applied only when field.unique === undefined, keyed on type === 'autonumber', re-inserted in shapeOrder). Idempotent by construction — 'organization' is itself an accepted spelling.
  • Accept set: unchanged. true / 'organization' / 'global' / false all parse verbatim on every type; false written by the author is the whole opt-out surface, as the ruling's constraint 1 requires.
  • Published JSON Schema:Field.unique loses default: false (a single value would be wrong for one of the two cases). Recorded where the ratchet looks: the data/Field:unique = false → (none) row in DEFAULT_CHANGES_BY_MAJOR, the authorable-defaults shard, the ADR-0087 semantic entry autonumber-default-unique-organization, and the regenerated reference pages. Inferred output type declares unique? — the accepted ADR-0122 cost, same as deleteBehavior; consumer typechecks are green in CI.
  • Deployed behaviour: drivers read the parsed unique value-only, so the tenant-composite NULL-safe index materialises on the next serving boot. Existing duplicates meet ADR-0120 D4's loud degrade (driver error naming the index and remedy, drift pass naming the key groups, os migrate plan blocked) — measured, not inferred (M1-A). Constraint 2 forbids silent degrade; this one is on the error channel with rows and remedy.
  • Ruling fidelity: card body verbatim 「autonumber 字段默认 unique,租户复合形状照 case_number 既有样板;显式声明退出才不唯一。」 — met on all three clauses.

② Semver vs changeset

@objectstack/specminor with the BREAKING banner and the ADR-0087 registration marker — the repo's launch-window convention for a breaking emitted-shape change (check:changeset-no-major and check-adr-0087-registration green).

③ Boundary flags (the two open_questions in the dev report)

  1. Reconsider the D4 "do not take the boot down" posture for the autonumber default?No. ADR-0120 D4 / driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 is an accepted maintainer decision, and the ruling's constraint 2 is satisfied by a loud, remedied degrade. Not reopened; driver-sql: a PLAIN unique index (no organization key part) over existing duplicate rows fails the boot with the raw driver error naming no rows and no remedy, and os migrate plan classifies the same op safe with no duplicate pre-flight #14902 (the plain-unique boot failure with no pre-flight) stays the engine lane's card.
  2. Does the parse-time default reach hotcrm?Measured by this seat on hotcrm origin/main0ed728e: ten autonumber fields, every one declared through Field.autonumber({...}) inside ObjectSchema.create(), i.e. parsed — the default applies. hotcrm pins the published @objectstack/spec 17.2.0 (not a git SHA), so the cross-repo unlock is a spec release plus a hotcrm bump, not this merge; nothing further for this PR.

#14901 (hand-written field-types row) and #14902 filed unassigned — correct routing.

CI on 8591cccc1: every check green (38 runs). check-clause2-carriers.mjs --pair NOT MEASURED here (HTTP 403). Card-side carrier: #13894 never received the label (seat off shift) — nothing to strip on the card.

Landing:needs:contract-review stripped from this PR, marked ready, auto-merge armed. Not governed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants

@os-sam@hotlong@claude