You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
driver-memory: a partial syncSchema re-registration silently flips a platform-global object's UNIQUE partition from global to per-organization — the explicit tenancy.enabled: false opt-out is not sticky here the way it is in driver-sql #16729
Found while implementing #16589 (read-side tenant scope for the in-memory driver). Separate concern, separate path, filed rather than folded in.
The asymmetry
SqlDriver keeps a sticky record of an explicit tenancy.enabled: false opt-out — tenantOptOutByTable, maintained by computeAndRecordTenantField. Its docblock states the reason verbatim:
A schema that carries a tenancy declaration is authoritative: it sets or clears the opt-out and is computed normally. A schema WITHOUT one (partial re-registration — e.g. the lifecycle archive path passes only { name, fields } to syncSchema) preserves a previously declared opt-out instead of letting the implicit organization_id heuristic re-scope a platform-global table.
driver-memory has no such record. InMemoryDriver.syncSchema recomputes the uniqueness constraints from whatever schema this call happens to carry, through tenantFieldOf in packages/drivers/driver-memory/src/memory-unique-constraint.ts, which reads the passed schema and nothing else. A second syncSchema for the same object without a tenancy block therefore falls through to the implicit organization_id heuristic.
Repro — the exported functions, against the built artifact
Run from the repository root after pnpm --filter @objectstack/driver-memory build. Both symbols are published from the package index, so this is the shipped behaviour, not a private detail:
scopeField: null is a GLOBAL uniqueness partition — one key per install, which is what tenancy.enabled: false declares. scopeField: "organization_id" is one keyper organization. So after a partial re-registration, a duplicate the declared constraint refuses can land instead, and nothing announces the change: the declaration says global, the enforcement is per-organization. That is the declared-vs-enforced shape Prime Directive #10 forbids, reached by a state change rather than by a missing check — the mirror image of the closed #8323 (tenant-scoped objects getting GLOBAL indexes).
It is silent in both directions. Nothing logs the flip, and the constraint's own refusal message names the field, never the partition it used.
Boundaries, so the next reader does not re-derive them
⚠️Not confirmed end to end. The mechanism above is reproduced at the export boundary. What is NOT established here is a live caller that hands syncSchema a schema with no tenancy block: the lifecycle archive path (packages/objectql/src/lifecycle/lifecycle-service.ts, its cold.syncSchema(object, obj)) passes the registry object, which carries the declaration. The { name, fields } shape is asserted by driver-sql's own docblock as the reason its sticky record exists (Driver-native tenant scope hides platform-global (NULL-org) rows from an org-context read (sys_license: admin sees 0, anon sees row) #3249). Establishing or refuting the caller is the first step for whoever takes this.
⚠️ Worth checking in the same pass, not asserted here: SqlDriver.syncDeclaredIndexes is reached from at least one site (the shard path) with this.computeTenantField(obj) rather than the recorded value, which would be the same gap one driver over.
Found while implementing #16589 (read-side tenant scope for the in-memory driver). Separate concern, separate path, filed rather than folded in.
The asymmetry
SqlDriverkeeps a sticky record of an explicittenancy.enabled: falseopt-out —tenantOptOutByTable, maintained bycomputeAndRecordTenantField. Its docblock states the reason verbatim:driver-memoryhas no such record.InMemoryDriver.syncSchemarecomputes the uniqueness constraints from whatever schema this call happens to carry, throughtenantFieldOfinpackages/drivers/driver-memory/src/memory-unique-constraint.ts, which reads the passed schema and nothing else. A secondsyncSchemafor the same object without atenancyblock therefore falls through to the implicitorganization_idheuristic.Repro — the exported functions, against the built artifact
Run from the repository root after
pnpm --filter @objectstack/driver-memory build. Both symbols are published from the package index, so this is the shipped behaviour, not a private detail:Measured on
origin/mainat6ba0db4e0b(@objectstack/driver-memory17.3.0):Why it matters
scopeField: nullis a GLOBAL uniqueness partition — onekeyper install, which is whattenancy.enabled: falsedeclares.scopeField: "organization_id"is onekeyper organization. So after a partial re-registration, a duplicate the declared constraint refuses can land instead, and nothing announces the change: the declaration says global, the enforcement is per-organization. That is the declared-vs-enforced shape Prime Directive #10 forbids, reached by a state change rather than by a missing check — the mirror image of the closed #8323 (tenant-scoped objects getting GLOBAL indexes).It is silent in both directions. Nothing logs the flip, and the constraint's own refusal message names the field, never the partition it used.
Boundaries, so the next reader does not re-derive them
tenancyblock — its guard only refuses an explicitenabled: true, so memory-driver runs show cross-organization rows a SQL driver refuses #16589. That change adds a sticky record (recordTenantFieldinmemory-tenant-scope.ts) for the READ scope only, deliberately: making the uniqueness path sticky in the same stroke would have changed uniqueness semantics, which is [finding] driver-memory enforces no uniqueness, so an out-of-process duplicate autonumber lands SILENTLY and the write succeeds — the #5499 freeze that deferred the fix dissolved 2026-08-11 #13197 / driver-memory enforces field-leveluniquebut not object-level declaredindexes[]— a composite unique is a real constraint on driver-sql and nothing at all in memory #13239 territory and needs its own test. The consequence is that the two paths in one package now answer differently for a partially re-registered object — the read scope keeps the opt-out, the uniqueness key does not. Whoever picks this up should close that gap by routing the uniqueness path through the same record, not by removing the read-side one.syncSchemaa schema with notenancyblock: the lifecycle archive path (packages/objectql/src/lifecycle/lifecycle-service.ts, itscold.syncSchema(object, obj)) passes the registry object, which carries the declaration. The{ name, fields }shape is asserted bydriver-sql's own docblock as the reason its sticky record exists (Driver-native tenant scope hides platform-global (NULL-org) rows from an org-context read (sys_license: admin sees 0, anon sees row) #3249). Establishing or refuting the caller is the first step for whoever takes this.SqlDriver.syncDeclaredIndexesis reached from at least one site (the shard path) withthis.computeTenantField(obj)rather than the recorded value, which would be the same gap one driver over.