fix(driver-memory,driver-sql): an explicit tenancy opt-out survives a partial syncSchema re-registration (#16729) - #17221
Conversation
…ky across a partial re-registration `InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever schema that call carried, so a second registration without a `tenancy` block fell through to the implicit `organization_id` heuristic and moved a `unique` field from one row per install to one row per organization. A duplicate the declaration refuses then landed, silently. `SqlDriver` has kept a sticky `tenantOptOutByTable` since #3249; this package had mirrored the inner `computeTenantField` and not the wrapper that consults the record. - driver-memory: publish `computeAndRecordTenantField` + `TenantOptOutRecord`; `InMemoryDriver` holds one record per instance and hands both declaration surfaces the same resolved column. `tenantFieldOf` is unchanged. - driver-sql: the shard leaf resolves through the record, keyed by the base table, instead of the bare `computeTenantField`. - objectql: `LifecycleObjectLike` declares `tenancy`, so the published type no longer refuses a key the Archiver's `cold.syncSchema` reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…oor too The field-level and declared-index constraints are wired through one resolved tenant column; the door-level pin covered only the field surface, so a fix that routed half of it would have stayed green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
The Archiver hands the cold driver the object it was given, and a driver resolves a uniqueness partition from `tenancy`. Pins the key on the published `LifecycleObjectLike` and on the `syncSchema` call in one assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
📓 Docs Drift CheckThis PR changes 3 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e2ca364fa729e52137c640519d2e3704d9a328ca && git checkout e2ca364fa729e52137c640519d2e3704d9a328ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed 0740437f5505c0f06b10f69cc4b8f1db8616faa3 && git checkout -B drift-repro 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed && git merge --no-ff 0740437f5505c0f06b10f69cc4b8f1db8616faa3
node scripts/docs-audit/affected-docs.mjs --json 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed
|
Contract review at
|
|
Landing provenance — director seat, summon #18 segment 6 ( Generated by Claude Code |
Fixes #16729
Clause-②: yes
An explicit
tenancy.enabled: falseopt-out is now sticky indriver-memory, the way it has been indriver-sqlsince #3249, so a partialsyncSchemare-registration can no longer flip a platform-global object's UNIQUE partition from global to per-organization.The reproduction, and the control that says the asymmetry is one-sided
Measured on
origin/maind61139f1ba, before the fix. Same sequence on both drivers: registersys_licensewithtenancy.enabled: false, then re-register it the way a partial caller does —{ name, fields }only — then write the samekeyunder two different organizations.key: 'K', different organizationdriver-memory, no re-registrationREFUSED—UNIQUE_VIOLATION/ 409driver-memory, after the partial re-registrationLANDEDdriver-sql, no re-registrationREFUSEDdriver-sql, after the partial re-registrationREFUSED— the record holdsAt the export boundary, which is what the card measured:
SqlDriver.computeAndRecordTenantField('sys_license', ...)answerednullfor both schemas andtenantOptOutByTable.has('sys_license')stayedtrue. So the divergence was real, one-sided, and located exactly where the card said.scopeField: nullis one row per install — what the declaration asks for.scopeField: 'organization_id'is one row per organization. The flip is silent in both directions: nothing logs it, and the refusal message names the field, never the partition.Rulings on the card that overrode this PR's original fence
Both are recorded on #16729 and both changed the plan. Naming them because the dispatch fence named only two files.
driver-sqlshard path to this card — "⛔ 不要另开卡,也 ⛔ 不要在修 driver-memory 时把它当作'以后再说'" — with its own required assertion: the same opted-out object must give the samescopeFieldthrough the shard path and the main-table path. That grants a wider surface than the fence (which namedmemory-unique-constraint.tsandlifecycle-service.ts), sopackages/drivers/driver-sql/src/sql-driver.tsis in.memory-tenant-scope.ts/recordTenantField, because that code lives only on a stopped PR. Re-measured on today'sorigin/maind61139f1ba:memory-tenant-scope.tsabsent,recordTenantField0 hits — positive control, the samegit ls-treelisted 58 entries includingmemory-unique-constraint.tsandmemory-tenancy-guard.ts. The refusal still holds, so this PR carries its own record.That seat also required a scan of #6915 and its siblings before building. Done: the #6915 maintainer ruling (comment 5261729371, 2026-08-12) is «裁定:处置 B —— 检测到多租户姿态时启动硬失败», and «⛔ 不做处置 A(实现行级租户隔离)». 处置 A is row-level tenant isolation — a read path that filters rows by organization. This PR adds none: it decides which partition a UNIQUE key occupies, and for the opted-out object it makes the key less scoped, not more.
memory-tenancy-guard.tsis untouched and this driver still refuses a multi-tenant posture outright. So uniqueness-partition stickiness is not part of the refused disposition.Landing site
Wider than the fence, per ruling 1, and one file wider than that:
driver-memory/src/memory-unique-constraint.tsdriver-memory/src/memory-driver.tssyncSchemais where it is maintained, and that is where the card locates the defectdriver-memory/src/index.tstenantFieldOfdriver-sql/src/sql-driver.tsobjectql/src/lifecycle/lifecycle-service.tsNo open PR holds any of these. Measured against all 20 open PRs' file lists; positive control fired (#17206 returned
memory-analytics.ts/filter-refusal.ts, #17195 returnedengine.ts), so the zero hits are a reading and not an empty query.⛔
packages/spec/**is untouched — no spec change is implied.docs/adr/**is untouched, andcheck-adr-0087-registration.mjs --base origin/mainexits 0, so no ADR row is owed.The live caller: refuted, and the PR says so
The card's item 1 is to establish or refute a live caller handing
syncSchemaa schema with notenancyblock. Refuted in this tree. Whole-tree scan; control fired at 315 files mentioningsyncSchema:lifecycle-service.ts:1262cold.syncSchema(object, obj)— the card's own candidate — passes the registry object, which carries the declaration.engine.ts:14484/:14534andplugin.ts:1796/:1807pass the registry object too.metadata/src/loaders/database-loader.ts:556/:634spread the full definition;SysMetadataObjectdeclares notenancyat all, so it has no opt-out to lose.verify/src/date-bucket-parity.ts:185andverify/src/read-coercion.ts:73are the only literal{ name, fields }calls. They are conformance harnesses over objects they create themselves, so no prior opted-out registration of the same name exists to be overwritten.⇒ There is no live path today. This is a defensive closure, not a repair of a production failure — the wording acceptance item 1 asks for. See Acceptance notes for how this reads against the card's escalation trigger.
What changed
@objectstack/driver-memorygainscomputeAndRecordTenantFieldand theTenantOptOutRecordtype.InMemoryDriverholds one record per instance and resolves through it insyncSchema, handing both declaration surfaces — field-leveluniqueand declaredindexes[]— the same resolved column, so one object's two keys cannot disagree about its partition.uniqueConstraintsFromFieldsanduniqueConstraintsFromDeclaredIndexestake that column as an optional second argument; called with one argument they answer exactly as before.tenantFieldOfis unchanged. Triage item 2 is explicit that the existing pin atmemory-unique-constraint.test.ts:346is still correct for a pure function and must not be touched; it is not. The stickiness never lived in that function on the SQL side either — it lives in the wrapper around it, which is precisely the half that was not mirrored. The resolver is therefore published besidetenantFieldOfrather than kept private: publishing only the inner half is what let this package diverge, and the next driver reproducing the pair now sees both halves.@objectstack/driver-sql: the shard leaf resolved with the barecomputeTenantField, so arotateShardssweep carrying notenancyblock gave a shard an organization key part the base table's index does not have — one object, two partitions, decided by which physical table a row landed in. It now resolves through the record, keyed by the base table (a new optionalbaseTableparameter defaulting to the shard name, so any external override keeps today's behaviour).@objectstack/objectql:LifecycleObjectLikenow declarestenancy. The Archiver hands that object straight tocold.syncSchema, and the published type refused the key while the driver below read it — so an author writing a fresh literal was pushed into producing exactly the partial shape above. Same correction #16711 made where the shard leaf narrowedindexesandtenancyoff the object it was handed.Both directions, proved
A fix that preserves the opt-out must not preserve it when a schema does carry an authoritative declaration that clears it.
tenancyblock, opt-out recorded ⇒null. Pinned at the resolver and at the driver door, on both declaration surfaces.tenancy: { enabled: true }and a block that declares no opt-out at all each delete the record, and the object is scoped again from then on. Pinned; the driver-door half uses the second spelling because the driver-memory 完全没有行级租户隔离(#3724 的未修姊妹面):多租户下静默不隔离 #6915 guard refuses an explicitly tenant-scoped object at that door outright.organization_idacross a partial re-registration — and its same-organization duplicate is still refused. This is triage item 4's negative control: an implementation answeringnulleverywhere would make every positive assertion green while switching tenant isolation off.Only the opt-out is sticky. A declared
tenancy.tenantFieldis not recorded, so a partial re-registration of a custom-tenant-column object still falls back toorganization_id— matchingSqlDriver, whose behaviour this module's contract is to reproduce. That is the safer of the two readings where the evidence was ambiguous: recording more would be a second answer to a questiondriver-sqlalready answers.The record is deliberately not cleared by
dropTable, which does clearuniqueConstraintsbeside it. The asymmetry is principled and documented at the field: a constraint outliving its table would be enforced over a table nobody declared, whereas this record enforces nothing on its own — it only decides what the next declaration resolves to, and dropping a table is not a schema declaring itself tenant-scoped.Assertions read
uniqueConstraintsFromFieldsoutput and the driver's own refusal envelope (codeandstatus, never merely "it threw"), nottenantFieldOfalone — triage item 5.Verification
Gate exit codes captured by redirect-then-
$?, never across a pipe.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack⇒ 63 commands. All 63 exit 0. Reconciled:--ranreports63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. Includescheck:tenant-chokepoint,check:platform-object-tenancy-census,check:driver-conformance,check:engine-double-contract,check:nul-bytes,check:changeset-no-major.check:lean-entry-closureandcheck:dual-build-cjs-loads(withcheck:type-check-debt). The closure was built and all three re-run to exit 0 — reported as measured, not as a 3.driver-memory1190 passed / 49 files ·driver-sql2453 passed, 150 skipped / 175 files ·objectql4856 passed / 288 files. No failure to diagnose against fix(driver-memory): an analytics time dimension buckets by its declaredgranularity(#16178) #17206 or fix(objectql)!: beforeUpdate receives the persist image; the caller submission moves to ctx.submitted (#16344) #17195.objectql's test-layer ledger — 44 files / 242 errors / 69 pinned signatures held, unchanged.pnpm lintover the whole repo: exit 0. No narrowing claimed, so none owed.0x01and0x7fmatches; a tab-only probe correctly does not).Ablation — each pin proved able to fail
Every leg mutated the committed tree, proved the mutation reached disk by hit-count and by
git hash-objectagainst theHEADblob, ran, then restored under atrap ... EXIT INT TERM. Restoration proved by hash equality and an emptygit diff HEAD, never by an exit code.driver-memorysyncSchemastops consulting the recorddriver-sqlshard path back to the bare resolvertenancyfromLifecycleObjectLikelifecycle-service.test.ts(950,7): error TS2353: Object literal may only specify known properties, and 'tenancy' does not exist in type 'LifecycleObjectLike'packages/objectql/tsconfig.jsonit exits 0 — that config carriesexclude: ["**/*.test.ts"], so--listFilescounts zero test files and the reading is NOT MEASURED, not a pass. Re-run undertsconfig.test.json, whose--listFilesdoes contain the file, it is decisive. Recording the trap because the first reading looked like a clean green.Semver
@objectstack/driver-memoryminor ·@objectstack/driver-sqlpatch ·@objectstack/objectqlpatch.driver-memoryminor — two new public-entry exports (computeAndRecordTenantField,TenantOptOutRecord). New export from a published entry is minor on its own terms, independent of the behaviour repair.tenancy.enabled: false,isTenancyDisabled(ADR-0066) is the single source of truth for what that means, and the driver enforced the opposite partition. That is repairing an implementation that silently violated its own published declared contract.objectqlpatch — a published type widening. A key the interface refused is now accepted; nothing that compiled before stops compiling. Not a narrowing, so no BREAKING marker and noadr-0087:line, andcheck-adr-0087-registration.mjsagrees at exit 0.major.Acceptance notes
Out-of-scope observations, filed nowhere, recorded here.
priority:p1the moment someone names "一个具体的、把不含tenancy的 schema 传给syncSchema的调用方(文件 + 行 + 该处传入的对象形状)". Literally,packages/verify/src/date-bucket-parity.ts:185andpackages/verify/src/read-coercion.ts:73are exactly that —driver.syncSchema(object, { name: object, fields: FIELDS }), notenancyblock. But neither can produce the harm: each creates its own conformance object, so there is no earlier opted-out registration of that name to overwrite. The trigger's letter is met and its purpose is not. Flagging rather than acting: I do not set labels.LifecycleObjectLikenarrowsindexesthe same way it narrowedtenancy. A fresh literal spellingindexesis refused by the type whilecold.syncSchemareads the key and both drivers build uniqueness constraints from it — the identical [finding]SqlDriverreads keys off caller objects through(obj as any)at 7 sites while 3 parameter types declare none of them — a class, not a third coincidence (after #4311tenancy, #16570indexes) #16711 shape, on the identical interface, one key over. Left out deliberately:indexesis a different declaration key with its own ADR-0120 line and real DDL consequences on a SQL cold store, and it deserves its own measurement rather than a rider on a tenancy PR. Noted, not filed. Carrier: whoever next touches this interface, or a follow-up to this card.resolveTenantField(shardName)atsql-driver.ts:9609reads the shard's own bookkeeping entry forkeyedColumns, a few lines before the code this PR corrected notes that shard bookkeeping is aliased only after the method runs. Not touched, not measured, and not obviously wrong — recording it because it sits inside the region this PR read closely. Noted, not filed.Generated by Claude Code