Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): plugin registry reads discriminate unreadable from empty — schema sync no longer skips every object silently at boot (#9285) - #9682
Conversation
…ty (#9285) `ObjectQLPlugin` read the registered object set in three places, all spelled `this.ql.registry?.getAllObjects?.() ?? []` — an expression that folds "the registry holds nothing", "the engine exposes no registry" and "the registry does not implement getAllObjects" into one value. Only the first is truthfully "no objects" (#8895: discriminate or propagate; applied by #9002 and #9154). - syncRegisteredSchemas propagates: its early return meant NO object's schema was synced to any driver, silently, at boot. - reconcileFederatedBindings reports at `error` then degrades — it is a post-hoc reconciliation, deliberately not a boot gate. - runGovernanceInventory reports at `warn` then skips, closing BOTH swallows (`?.()` and the wrapping try/catch); auditing an invented empty object set accused every object-declared action of being undeclared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qYPmkKEsfbWY1yVg83p8F
…ed (#9285) Reverse verification measured it green in both directions: seam 2's swallow was the optional-chain half only (`?.` short-circuits on absence, never on a throw, and that seam had no `catch`), so a throwing registry propagated there before this change too. The test is kept — relabelled, with the measurement recorded in the file header — because it fails the day someone wraps this read in a try/catch, which is how seam 3 acquired its second swallow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qYPmkKEsfbWY1yVg83p8F
…ys flat
`check:type-check-debt --re-measure` caught the new file adding +1 raw tsc error
to @objectstack/objectql's TEST_DEBT (355 -> 356): `body` on an action is
`{ language, source }`, not a bare string. Fixed at the fixture, which is the
author's remedy — the ledger is shrink-only and raising it is maintainer-only.
Re-measured: 355, and 0 errors attributable to this file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qYPmkKEsfbWY1yVg83p8F📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not seeCoarse fallback — 14 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 6db3ebe334e036398967909ce204ecccf05fcbba && git checkout 6db3ebe334e036398967909ce204ecccf05fcbba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4b2de3cd1406315754eb5ea3d016dc867740325f 5da142ba7d12507f18d01679bbe46d482120b2dd && git checkout -B drift-repro 4b2de3cd1406315754eb5ea3d016dc867740325f && git merge --no-ff 5da142ba7d12507f18d01679bbe46d482120b2dd
node scripts/docs-audit/affected-docs.mjs --json 4b2de3cd1406315754eb5ea3d016dc867740325f
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9285
ObjectQLPluginread the registered object set in three places, all spelledthis.ql.registry?.getAllObjects?.() ?? []. That expression folds three differentfacts into one value:
registryat all;getAllObjects— a structural omission that neverthrows, so it is invisible precisely when it is wrong.
Only (1) is truthfully "no objects". This applies the inherited adjudication, it
does not re-argue it: #8895 ruled the family discriminate or propagate, #9002
applied it to the two delete-cascade seams, #9154 to the roll-up summary index
(PR #9284). Triage promoted this card straight to
pm:queuefor that reason.The ruling, per seam
All three seams now read through one shared private helper,
ObjectQLPlugin.readRegisteredObjects(seam), which throws rather thaninventing — naming the consequence — and adds no
catchof its own, so aregistry that throws propagates its own error verbatim. What each caller does
with that failure is decided at the seam, and the three answers differ:
syncRegisteredSchemasif (allObjects.length === 0) return;, so an invented empty answer meant no registered object's schema was synced to any driver — no table created, no column added — silently, at boot, with the plugin reporting a clean start. Failing the boot is more truthful than starting against a store whose DDL never ran.reconcileFederatedBindingserror, then degradestart()so a late-connecting datasource is not a boot failure, and propagating would turn a diagnostic into the hard stop it was written not to be.error, notwarn, matches the level its existing report already uses for the same consequence.runGovernanceInventorywarn, then skipSeam 1's degradation is proportionate rather than merely convenient: on every boot
that does not set
skipSchemaSync, seam 2 has already read the same registrysuccessfully before
kernel:ready, so a failure at seam 1 is a registry thatbecame unreadable mid-boot, not the boot-wide condition seam 2 now catches.
Seam 3's double swallow — addressed explicitly
Seam 3 carried two independent inventions on one expression:
?.()for a registry that does not implement the method, and a wrappingtry { … } catch { return [] }for one that throws. A fix scoped to the??class alone would have left a throwing registry indistinguishable from an empty
one. Both are gone.
The measurement that decided the disposition: feeding the audit an invented empty
object set is worse than silence.
collectEngineActionDeclarationsderivesdeclarations from the objects, so with none, every handler declared on an
object reconciles as an
"undeclared handler … REFUSED at dispatch"— anunreadable registry produced a page of false accusations against a healthy
deployment. So the seam does not audit a substitute set at all: it reports once and
returns, leaving
lastGovernanceFingerprintuntouched so the next successful runreports in full rather than being suppressed as "unchanged".
warnand noterrorper the AGENTS.md degradation-log-level rule — an audit that did not run isa functional degradation; nothing here claims to have been persisted.
Deliberately untouched
The
objectsRegistered: … ?.length || 0count in theObjectQL engine startedinfo log, per the card and the dispatch. Judged benign, not left unexamined: a
wrong
0there costs one advisory line and no data. It is the one executableoccurrence of the old shape the probe below still reports.
① Boot-time reachability — what was MEASURED
Triage deliberately did not establish this, and the fix direction does not hinge on
it. Re-derived on this tree rather than inherited, and it still holds:
SchemaRegistry.getAllObjects()(registry.ts) is a walk over the in-memoryobjectContributorsMap, callingresolveObject(fqn).resolveObjectreturnsundefinedon every failure branch it models — no contributors, no owner (aftera
console.warn) — and never throws. Below it the fold isfoldExtenders→foldExtendersOntoDefinition→tenantAuthoredScalars/mergeObjectDefinitions/scalarOverridesPackagedBase: spreads,Setadds andcomparisons. No I/O, no driver, no
throwon the measured path.?.links are dead for a real engine too, which the card did not state:ObjectQL.registryis a plain getter returningthis._registry, afield-initialized
SchemaRegistry, andgetAllObjectsis a prototype method.Neither optional link can short-circuit for any engine constructed by
ObjectQLPlugin.init()or handed in asopts.ql(typedObjectQL).So: the seams are dormant against real data, and this is a structural close, not
a live-defect fix. The reach that is real is a duck-typed
ql— an incompletetest double, which is the #9154 blind spot below. The tests therefore inject the
failure at the registry itself, and the injection is the statement that nothing
shipped reaches these seams today.
This does not change the verdict — discriminate unreadable from empty is right
either way — but it does mean the urgency is "keep the fail-open shape from coming
back", not "a boot is failing today".
② The
vi.mockblind spot — classified, not blanket-patched#9154 measured 83 reds across 9 suites when
?.()came offbuildSummaryIndex.Measured here: zero reds, in both directions.
@objectstack/objectqlfull suite, before: 217 files / 3836 tests, all green.After: 218 / 3853, all green (the +1 file and +17 tests are this PR's pin).
No suite went red — so there is nothing to classify as either "a double to
complete" or "real coverage", and nothing was blanket-added to any double.
vi.mock('./registry')in this package driveengine.ts(which is wherebuildSummaryIndexlives), neverObjectQLPlugin's three seams.ObjectQLPluginoutside this package isnew ObjectQLPlugin()ornew ObjectQLPlugin({ …options })with noql, soinit()builds a realObjectQLwith a realSchemaRegistry. Not one consumer injects a duck-typedengine. Verified across
packages/runtime,packages/metadata,packages/metadata-protocol,packages/rest,packages/client,packages/services/*,packages/cliandpackages/qa/*.The one place a double now has to answer deliberately is this PR's own pin
file, and it answers by failing: two of its three injections install a registry
that cannot serve
getAllObjects, which is the exact indistinguishability the cardis about, asserted rather than papered over.
Reverse verification — predicted vs observed
Predictions were written before running. Resolution note: the pin imports
./plugin.js, a same-package relative specifier vitest resolves tosrc/plugin.ts— noexports-mediated hop intodist/, so no rebuild leg appliesto this ablation. Each leg reverted only its own seam, leaving the tests and the
shared helper in place; the tree was then restored and
git diff HEADprovedempty before the restoration leg was re-run.
?? []?? []Observed failure reasons, all as predicted:
AssertionError: promise resolved "undefined" instead of rejecting(×2) —the read answers
[], the next line early-returns, the call resolves.no-method/no-registry:expected [] to have a length of 1 but got +0— the silent early return, zero error logs. B,
throws:promise rejected "Error: registry read exploded" instead of resolving— adifferent reason, predicted in advance, because old seam 1 had no
catch.expected [] to have a length of 1 but got +0(no SKIPPED warn) andexpected 'u:acct:ping' to be 'previous-run'— the fingerprint overwritten withthe false accusation against a declared action.
The falsified prediction, and what was done about it
Leg A predicted 3 reds and produced 2. The test
"hands a THROWING registry error to the caller, identity intact" stayed green
in both directions — i.e. it pinned nothing about this change. The reason is a
real correction to how seam 2 was described:
?.short-circuits on absence,never on a throw, and seam 2 had no
catch(unlike seam 3), so a throwingregistry already propagated there before this PR. Seam 2's swallow was the
structural half alone.
Rather than delete or quietly keep it, the test is relabelled as
"a THROWING registry already propagated, and must keep propagating (preserved, not
fixed)", with the measurement recorded in the file header — because it still earns
its place: it fails the day someone "hardens" this read by wrapping it in a
try/catch, which is precisely how seam 3 acquired its second swallow.Gates — union derived on the final commit
node scripts/pm/dispatch-gates.mjs(no path arguments) against the committeddiff, re-derived on the final commit and unchanged from the first derivation.
All values below are from runs on
5da142ba7, captured ascmd > log 2>&1; EXIT=$?— never piped into
tail, per #9552.pnpm check:changeset-gate-self-testspnpm check:durability-log-levelpnpm check:objectui-changesetnode scripts/check-adr-0087-registration.mjsnode scripts/check-changeset-no-major.mjsnode scripts/check-empty-changeset.mjsnode scripts/check-engine-split-ratio.mjsnode scripts/docs-audit/check-affected-docs.mjspnpm check:query-options-erasurepnpm check:type-check-coveragepnpm check:type-check-debt --re-measurepnpm check:engine-double-contractpnpm check:where-matchercheck:durability-log-levelstays green and its census is unchanged — neitherthe read-invention baseline (
scripts/durability-read-invention.baseline.json, noplugin.tsentry before or after) nor the empty degradation baseline moved. Thatis the #8845 blind spot re-confirmed across this fix, exactly as #9154 re-measured
it: this shape remains invisible to the gate, which is why the pin file exists.
check:type-check-debt --re-measurecaught a real regression and it was fixed atthe source, not at the ledger. The new test file first added +1 raw tsc error to
@objectstack/objectql's TEST_DEBT (355 → 356): an action'sbodyis{ language, source }, not a bare string. Re-measured after the fixture fix:355, with 0 errors attributable to this file — the shrink-only ledger is
untouched, and raising it was never considered (maintainer-only).
Suites
@objectstack/objectql(full, final commit)@objectstack/objectql(baseline, before any edit)@objectstack/objectql typecheck(tsc --noEmit, script name echoed)@objectstack/runtime@objectstack/metadata@objectstack/metadata-protocol@objectstack/rest@objectstack/http-conformance@objectstack/client@objectstack/http-conformancefailed once onFailed to resolve entry for package "@objectstack/runtime"— that is theunbuilt-
dist/prerequisite, not a finding. It passed after building runtime.The consumer suites resolve
@objectstack/objectqlthrough packageexportsintodist/, sodist/index.jswas confirmed to carry the fix (readRegisteredObjectspresent) before those runs were read.
Probe, with its positive control
A zero-hit grep is not a measurement, so the probe
getAllObjects\?\.\(\)is shownhitting the known sites first:
origin/main'splugin.ts: 4 hits —:664(the deliberately-excluded log count),:1144,:1214,:2002.HEAD'splugin.ts: 1 executable hit —:665,the deliberately-excluded log count. The two other textual hits are prose:
docblock/comment lines quoting the retired spelling so the next reader recognises
it (the same thing
engine.ts:6946does for finding(objectql): the roll-up summary index's registry read swallows into an empty index — and CACHES the invented emptiness, so every parent roll-up silently stops recomputing #9154).plugin.tsreports 6 totalgetAllObjectsoccurrences onorigin/mainagainst the 3 matching the fallback shape (10 onHEAD— the helperand its docblock), so the probe discriminates rather than matching everything.
Scope
Clause ② was not reached and was not approached: no file under
packages/spec/src/**(only animport type { ServiceObject }, added so the seamskeep the exact element type they already inferred), no contract accept/reject
behaviour changed, no public surface widened.
syncRegisteredSchemaspropagatinginstead of silently returning is a boot behaviour change, not a contract one.
Files changed:
packages/objectql/src/plugin.ts,packages/objectql/src/plugin-registry-read-failure.test.ts(new),.changeset/objectql-plugin-registry-read-seams.md(new).Generated by Claude Code