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).

Activity

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions