Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): the action-governance audit resolves declarations through the router's rungs - #14421
Conversation
…ugh the router's rungs The boot inventory built its declaration set from object-embedded `actions[]` plus the metadata service's `action` rows, while `resolveRouteActionDeclaration` resolves through a third source between those two: the engine registry's standalone `action` items. On the in-process boot the metadata plane holds no `action` rows, so every object-less `defineAction` was reported as a registered handler with no declaration, "REFUSED at dispatch ... there is no opt-out", in the same boot in which the router resolved it at that rung and dispatched it. `ObjectQLPlugin` — the one caller holding the engine — now injects that rung, and the audit judges the answer with the router's own ownership test. The warning stops asserting a dispatch outcome it never performed: it names the sources it read, says it did not dispatch, and sends an author whose action IS declared to the real bug rather than to deleting a working registration. The file docblock's "the inventory can never disagree with the router" invariant is corrected to the one that now holds. `declared script actions with NO handler` is unchanged in wording and in population. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
📓 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 see
Coarse fallback — 15 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 4c2bf8fe9d4a0da48a9370b1a3c360d07f2b7bcb && git checkout 4c2bf8fe9d4a0da48a9370b1a3c360d07f2b7bcb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 793065de2c03936d4dd88f7026a1530d4c52c462 73bd8d568e4b77f9ed8c7e329bc2f3f1e55a237a && git checkout -B drift-repro 793065de2c03936d4dd88f7026a1530d4c52c462 && git merge --no-ff 73bd8d568e4b77f9ed8c7e329bc2f3f1e55a237a
node scripts/docs-audit/affected-docs.mjs --json 793065de2c03936d4dd88f7026a1530d4c52c462
|
os-musk
commented
Sep 2, 2026
Provenance ( Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14123
The startup
[action-governance]inventory and the REST router disagreed about whether a declaration exists, and the inventory printed its answer as a verified dispatch outcome. Both halves are fixed here.The rung order, read off
origin/main— route vs auditresolveRouteActionDeclaration(packages/runtime/src/action-execution.ts, read-only reference here) resolves a declaration in three rungs, in this order:ql.getSchema(objectName)(falling back toregistry.getObject), thenobj.actionsmatched by name — the object-embedded declaration.ql.registry.getItem('action', actionName), accepted whenownsRoute(action)—standaloneActionObjectName(deps, action) === objectName || isObjectLessActionKey(owner).meta.loadDiagnosed('action', name), elsemeta.load('action', name), under the sameownsRoutetest.The audit built its declaration set in
collectEngineActionDeclarations(args.objects, args.loadStandaloneActions): each registry object's embeddedactionsarray (rung 1) plusmeta.loadMany('action')(the bulk form of rung 3). Rung 2 was absent. That is the whole defect: the verdict tracked whether an action happens to be object-bound, not whether it is declared.actionsarrayactionitemsactionrowsloadManyloadManyThe two boots — why the card measured an empty plane and the filer later measured a full one
The card's probe was taken on the in-process boot (
new AppPlugin({ ...stackConfig, onEnable })thenkernel.bootstrap()), where the metadata plugin's artifact ingestion does not run:meta.loadMany('action')answers[]whileql.registry.getItem('action', name)answers the declaration. Onobjectstack devthe plane is populated (filer's un-claim comment:GET /api/v1/meta/actionreturns 6). The fix has to be right on both, and it is, because it no longer depends on which source happens to be populated: the audit now asks every source the router asks.Pinned both ways in
packages/objectql/src/action-governance.test.ts: the in-process shape (registry holds it,loadStandaloneActionsanswers[]⇒ not reported) for both call forms, object-bound and object-lessglobal; and a positive control (a handler no source declares ⇒ still reported) in the same run as a cleared one, so a fix that simply silences the warning is red.The message — before and after
Before (the sentence the card and the filer both objected to):
After:
Three things changed and one deliberately did not. It states what was measured (the sources it read) instead of a runtime outcome it never performed; it says it did not dispatch in as many words; and it no longer offers "drop the registration" as the branch an author reaches for after "declare it with
defineAction" fails — that branch is where a working onboarding path would have been deleted under a greenpnpm validate. The other warning in the block,declared script actions with NO handler, is unchanged in wording and in population, pinned against its exact literal string.The docblock invariant — corrected, not merely repaired
Triage required this explicitly. Before (
action-governance.ts, the closing paragraph):After, in substance: sharing functions buys agreement about what keys a declaration can address; it never bought agreement about whether a declaration exists, because the two sides answered that from different sources — and the paragraph now names the three rungs, records the measured disagreement, and states the invariant that actually holds:
The same over-claim appeared a second time, in the docblock of
reconcileActionRegistrations("Since D3 those are REFUSED at dispatch, so this list is the upgrade checklist"). That function is pure set reconciliation and knows nothing about where its set came from, so its scope is now stated literally, with the caller named as the thing that owes the remaining rungs.Shape of the fix
ObjectQLPlugin.runGovernanceInventory— the one call site, and the only place withqlin hand — passeslookupRegistryAction: (actionName) => ql.registry?.getItem?.('action', actionName). Dependency direction is untouched (runtime to objectql, never the reverse); the audit cannot import the router.standaloneActionOwnerKey(the three-line owner ladder, previously written out three times) andstandaloneActionOwnsRoute(the router'sownsRoute, asymmetry included — an object-less declaration owns any route, an object-bound one owns only its own)./actions/O/Kexactly when the router resolves a declaration named K owning O, so this is the same question asked of the same source — and it leavesunboundDeclarationsreading the population it read before.Ablation — predicted before the run, then measured
Both legs resolve through relative source imports (
./action-governance.js,./plugin.js), so no packageexportsboundary and nodist/sits in the resolution path; the mutation reaches the code under test directly. Each leg carried anEXIT INT TERMtrap, proved the mutation on disk by anchored counts of the removed and injected text before reading a single result, and proved the restore bygit hash-objectagainst the HEAD blob plus an emptygit diff HEAD.A — remove the injected rung at the call site (
plugin.ts). Predicted: the three wiring pins red, the audit-level suite entirely green (it hands the rung over itself, which is the blindness the sibling file exists to close). Measured:PRE anchor-count=1thenPOST anchor-count=0, worktree blobd0051a4vs HEAD blob3269418; 3 failed | 17 passed — exactly the three inplugin-action-governance-rung.test.ts, and every test inaction-governance.test.tsgreen. Restore: worktree blob back to3269418, diff vs HEAD empty.B — keep the wiring, disable the rung inside the audit (
args.lookupRegistryActionbecomesundefinedat the one call). Predicted: 7 red / 13 green, named individually in the script before it ran. Measured:PRE removed-text=1 injected-text=0thenPOST removed-text=0 injected-text=1, worktree blobb348082vs HEAD blob29b6f13; 7 failed | 13 passed, the same seven the prediction named. Restore: worktree blob back to29b6f13, diff vs HEAD empty.Verification
Union run on the final HEAD
73bd8d5(git rev-parse --short HEAD), tree clean.pnpm --filter @objectstack/objectql typecheck— OK, includingcheck:test-typecheck(44 files / 242 errors / 69 pinned signatures held, shrink-only). Both edited/new test files are genuinely in that program:tsc -p tsconfig.test.json --listFileslists each once, and none of the 242 ledgered errors is in either of them.pnpm --filter @objectstack/objectql test—Test Files 256 passed (256),Tests 4418 passed (4418).pnpm lint(repo-wideeslint . --no-inline-config) — exit 0, no narrowing.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandson this HEAD (32 by path + 6 by change kind, 2 shared), pluspnpm check:nul-bytes: 34 green, 3 NOT MEASURED and 0 red.check:system-context-censusis green on its own verdict line ("109 elevation read sites … all anchored"), so theplugin.tsline shift needed no re-anchor.check:type-check-debt --re-measureandcheck:dual-build-cjs-loadswere re-run after a full workspace build, both green ("27 ledger entries re-measured … none above its recorded number"; "102 published require entry points across 66 packages load").node scripts/check-test-completeness.mjsexit 3 ("There is no local log to hand it, so the local reading for this gate is NOT MEASURED"),node scripts/pm/check-half-states.mjsexit 3 (needs the GitHub API this session cannot reach). Every gate exit code was captured after a redirect, never through a pipe.git fetch: one sibling branch touchespackages/objectql/src/plugin.ts(claude/issue-14163-install-gate-co-ownership) and its hunks are at lines 4 and 433 against mine at 2476 and 2529 — textually disjoint.git merge-tree --write-tree origin/main HEADagainstorigin/mainatbd4096ffareports no conflict.Changeset
.changeset/action-governance-registry-rung.md,@objectstack/objectqlpatch — the warning text is user-visible, so it carries the before/after and the reason both old remedies were wrong for this shape.Scope
Fences held: no export added to
packages/objectql/src/index.ts, and nothing written inengine.ts,registry.ts,packages/runtime/**,packages/spec/**orcontent/docs/releases/**. One bounded extension inside an already-declared file, declared on the card before it landed and named here with its evidence: the docblock ofObjectQLPlugin.runGovernanceInventory, five lines above the call site, carried the same over-claim the card is about ("every handler listed here answers 404 at dispatch"); it now says a listed handler is one no source declares, and that the message stops short of a dispatch this audit never performed.Clause 2 self-reading: no, agreeing with the PM. No accept/reject behaviour on a public door changes and no surface widens — the REST route is untouched, the dispatch decision is untouched, and the diff moves a boot-time diagnostic from two sources to three and rewrites what it says.
🤖 Generated with Claude Code
https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Generated by Claude Code