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:

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:

  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_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

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

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