Skip to content

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

@hotlong

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:

CREATEUNIQUE 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:

  1. 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.
  2. Seed rows are deliberately untenanted — then the autonumber scope must not be keyed on organization_id for that object, or the COALESCE(...,'__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_numberand_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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions