fix(objectql): lifecycle tenant scan asks the registry before reading sys_organization - #21628
Conversation
… sys_organization
The engine's in-process verbs refuse an object name the registry does not
resolve with OBJECT_NOT_FOUND before any driver is asked. In a composition
that registers no sys_organization, LifecycleService.loadGovernance's tenant
scan received that refusal, which is not the missing-table cause its catch
accepts, so every lifecycle sweep aborted before applying a policy.
The scan now asks engine.registry.getObject('sys_organization') first, the
shape ObjectQL.probeInstallOrganizations takes, and answers an unregistered
object with no tenant overrides. A registered object is read as before: a
missing table stays the one benign cause, and every other failure, an
OBJECT_NOT_FOUND from that read included, still aborts the sweep.
LifecycleEngineLike['registry'] declares the optional getObject member the
scan reads. The runtime noise-capture header no longer says the org probe
catches only a missing table.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
…f erasing them The two direct engine reads in the new tenant-scan pins passed their options through `as any`, which the query-options erasure ratchet counts in test code too. They now share one typed EngineQueryOptions constant, and the spy's recorded options are read without a cast. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
📓 Docs Drift CheckThis PR changes 2 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 35 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 16e12a21aece8ba28e65e473337e33d812033d09 && git checkout 16e12a21aece8ba28e65e473337e33d812033d09
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 045b946256d988653fdca185c7fd33d6d86bd78d 190cea2e9edaa43f98079514ba36bf240a956026 && git checkout -B drift-repro 045b946256d988653fdca185c7fd33d6d86bd78d && git merge --no-ff 190cea2e9edaa43f98079514ba36bf240a956026
node scripts/docs-audit/affected-docs.mjs --json 045b946256d988653fdca185c7fd33d6d86bd78d
|
Fixes #21597
Clause-②: no
What changed
packages/objectql/src/lifecycle/lifecycle-service.ts:loadGovernance's tenant scan now asksengine.registry.getObject('sys_organization')before it reads that object.sys_organizationis the single-tenant answer. There is no read and no tenant override, and the sweep runs one global pass on each declared window.isMissingTableError(error, 'sys_organization'). Every other failure still aborts the sweep, and that includes anOBJECT_NOT_FOUNDthrown from that read.LifecycleEngineLike['registry']declares the optionalgetObject?(name: string): unknownmember the scan reads, because a published type must not refuse a key the code below it reads.getAllObjectsstays a legal engine. Such a registry cannot be asked, and the scan then reads as before.ObjectQL.registryis theSchemaRegistry.packages/runtime/src/expected-read-refusal-noise.ts(the claim's declared cross-lane path, comment only): the header no longer says the org probe catches only a missing table. It now says that the probe and the lifecycle snapshot both ask the registry first, and it states what the seed-loader accepts.packages/objectql/src/lifecycle/lifecycle-service.organization-registry.test.ts: six cases on a REALObjectQLengine over a stub driver, so the refusal the guard avoids is the engine's own and not a double's guess..changeset/21597-lifecycle-registry-first-guard.md:patchfor@objectstack/objectqlonly. The runtime half is measured below.Why: the mechanism, measured
eb9ef791bd,ObjectQL.resolveObjectNamethrowsobjectNotFoundError(name)exactly whenthis._registry.getObject(name)is falsy. Every in-process verb resolves through it. Soengine.registry.getObjectis the refusal's own predicate: the guard asks the very question the refusal asks, never a list of names.getAllObjectswas not used for this. It is a full merging walk with a side effect, and it can disagree withgetObject's short-name and FQN index. The dangerous direction of that disagreement is "absent" for an object that resolves.36ad3210d4: the new file was run before the fix and gave 5 passed, 1 failed.find('sys_organization')withcode: 'OBJECT_NOT_FOUND',status: 404andobject: 'sys_organization', with no driver read at all.sys_organization(expected 1 to be +0).Which shape was followed, and why
No shared helper exists: there is no registry-presence helper in objectql, core or types. #21545 left two shapes:
probeInstallOrganizations);object(resolveSoleOrganizationId).This follows the engine probe's shape, with its catch unchanged. It is spelled with
MigrationRecoveryPlugin's capability check (typeof ... === 'function' && !...), becauseLifecycleServiceholds a duck-typed engine rather than the registry itself. It is not the seed-loader's catch-side shape, for three reasons:sys_organizationarm unreachable, so adding that arm as well would make a third variant.OBJECT_NOT_FOUNDthat arrives after the registry said "registered" either names another object or contradicts the registry. It propagates, and this is pinned.There is no new engine API and no
engine.tsedit. The registry comes from the engine's existingregistryaccessor, which settles H3.Pins (real
ObjectQL,engine.findspied with call-through)OBJECT_NOT_FOUND, 404,object: 'sys_organization'; the driver saw nothingreport.errorsempty; zero scan reads; one global 30d pass;report.sweptrecords the 30d cutoff; no warnno such table)ECONNREFUSED)code: 'ECONNREFUSED'; no candidate read, no delete, nothing swept;report.errorsand the warn quote that fault's own messageOBJECT_NOT_FOUNDattributed to ANOTHER object (abeforeFindhook reads an unregisteredsys_org_unit)OBJECT_NOT_FOUND, 404,object: 'sys_org_unit'; the sweep aborts; not read as absenceVerification, at final head
190cea2e9escripts/ablation-replace.mjs: the anchor hit once, and the blob wenta3ff3c2d4d3dto6c3fa03e505d. Result:src/lifecycle/gave 1 failed, 115 passed (116). Only the unregistered pin went red, and it reported the defect itself:governance snapshot could not be loaded (Object 'sys_organization' not found) — sweep aborted before any policy was applied.git checkout HEAD -- ABSPATH. The blob is back at the HEAD bloba3ff3c2d4d3d,git diff HEADis empty andgit status --porcelainis empty.dfb2af2144, gave the same direction.getObject, so they never reach the guard.pnpm --filter @objectstack/objectql test: Test Files 368 passed (368), Tests 7421 passed (7421).src/lifecycle/: 2 files, 116 passed.pnpm --filter @objectstack/objectql typecheckexit 0, withcheck:test-typecheck: OK. The new file is in the test program (tsc -p tsconfig.test.json --listFilesOnly: 1 hit) and compiles with zero errors..d.tsgains the optional member. Three test files in other packages build typedLifecycleEngineLikedoubles, allregistry: { getAllObjects }and none passinggetSettings.pnpm --filter @objectstack/service-messaging typecheckexits 0.getObject: 42into its double givesTS2352 ... The types of 'registry.getObject' are incompatibleagainst the rebuilt.d.ts. The same paste passes anasassertion against the old type, so this proves the consumer read the rebuilt declarations. Restore proven.pnpm --filter @objectstack/runtime typecheckexit 0.src/expected-read-refusal-noise.channel-asymmetry.test.ts: 4 passed.captureExpectedReadRefusalshave 0 hits indist/, and the module is 0 of 79sourcesin both sourcemaps. The positive controlmigration-recovery-pluginis 1 of 79, and its export hits 4distfiles. So the changeset carries no runtime entry.node scripts/pm/dispatch-gates.mjs --commandsderived 64 commands at190cea2e9e, and all 64 exited 0.--ranreconciliation: 64 derived, 64 run, 0 NOT-MEASURED (a derived zero, every exit code recorded).check:query-options-erasurewas red once on the first head (test surface 236 to 238: twoas anyquery options in the new file). Both are typed now, and the ratchet holds at 236.pnpm lintis CI's).eslint --no-inline-config --format jsonwas run on the three TS files in the diff: 3 files, 0 errors, 0 warnings. The changeset.mdis outside eslint's configuration ("no matching configuration").eslint.config.mjsstates that it never enables type-aware linting, so this diff cannot move any untouched file's verdict.Acceptance notes
sys_organizationwhile its database still holds an organization table written by another composition. It now sweeps every tenant on the global window. That is the card's stated semantics, and it is the same answerprobeInstallOrganizationsgives. Sinceeb9ef791bdno in-process verb can read that table by its raw name anyway.Clause-②: nois copied from the claim. The only type change is the optional member on the published input typeLifecycleEngineLike['registry']. Every engine accepted before is still accepted, and there is no new export. Precedent: commit0f38ab084added the optionaltenancykey toLifecycleObjectLike, and it shipped as an objectqlpatch.origin/main(f97660cdd6). Neither commit touches objectql, the lifecycle or the runtime header, so the branch is not merged here.--maxWorkers=2after a bare--, which vitest drops, so it ran at vitest's default worker count. It is still a whole-package measurement.Generated by Claude Code