Repository navigation
Seed loader writes untenanted rows while the REST path stamps an organization — one single-tenant install runs two autonumber scopes and mints duplicate business identifiers, silently (17.0.0 GA) #8686
Description
Part of objectstack-ai/hotcrm#698 — the residual half of that card after objectstack#5495 → PR #6932 landed. Measured live on @objectstack/* 17.0.0 GA.
Sibling-in-family, both closed and neither covering this: #5495 (stale counter / burn-on-failure — fixed, re-verified below), #6249 (windowed seeding scan), #5030 (NULL-distinct composite unique — fixed; the index is now COALESCE-guarded).
Symptom
On a fresh database seeded from an app's declared seed data, the first REST create of an autonumbered record mints a number that already exists, with no error and no warning.
seeded rows: 38, max=CASE-00038
sequence last_value BEFORE any API create: 38
POST /api/v1/data/crm_case -> 201 case_number=CASE-00001
POST /api/v1/data/crm_case -> 201 case_number=CASE-00002
POST /api/v1/data/crm_case -> 201 case_number=CASE-00003
POST /api/v1/data/crm_case -> 201 case_number=CASE-00004
duplicate case_number values now in DB: [["CASE-00001",2],["CASE-00002",2],["CASE-00003",2],["CASE-00004",2]]
Zero 409s. Four duplicated business identifiers on a unique record-number field.
Not object-specific — same run, second object:
POST /api/v1/data/crm_knowledge_article -> 201 article_number=KA-0001
article numbers by org:
organization_id=null KA-0001,KA-0002,KA-0003,KA-0004 (seed)
organization_id='org_mssymr19xzd645gv' KA-0001 (API)
Mechanism
Two sequence rows exist for one object, because the two write paths disagree about tenancy:
_objectstack_sequences where object='crm_case':
tenant_id='__global__' last_value=38 <- the SEED loader
tenant_id='org_mssymr19xzd645gv' last_value=6 <- the REST API
crm_case rows by organization_id:
organization_id=null 38 rows CASE-00001..CASE-00038
organization_id='org_mssymr19xzd645gv' 6 rows CASE-00001..CASE-00006
and the uniqueness index is partitioned by exactly that column:
CREATE UNIQUE INDEX `uniq_crm_case_organization_id_case_number`
ON `crm_case` (COALESCE(`organization_id`, '__global__'), `case_number`)The boot banner for this install reads Tenancy: single. So a single-tenant stack splits one logical tenant across two partitions of its own uniqueness index, each with an independent counter.
Each counter is correct within its own scope, which is why #6249's complete keyset scan does not help: the org-scoped counter scans its own partition, correctly finds it empty on a fresh database, and correctly starts at 1. The defect is upstream of the counter — in who stamps organization_id on a seeded row.
The consumer-side symptom is the one that costs: a duplicated unique business identifier that no constraint catches, because the two copies live in different partitions. In the reporting app this silently mis-keys idempotence gates that dedupe on the record number, and makes notification copy (Contract expired: CTR-0001) ambiguous.
What a fix has to decide
Which of these is the contract, stated once and enforced on both paths:
- Seed writes carry the organization the same way API writes do — then both paths share one scope and one counter, and the existing unique index bites across them.
- Seed rows are deliberately untenanted — then the autonumber scope must not be keyed on
organization_idfor that object, or theCOALESCE(...,'__global__')partition must not be treated as a peer of a real org, because as it stands "unique per tenant" is not true of the rows the platform itself wrote.
Option 1 is the one that makes a declared unique mean what it says without the app having to know about the split. Either way the current state — two producers, two scopes, one index, no diagnostic — is the shape that cannot be right.
Repro
Fresh DB, app with seeded rows on an autonumber+unique field, objectstack dev --seed-admin --fresh; sign in and POST one record with no number supplied; read case_number and _objectstack_sequences. Watching status codes will not show it — on a fresh DB that path is entirely green.
Verified fixed in the same run (recorded so it is not re-opened)
#5495's burn-on-failure half is gone. A taken number no longer produces a failure at all — the allocator skips a contiguous taken band inside one request (counter at 11, band CASE-00012..CASE-00020 planted, one create returned CASE-00021, counter 21), and a create rejected by validation leaves the counter unmoved (22 → 22).
Generated by Claude Code
Activity
hotlong commentedon Aug 14, 2026
Triage: lands in the seed-apply path —
packages/runtime/src/app-plugin.ts/packages/runtime/src/domains/packages-seed-apply-*(verified onorigin/main@1eb28a1) ⇒domain:cli(that lane ownsruntime). Type Bug. Escalated toneeds-user-decision.Why escalated. Two reasons, either sufficient: the card asks which of two contracts is the contract (a platform-semantics call), and whichever wins, existing installs already carry untenanted seed rows — a stored-data disposition, which is on the human floor.
The diagnosis is located, not inferred: two sequence rows for one object, the unique index partitioned by
COALESCE(organization_id, '__global__'), and the boot banner readingTenancy: single. The card also correctly rules out #6249's keyset scan as a remedy — each counter is correct within its own scope, so the defect is upstream of the counter, in who stampsorganization_idon a seeded row.Four-prism block
CASE-00001..4which already exist — four duplicated values on a field declaredunique, zero 409s, no warning. Downstream this mis-keys idempotence gates that dedupe on the record number and makes notification copy ambiguous.uniquemean what it says without the app author (or an AI author) having to know a split exists. Under option 2 an author must know that "unique per tenant" is untrue of rows the platform itself wrote — undiscoverable, and the symptom appears far from the cause.__global__pseudo-tenant acting as a peer of a real org). Option 2 keeps it and adds scoping vocabulary the platform then owes forever.All four → option 1. Escalated on the stored-data half, not on the route.
Recommendation
Option 1 — seed writes carry the organization the same way API writes do.
The question that genuinely needs you, because it cannot be derived: what happens to seed rows already written untenanted on existing installs? Three shapes, ascending cost:
⛔ Do not let this be implemented as "make the counter smarter". That is #6249's remedy and the card shows it cannot work here — both counters are already correct.
Release-board note: not nominated for
target:*by this seat — v17 is GA and the board's producer question for post-GA defects is open (see this round's brief). Flag it and the label follows.Generated by Claude Code
os-zhuang commentedon Aug 15, 2026
Maintainer ruling (delegated adjudication; delegation 2026-08-15 verbatim 「决策你直接帮我做」, batch confirmed 「同意」)
Ruled: the contract is Option 1 — seed writes carry the organization exactly the way API writes do. Stored-data disposition: shape 2 — fix plus a one-shot backfill, single-tenant-guarded.
Contract half (triage's four prisms all point here, adopted): one tenancy contract for both write paths, one counter per object, and the existing
COALESCE-partitioned unique index bites across them. The__global__pseudo-tenant stops acting as a peer of a real organization.Stored-data half:
__global__counter into the org-scoped one atmax(last_value)), guarded to single-tenant installs — the only population where the right answer is derivable.CASE-00001..4), the migration reports them — it does not silently renumber either side; duplicated business identifiers already minted are surfaced, not papered over.⛔ Hard boundary from the card, made binding: this must not be implemented as "make the counter smarter" — both counters are already correct within their scopes; the defect is upstream, in who stamps
organization_idon a seeded row.Label flipped
needs-user-decision→pm:queue. Not dispatched from the spec seat (dispatch freeze in force); the owning lane's seat (domain:cli) may pick this up.Generated by Claude Code
fix(runtime): stamp seed rows with the install's organization so one …
fix(engine-core): a system-context insert resolves the install's orga…
fix(metadata-protocol): arm the kernel:ready platform migrations on a…
fix(metadata-protocol): record each applied seed-tenancy repair in th…
fix(metadata-protocol): write the merged autonumber high-water mark b…
fix(metadata-protocol): zero organizations is a third state, not the …
fix(metadata-protocol): write the merged autonumber high-water mark b…
fix(metadata-protocol): zero organizations is a third state, not the …