Skip to content

System-context writes land untenanted at RUNTIME, so a single-tenant install keeps re-forking the autonumber scope and minting duplicate business identifiers — the producer #8686's backfill cannot reach (17.0.0 GA) #8844

Description

@baozhoutao

Direct residual of #8686 (closed COMPLETED 2026-08-15 by PR #8832). That card fixed one producer of untenanted rows — the seed loader — and shipped a one-shot backfill for rows it had already written. This card is about the other producer, which is still running: ordinary application writes made under a system execution context. A backfill cannot reach it, because it mints a new duplicate every time a hook, a scheduled job, a custom endpoint or a runAs: system flow creates a record.

Measured on @objectstack/*17.0.0 GA in a real single-tenant app (an EHR/MES install, ~44 autonumbered objects), and code-confirmed against the shipped 17.0.0 artifacts.

Symptom

Same object, same install, Tenancy: single, two records with the same value on an autonumber business-key field, no error and no warning:

dispatch_order (the base table every dispatch category lands in):
DEFAULTWI0020260811001002 <- rework order, created by a quality-verdict HOOK (sudo)
DEFAULTWI0020260811001002 <- production order, created by a PLANNER in the Console

Searching by document number now returns 2 records; two record pages carry the same title.

Not object-specific. Observed duplicated on the same install: dispatch_order, inspection_request (report-for-inspection), notification, quality_dispatch_item, andon_record. The notification case is the crispest, because both producers are visible side by side — a cron job and a user action:

notification numbers on one fresh DB:
NT-00002 .. NT-00011 each appears TWICE
copy A: created by the "maintenance overdue" CRON job (system context)
copy B: created by a user action / hook on a user request (session context)

Reproduced across two independent test rounds on fresh databases.

Mechanism

Identical in shape to #8686, one layer up: the autonumber scope is a function of whether the row got an organization_id stamped, and a system-context write does not get one.

_objectstack_sequences, one object, one scope key `DEFAULTWI0020260811001`:
tenant_id = 'org_msokm9oaz0cal87q' last_value = 2 <- REST / Console writes
tenant_id = '__global__' last_value = 2 <- system-context writes

The two rows cannot see each other, so each is correct within its own scope and both hand out 002.

Confirmed in the shipped 17.0.0@objectstack/driver-sql artifact:

  • GLOBAL_TENANT = "__global__", and the tenant-column key is materialized as COALESCE(<tenantField>, '__global__');
  • fillAutoNumberFields() derives the sequence tenant as row[cfg.tenantField] ?? options.tenantId ?? null — i.e. read off the row, after stamping;
  • getNextSequenceValue() and resyncSequenceToDataMax() both collapse a null tenant to GLOBAL_TENANT.

So the defect is upstream of the counter — exactly the boundary #8686's triage drew, and the reason "make the counter smarter" was ruled out there. Both counters are already correct.

The upstream half is the write context. #8672 measured the same primitive directly, on a different object: insert under { isSystem: true } stores organization_id NULL. That card filed it as an observation about sys_permission_set, where an org-less row is arguably correct by design. On application objects it is not arguable — it silently forks the uniqueness partition of a field the app declared unique.

Why #8686's fix does not cover this

PR #8832 touches packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts, packages/metadata-protocol/src/plugin.ts and packages/runtime/src/app-plugin.ts — the seed-apply path plus a one-shot repair. Nothing in that diff changes how a runtime write under a system context resolves its organization. After the fix:

  • seed rows are stamped and the historical split is merged → good;
  • the very next hook/cron/endpoint write still lands organization_id = NULL, re-creates the __global__ sequence row, and the split reopens.

The backfill is therefore self-undoing on any install that has server-side automation, which is the normal case for a business app.

⚠️ The obvious app-side remedy is worse, and was measured

Adding a unique index on the number field escalates this from a silent duplicate to a hard create failure. The collision self-heal rescans for the max existing value, but the rescan is org-filtered too — it cannot see the other partition, re-mints the identical number, and exhausts its retries. Measured on the reporting install; do not recommend it as a workaround.

What a fix has to decide

The same question #8686 answered for the seed path, answered once more for the runtime path:

  1. A system-context write on a tenant-scoped object resolves the install's organization the way a session write does (the twin of 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's ruling — on a single-tenant install this is derivable; on multi-tenant it is not, and the write must either carry an explicit organization or be refused rather than silently defaulting to __global__).
  2. Or system-context writes are deliberately untenanted, in which case the autonumber scope and the COALESCE(..., '__global__') unique partition must stop treating the pseudo-tenant as a peer of a real organization — because as it stands, a declared unique is untrue of rows the platform's own automation wrote.

Option 1 is what makes a declared unique mean what it says without every hook author knowing a split exists. Whichever wins, the residual question this card cannot answer is what to do with already-minted duplicates on live installs — in the reporting app the numbers have already propagated into notification bodies, approval trails and a cross-reference field, so renumbering is off the table there; they need to be reported, not silently rewritten (same posture #8686's ruling took).

Repro

Single-tenant install, any object with an autonumber field:

  1. create one record through REST/Console as a signed-in user (session carries an organization);
  2. create one record on the same object from a hook / scheduled job / custom endpoint under a system context with no organization;
  3. read the numbers and_objectstack_sequences for that object.

Two sequence rows, one org_… and one __global__, each counting independently. Status codes stay green throughout — watching responses will not show it.

Related

Downstream app-side tracking (private): steedos-labs/os-project-titanwind-ehr#1082 (the family card, with the per-object inventory) and #1056 / #1243 (symptom cards, held pending this).

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions