fix(objectql): the action-governance audit resolves declarations through the router's rungs - #14421

Merged
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung
Sep 2, 2026
Merged

fix(objectql): the action-governance audit resolves declarations through the router's rungs#14421
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

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 audit

resolveRouteActionDeclaration (packages/runtime/src/action-execution.ts, read-only reference here) resolves a declaration in three rungs, in this order:

  1. ql.getSchema(objectName) (falling back to registry.getObject), then obj.actions matched by name — the object-embedded declaration.
  2. ql.registry.getItem('action', actionName), accepted when ownsRoute(action)standaloneActionObjectName(deps, action) === objectName || isObjectLessActionKey(owner).
  3. meta.loadDiagnosed('action', name), else meta.load('action', name), under the same ownsRoute test.

The audit built its declaration set in collectEngineActionDeclarations(args.objects, args.loadStandaloneActions): each registry object's embedded actions array (rung 1) plus meta.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.

sourcerouteraudit beforeaudit after
object-embedded actions arrayrung 1yesyes
engine registry standalone action itemsrung 2noyes, injected by the one caller
metadata service action rowsrung 3yes, via loadManyyes, via loadMany

The 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 }) then kernel.bootstrap()), where the metadata plugin's artifact ingestion does not run: meta.loadMany('action') answers [] while ql.registry.getItem('action', name) answers the declaration. On objectstack dev the plane is populated (filer's un-claim comment: GET /api/v1/meta/action returns 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, loadStandaloneActions answers []not reported) for both call forms, object-bound and object-less global; 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):

[action-governance] registered handlers with NO declaration — these are REFUSED at dispatch (ADR-0110 D3) and there is no opt-out; declare each one with defineAction, or drop the registration if nothing should invoke it over HTTP

After:

[action-governance] registered handlers with NO declaration in any source the router resolves through (object-embedded actions[], the engine registry standalone action items, the metadata service action rows). ADR-0110 D3 refuses a handler whose declaration the router cannot resolve, so each of these is expected to answer 404 — expected, not measured: this audit read the sources, it did not dispatch. Declare each one with defineAction; if you believe it IS declared, then its declaration is not reaching this engine, and that is the bug to report rather than dropping a registration that may still be serving traffic

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 green pnpm 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):

Runtime re-exports these under their old names — dispatch and the MCP bridge keep reading the SAME functions, which is the load-bearing property: the inventory can never disagree with the router about what a declaration can address.

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 invariant this file may claim, and no more: the inventory reports a handler as undeclared only when EVERY source the router resolves through answered nothing for it.

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

  • The rung is injected, not re-implemented: ObjectQLPlugin.runGovernanceInventory — the one call site, and the only place with ql in hand — passes lookupRegistryAction: (actionName) => ql.registry?.getItem?.('action', actionName). Dependency direction is untouched (runtime to objectql, never the reverse); the audit cannot import the router.
  • The rules stay in the engine module beside the rest of the addressing vocabulary: standaloneActionOwnerKey (the three-line owner ladder, previously written out three times) and standaloneActionOwnsRoute (the router's ownsRoute, asymmetry included — an object-less declaration owns any route, an object-bound one owns only its own).
  • The probe is by NAME, mirroring rung 2, rather than folding the registry into the declaration set. A handler under key K on object O is dispatchable at /actions/O/K exactly when the router resolves a declaration named K owning O, so this is the same question asked of the same source — and it leaves unboundDeclarations reading the population it read before.
  • Conservative in one direction only: a lookup that throws, or answers a non-object, leaves the handler ON the list. The audit can over-report a broken registry; it cannot clear a handler on an answer it could not read. Warn-only, exception-proof and fingerprint-deduplicated all hold, each pinned — including that the fingerprint is taken from the FILTERED set, so a boot the rung clears reports nothing and remembers nothing.

Ablation — predicted before the run, then measured

Both legs resolve through relative source imports (./action-governance.js, ./plugin.js), so no package exports boundary and no dist/ sits in the resolution path; the mutation reaches the code under test directly. Each leg carried an EXIT INT TERM trap, proved the mutation on disk by anchored counts of the removed and injected text before reading a single result, and proved the restore by git hash-object against the HEAD blob plus an empty git 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=1 then POST anchor-count=0, worktree blob d0051a4 vs HEAD blob 3269418; 3 failed | 17 passed — exactly the three in plugin-action-governance-rung.test.ts, and every test in action-governance.test.ts green. Restore: worktree blob back to 3269418, diff vs HEAD empty.

B — keep the wiring, disable the rung inside the audit (args.lookupRegistryAction becomes undefined at the one call). Predicted: 7 red / 13 green, named individually in the script before it ran. Measured: PRE removed-text=1 injected-text=0 then POST removed-text=0 injected-text=1, worktree blob b348082 vs HEAD blob 29b6f13; 7 failed | 13 passed, the same seven the prediction named. Restore: worktree blob back to 29b6f13, 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, including check: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 --listFiles lists each once, and none of the 242 ledgered errors is in either of them.
  • pnpm --filter @objectstack/objectql testTest Files 256 passed (256), Tests 4418 passed (4418).
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, no narrowing.
  • The 36-family union derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on this HEAD (32 by path + 6 by change kind, 2 shared), plus pnpm check:nul-bytes: 34 green, 3 NOT MEASURED and 0 red. check:system-context-census is green on its own verdict line ("109 elevation read sites … all anchored"), so the plugin.ts line shift needed no re-anchor. check:type-check-debt --re-measure and check:dual-build-cjs-loads were 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").
  • NOT MEASURED, recorded with each gate's own text, never as a pass: node scripts/check-test-completeness.mjs exit 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.mjs exit 3 (needs the GitHub API this session cannot reach). Every gate exit code was captured after a redirect, never through a pipe.
  • Serial re-check against the final file list, zero quota, after a full git fetch: one sibling branch touches packages/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 HEAD against origin/main at bd4096ffa reports no conflict.

Changeset

.changeset/action-governance-registry-rung.md, @objectstack/objectql patch — 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 in engine.ts, registry.ts, packages/runtime/**, packages/spec/** or content/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 of ObjectQLPlugin.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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 7 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 793065de2c03936d4dd88f7026a1530d4c52c462packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4c2bf8fe9d4a0da48a9370b1a3c360d07f2b7bcb — the merge of head 73bd8d568e4b77f9ed8c7e329bc2f3f1e55a237a into base 793065de2c03936d4dd88f7026a1530d4c52c462, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 793065de2c03936d4dd88f7026a1530d4c52c462 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance (domain:engine seat, session session_0112hMx9hjJ9BgB28X97DS68, 06:26Z): flipped to ready and auto-merge (squash) armed on head 73bd8d568. ACCEPT on the card: 5505235600 (#14123). Every check run on this head completed success or skipped (Lint & Repo Gates 06:23:25Z, Test Core (1/6) 06:20:08Z); mergeable_state: clean; governed-surface test on the exact five-file list: NOT governed. Clause-②: no. Landing to-do at MERGED: verify by content on origin/main, strip pm:dispatched from #14123 (the Fixes keyword closes it), landing record.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bd8795eSep 2, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14123-action-governance-registry-rung branch September 2, 2026 06:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(objectql): the action-governance audit resolves declarations through the router's rungs - #14421

Merged
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung
Sep 2, 2026
Merged

fix(objectql): the action-governance audit resolves declarations through the router's rungs#14421
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

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 audit

resolveRouteActionDeclaration (packages/runtime/src/action-execution.ts, read-only reference here) resolves a declaration in three rungs, in this order:

  1. ql.getSchema(objectName) (falling back to registry.getObject), then obj.actions matched by name — the object-embedded declaration.
  2. ql.registry.getItem('action', actionName), accepted when ownsRoute(action)standaloneActionObjectName(deps, action) === objectName || isObjectLessActionKey(owner).
  3. meta.loadDiagnosed('action', name), else meta.load('action', name), under the same ownsRoute test.

The audit built its declaration set in collectEngineActionDeclarations(args.objects, args.loadStandaloneActions): each registry object's embedded actions array (rung 1) plus meta.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.

sourcerouteraudit beforeaudit after
object-embedded actions arrayrung 1yesyes
engine registry standalone action itemsrung 2noyes, injected by the one caller
metadata service action rowsrung 3yes, via loadManyyes, via loadMany

The 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 }) then kernel.bootstrap()), where the metadata plugin's artifact ingestion does not run: meta.loadMany('action') answers [] while ql.registry.getItem('action', name) answers the declaration. On objectstack dev the plane is populated (filer's un-claim comment: GET /api/v1/meta/action returns 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, loadStandaloneActions answers []not reported) for both call forms, object-bound and object-less global; 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):

[action-governance] registered handlers with NO declaration — these are REFUSED at dispatch (ADR-0110 D3) and there is no opt-out; declare each one with defineAction, or drop the registration if nothing should invoke it over HTTP

After:

[action-governance] registered handlers with NO declaration in any source the router resolves through (object-embedded actions[], the engine registry standalone action items, the metadata service action rows). ADR-0110 D3 refuses a handler whose declaration the router cannot resolve, so each of these is expected to answer 404 — expected, not measured: this audit read the sources, it did not dispatch. Declare each one with defineAction; if you believe it IS declared, then its declaration is not reaching this engine, and that is the bug to report rather than dropping a registration that may still be serving traffic

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 green pnpm 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):

Runtime re-exports these under their old names — dispatch and the MCP bridge keep reading the SAME functions, which is the load-bearing property: the inventory can never disagree with the router about what a declaration can address.

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 invariant this file may claim, and no more: the inventory reports a handler as undeclared only when EVERY source the router resolves through answered nothing for it.

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

  • The rung is injected, not re-implemented: ObjectQLPlugin.runGovernanceInventory — the one call site, and the only place with ql in hand — passes lookupRegistryAction: (actionName) => ql.registry?.getItem?.('action', actionName). Dependency direction is untouched (runtime to objectql, never the reverse); the audit cannot import the router.
  • The rules stay in the engine module beside the rest of the addressing vocabulary: standaloneActionOwnerKey (the three-line owner ladder, previously written out three times) and standaloneActionOwnsRoute (the router's ownsRoute, asymmetry included — an object-less declaration owns any route, an object-bound one owns only its own).
  • The probe is by NAME, mirroring rung 2, rather than folding the registry into the declaration set. A handler under key K on object O is dispatchable at /actions/O/K exactly when the router resolves a declaration named K owning O, so this is the same question asked of the same source — and it leaves unboundDeclarations reading the population it read before.
  • Conservative in one direction only: a lookup that throws, or answers a non-object, leaves the handler ON the list. The audit can over-report a broken registry; it cannot clear a handler on an answer it could not read. Warn-only, exception-proof and fingerprint-deduplicated all hold, each pinned — including that the fingerprint is taken from the FILTERED set, so a boot the rung clears reports nothing and remembers nothing.

Ablation — predicted before the run, then measured

Both legs resolve through relative source imports (./action-governance.js, ./plugin.js), so no package exports boundary and no dist/ sits in the resolution path; the mutation reaches the code under test directly. Each leg carried an EXIT INT TERM trap, proved the mutation on disk by anchored counts of the removed and injected text before reading a single result, and proved the restore by git hash-object against the HEAD blob plus an empty git 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=1 then POST anchor-count=0, worktree blob d0051a4 vs HEAD blob 3269418; 3 failed | 17 passed — exactly the three in plugin-action-governance-rung.test.ts, and every test in action-governance.test.ts green. Restore: worktree blob back to 3269418, diff vs HEAD empty.

B — keep the wiring, disable the rung inside the audit (args.lookupRegistryAction becomes undefined at the one call). Predicted: 7 red / 13 green, named individually in the script before it ran. Measured: PRE removed-text=1 injected-text=0 then POST removed-text=0 injected-text=1, worktree blob b348082 vs HEAD blob 29b6f13; 7 failed | 13 passed, the same seven the prediction named. Restore: worktree blob back to 29b6f13, 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, including check: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 --listFiles lists each once, and none of the 242 ledgered errors is in either of them.
  • pnpm --filter @objectstack/objectql testTest Files 256 passed (256), Tests 4418 passed (4418).
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, no narrowing.
  • The 36-family union derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on this HEAD (32 by path + 6 by change kind, 2 shared), plus pnpm check:nul-bytes: 34 green, 3 NOT MEASURED and 0 red. check:system-context-census is green on its own verdict line ("109 elevation read sites … all anchored"), so the plugin.ts line shift needed no re-anchor. check:type-check-debt --re-measure and check:dual-build-cjs-loads were 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").
  • NOT MEASURED, recorded with each gate's own text, never as a pass: node scripts/check-test-completeness.mjs exit 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.mjs exit 3 (needs the GitHub API this session cannot reach). Every gate exit code was captured after a redirect, never through a pipe.
  • Serial re-check against the final file list, zero quota, after a full git fetch: one sibling branch touches packages/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 HEAD against origin/main at bd4096ffa reports no conflict.

Changeset

.changeset/action-governance-registry-rung.md, @objectstack/objectql patch — 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 in engine.ts, registry.ts, packages/runtime/**, packages/spec/** or content/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 of ObjectQLPlugin.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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 7 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 793065de2c03936d4dd88f7026a1530d4c52c462packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4c2bf8fe9d4a0da48a9370b1a3c360d07f2b7bcb — the merge of head 73bd8d568e4b77f9ed8c7e329bc2f3f1e55a237a into base 793065de2c03936d4dd88f7026a1530d4c52c462, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 793065de2c03936d4dd88f7026a1530d4c52c462 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance (domain:engine seat, session session_0112hMx9hjJ9BgB28X97DS68, 06:26Z): flipped to ready and auto-merge (squash) armed on head 73bd8d568. ACCEPT on the card: 5505235600 (#14123). Every check run on this head completed success or skipped (Lint & Repo Gates 06:23:25Z, Test Core (1/6) 06:20:08Z); mergeable_state: clean; governed-surface test on the exact five-file list: NOT governed. Clause-②: no. Landing to-do at MERGED: verify by content on origin/main, strip pm:dispatched from #14123 (the Fixes keyword closes it), landing record.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bd8795eSep 2, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14123-action-governance-registry-rung branch September 2, 2026 06:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(objectql): the action-governance audit resolves declarations through the router's rungs - #14421

Merged
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung
Sep 2, 2026
Merged

fix(objectql): the action-governance audit resolves declarations through the router's rungs#14421
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

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 audit

resolveRouteActionDeclaration (packages/runtime/src/action-execution.ts, read-only reference here) resolves a declaration in three rungs, in this order:

  1. ql.getSchema(objectName) (falling back to registry.getObject), then obj.actions matched by name — the object-embedded declaration.
  2. ql.registry.getItem('action', actionName), accepted when ownsRoute(action)standaloneActionObjectName(deps, action) === objectName || isObjectLessActionKey(owner).
  3. meta.loadDiagnosed('action', name), else meta.load('action', name), under the same ownsRoute test.

The audit built its declaration set in collectEngineActionDeclarations(args.objects, args.loadStandaloneActions): each registry object's embedded actions array (rung 1) plus meta.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.

sourcerouteraudit beforeaudit after
object-embedded actions arrayrung 1yesyes
engine registry standalone action itemsrung 2noyes, injected by the one caller
metadata service action rowsrung 3yes, via loadManyyes, via loadMany

The 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 }) then kernel.bootstrap()), where the metadata plugin's artifact ingestion does not run: meta.loadMany('action') answers [] while ql.registry.getItem('action', name) answers the declaration. On objectstack dev the plane is populated (filer's un-claim comment: GET /api/v1/meta/action returns 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, loadStandaloneActions answers []not reported) for both call forms, object-bound and object-less global; 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):

[action-governance] registered handlers with NO declaration — these are REFUSED at dispatch (ADR-0110 D3) and there is no opt-out; declare each one with defineAction, or drop the registration if nothing should invoke it over HTTP

After:

[action-governance] registered handlers with NO declaration in any source the router resolves through (object-embedded actions[], the engine registry standalone action items, the metadata service action rows). ADR-0110 D3 refuses a handler whose declaration the router cannot resolve, so each of these is expected to answer 404 — expected, not measured: this audit read the sources, it did not dispatch. Declare each one with defineAction; if you believe it IS declared, then its declaration is not reaching this engine, and that is the bug to report rather than dropping a registration that may still be serving traffic

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 green pnpm 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):

Runtime re-exports these under their old names — dispatch and the MCP bridge keep reading the SAME functions, which is the load-bearing property: the inventory can never disagree with the router about what a declaration can address.

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 invariant this file may claim, and no more: the inventory reports a handler as undeclared only when EVERY source the router resolves through answered nothing for it.

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

  • The rung is injected, not re-implemented: ObjectQLPlugin.runGovernanceInventory — the one call site, and the only place with ql in hand — passes lookupRegistryAction: (actionName) => ql.registry?.getItem?.('action', actionName). Dependency direction is untouched (runtime to objectql, never the reverse); the audit cannot import the router.
  • The rules stay in the engine module beside the rest of the addressing vocabulary: standaloneActionOwnerKey (the three-line owner ladder, previously written out three times) and standaloneActionOwnsRoute (the router's ownsRoute, asymmetry included — an object-less declaration owns any route, an object-bound one owns only its own).
  • The probe is by NAME, mirroring rung 2, rather than folding the registry into the declaration set. A handler under key K on object O is dispatchable at /actions/O/K exactly when the router resolves a declaration named K owning O, so this is the same question asked of the same source — and it leaves unboundDeclarations reading the population it read before.
  • Conservative in one direction only: a lookup that throws, or answers a non-object, leaves the handler ON the list. The audit can over-report a broken registry; it cannot clear a handler on an answer it could not read. Warn-only, exception-proof and fingerprint-deduplicated all hold, each pinned — including that the fingerprint is taken from the FILTERED set, so a boot the rung clears reports nothing and remembers nothing.

Ablation — predicted before the run, then measured

Both legs resolve through relative source imports (./action-governance.js, ./plugin.js), so no package exports boundary and no dist/ sits in the resolution path; the mutation reaches the code under test directly. Each leg carried an EXIT INT TERM trap, proved the mutation on disk by anchored counts of the removed and injected text before reading a single result, and proved the restore by git hash-object against the HEAD blob plus an empty git 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=1 then POST anchor-count=0, worktree blob d0051a4 vs HEAD blob 3269418; 3 failed | 17 passed — exactly the three in plugin-action-governance-rung.test.ts, and every test in action-governance.test.ts green. Restore: worktree blob back to 3269418, diff vs HEAD empty.

B — keep the wiring, disable the rung inside the audit (args.lookupRegistryAction becomes undefined at the one call). Predicted: 7 red / 13 green, named individually in the script before it ran. Measured: PRE removed-text=1 injected-text=0 then POST removed-text=0 injected-text=1, worktree blob b348082 vs HEAD blob 29b6f13; 7 failed | 13 passed, the same seven the prediction named. Restore: worktree blob back to 29b6f13, 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, including check: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 --listFiles lists each once, and none of the 242 ledgered errors is in either of them.
  • pnpm --filter @objectstack/objectql testTest Files 256 passed (256), Tests 4418 passed (4418).
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, no narrowing.
  • The 36-family union derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on this HEAD (32 by path + 6 by change kind, 2 shared), plus pnpm check:nul-bytes: 34 green, 3 NOT MEASURED and 0 red. check:system-context-census is green on its own verdict line ("109 elevation read sites … all anchored"), so the plugin.ts line shift needed no re-anchor. check:type-check-debt --re-measure and check:dual-build-cjs-loads were 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").
  • NOT MEASURED, recorded with each gate's own text, never as a pass: node scripts/check-test-completeness.mjs exit 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.mjs exit 3 (needs the GitHub API this session cannot reach). Every gate exit code was captured after a redirect, never through a pipe.
  • Serial re-check against the final file list, zero quota, after a full git fetch: one sibling branch touches packages/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 HEAD against origin/main at bd4096ffa reports no conflict.

Changeset

.changeset/action-governance-registry-rung.md, @objectstack/objectql patch — 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 in engine.ts, registry.ts, packages/runtime/**, packages/spec/** or content/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 of ObjectQLPlugin.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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 7 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 793065de2c03936d4dd88f7026a1530d4c52c462packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4c2bf8fe9d4a0da48a9370b1a3c360d07f2b7bcb — the merge of head 73bd8d568e4b77f9ed8c7e329bc2f3f1e55a237a into base 793065de2c03936d4dd88f7026a1530d4c52c462, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 793065de2c03936d4dd88f7026a1530d4c52c462 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance (domain:engine seat, session session_0112hMx9hjJ9BgB28X97DS68, 06:26Z): flipped to ready and auto-merge (squash) armed on head 73bd8d568. ACCEPT on the card: 5505235600 (#14123). Every check run on this head completed success or skipped (Lint & Repo Gates 06:23:25Z, Test Core (1/6) 06:20:08Z); mergeable_state: clean; governed-surface test on the exact five-file list: NOT governed. Clause-②: no. Landing to-do at MERGED: verify by content on origin/main, strip pm:dispatched from #14123 (the Fixes keyword closes it), landing record.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bd8795eSep 2, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14123-action-governance-registry-rung branch September 2, 2026 06:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(objectql): the action-governance audit resolves declarations through the router's rungs - #14421

Merged
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung
Sep 2, 2026
Merged

fix(objectql): the action-governance audit resolves declarations through the router's rungs#14421
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

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 audit

resolveRouteActionDeclaration (packages/runtime/src/action-execution.ts, read-only reference here) resolves a declaration in three rungs, in this order:

  1. ql.getSchema(objectName) (falling back to registry.getObject), then obj.actions matched by name — the object-embedded declaration.
  2. ql.registry.getItem('action', actionName), accepted when ownsRoute(action)standaloneActionObjectName(deps, action) === objectName || isObjectLessActionKey(owner).
  3. meta.loadDiagnosed('action', name), else meta.load('action', name), under the same ownsRoute test.

The audit built its declaration set in collectEngineActionDeclarations(args.objects, args.loadStandaloneActions): each registry object's embedded actions array (rung 1) plus meta.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.

sourcerouteraudit beforeaudit after
object-embedded actions arrayrung 1yesyes
engine registry standalone action itemsrung 2noyes, injected by the one caller
metadata service action rowsrung 3yes, via loadManyyes, via loadMany

The 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 }) then kernel.bootstrap()), where the metadata plugin's artifact ingestion does not run: meta.loadMany('action') answers [] while ql.registry.getItem('action', name) answers the declaration. On objectstack dev the plane is populated (filer's un-claim comment: GET /api/v1/meta/action returns 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, loadStandaloneActions answers []not reported) for both call forms, object-bound and object-less global; 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):

[action-governance] registered handlers with NO declaration — these are REFUSED at dispatch (ADR-0110 D3) and there is no opt-out; declare each one with defineAction, or drop the registration if nothing should invoke it over HTTP

After:

[action-governance] registered handlers with NO declaration in any source the router resolves through (object-embedded actions[], the engine registry standalone action items, the metadata service action rows). ADR-0110 D3 refuses a handler whose declaration the router cannot resolve, so each of these is expected to answer 404 — expected, not measured: this audit read the sources, it did not dispatch. Declare each one with defineAction; if you believe it IS declared, then its declaration is not reaching this engine, and that is the bug to report rather than dropping a registration that may still be serving traffic

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 green pnpm 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):

Runtime re-exports these under their old names — dispatch and the MCP bridge keep reading the SAME functions, which is the load-bearing property: the inventory can never disagree with the router about what a declaration can address.

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 invariant this file may claim, and no more: the inventory reports a handler as undeclared only when EVERY source the router resolves through answered nothing for it.

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

  • The rung is injected, not re-implemented: ObjectQLPlugin.runGovernanceInventory — the one call site, and the only place with ql in hand — passes lookupRegistryAction: (actionName) => ql.registry?.getItem?.('action', actionName). Dependency direction is untouched (runtime to objectql, never the reverse); the audit cannot import the router.
  • The rules stay in the engine module beside the rest of the addressing vocabulary: standaloneActionOwnerKey (the three-line owner ladder, previously written out three times) and standaloneActionOwnsRoute (the router's ownsRoute, asymmetry included — an object-less declaration owns any route, an object-bound one owns only its own).
  • The probe is by NAME, mirroring rung 2, rather than folding the registry into the declaration set. A handler under key K on object O is dispatchable at /actions/O/K exactly when the router resolves a declaration named K owning O, so this is the same question asked of the same source — and it leaves unboundDeclarations reading the population it read before.
  • Conservative in one direction only: a lookup that throws, or answers a non-object, leaves the handler ON the list. The audit can over-report a broken registry; it cannot clear a handler on an answer it could not read. Warn-only, exception-proof and fingerprint-deduplicated all hold, each pinned — including that the fingerprint is taken from the FILTERED set, so a boot the rung clears reports nothing and remembers nothing.

Ablation — predicted before the run, then measured

Both legs resolve through relative source imports (./action-governance.js, ./plugin.js), so no package exports boundary and no dist/ sits in the resolution path; the mutation reaches the code under test directly. Each leg carried an EXIT INT TERM trap, proved the mutation on disk by anchored counts of the removed and injected text before reading a single result, and proved the restore by git hash-object against the HEAD blob plus an empty git 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=1 then POST anchor-count=0, worktree blob d0051a4 vs HEAD blob 3269418; 3 failed | 17 passed — exactly the three in plugin-action-governance-rung.test.ts, and every test in action-governance.test.ts green. Restore: worktree blob back to 3269418, diff vs HEAD empty.

B — keep the wiring, disable the rung inside the audit (args.lookupRegistryAction becomes undefined at the one call). Predicted: 7 red / 13 green, named individually in the script before it ran. Measured: PRE removed-text=1 injected-text=0 then POST removed-text=0 injected-text=1, worktree blob b348082 vs HEAD blob 29b6f13; 7 failed | 13 passed, the same seven the prediction named. Restore: worktree blob back to 29b6f13, 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, including check: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 --listFiles lists each once, and none of the 242 ledgered errors is in either of them.
  • pnpm --filter @objectstack/objectql testTest Files 256 passed (256), Tests 4418 passed (4418).
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, no narrowing.
  • The 36-family union derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on this HEAD (32 by path + 6 by change kind, 2 shared), plus pnpm check:nul-bytes: 34 green, 3 NOT MEASURED and 0 red. check:system-context-census is green on its own verdict line ("109 elevation read sites … all anchored"), so the plugin.ts line shift needed no re-anchor. check:type-check-debt --re-measure and check:dual-build-cjs-loads were 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").
  • NOT MEASURED, recorded with each gate's own text, never as a pass: node scripts/check-test-completeness.mjs exit 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.mjs exit 3 (needs the GitHub API this session cannot reach). Every gate exit code was captured after a redirect, never through a pipe.
  • Serial re-check against the final file list, zero quota, after a full git fetch: one sibling branch touches packages/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 HEAD against origin/main at bd4096ffa reports no conflict.

Changeset

.changeset/action-governance-registry-rung.md, @objectstack/objectql patch — 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 in engine.ts, registry.ts, packages/runtime/**, packages/spec/** or content/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 of ObjectQLPlugin.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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 7 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 793065de2c03936d4dd88f7026a1530d4c52c462packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4c2bf8fe9d4a0da48a9370b1a3c360d07f2b7bcb — the merge of head 73bd8d568e4b77f9ed8c7e329bc2f3f1e55a237a into base 793065de2c03936d4dd88f7026a1530d4c52c462, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 793065de2c03936d4dd88f7026a1530d4c52c462 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance (domain:engine seat, session session_0112hMx9hjJ9BgB28X97DS68, 06:26Z): flipped to ready and auto-merge (squash) armed on head 73bd8d568. ACCEPT on the card: 5505235600 (#14123). Every check run on this head completed success or skipped (Lint & Repo Gates 06:23:25Z, Test Core (1/6) 06:20:08Z); mergeable_state: clean; governed-surface test on the exact five-file list: NOT governed. Clause-②: no. Landing to-do at MERGED: verify by content on origin/main, strip pm:dispatched from #14123 (the Fixes keyword closes it), landing record.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bd8795eSep 2, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14123-action-governance-registry-rung branch September 2, 2026 06:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(objectql): the action-governance audit resolves declarations through the router's rungs - #14421

Merged
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung
Sep 2, 2026
Merged

fix(objectql): the action-governance audit resolves declarations through the router's rungs#14421
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

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 audit

resolveRouteActionDeclaration (packages/runtime/src/action-execution.ts, read-only reference here) resolves a declaration in three rungs, in this order:

  1. ql.getSchema(objectName) (falling back to registry.getObject), then obj.actions matched by name — the object-embedded declaration.
  2. ql.registry.getItem('action', actionName), accepted when ownsRoute(action)standaloneActionObjectName(deps, action) === objectName || isObjectLessActionKey(owner).
  3. meta.loadDiagnosed('action', name), else meta.load('action', name), under the same ownsRoute test.

The audit built its declaration set in collectEngineActionDeclarations(args.objects, args.loadStandaloneActions): each registry object's embedded actions array (rung 1) plus meta.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.

sourcerouteraudit beforeaudit after
object-embedded actions arrayrung 1yesyes
engine registry standalone action itemsrung 2noyes, injected by the one caller
metadata service action rowsrung 3yes, via loadManyyes, via loadMany

The 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 }) then kernel.bootstrap()), where the metadata plugin's artifact ingestion does not run: meta.loadMany('action') answers [] while ql.registry.getItem('action', name) answers the declaration. On objectstack dev the plane is populated (filer's un-claim comment: GET /api/v1/meta/action returns 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, loadStandaloneActions answers []not reported) for both call forms, object-bound and object-less global; 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):

[action-governance] registered handlers with NO declaration — these are REFUSED at dispatch (ADR-0110 D3) and there is no opt-out; declare each one with defineAction, or drop the registration if nothing should invoke it over HTTP

After:

[action-governance] registered handlers with NO declaration in any source the router resolves through (object-embedded actions[], the engine registry standalone action items, the metadata service action rows). ADR-0110 D3 refuses a handler whose declaration the router cannot resolve, so each of these is expected to answer 404 — expected, not measured: this audit read the sources, it did not dispatch. Declare each one with defineAction; if you believe it IS declared, then its declaration is not reaching this engine, and that is the bug to report rather than dropping a registration that may still be serving traffic

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 green pnpm 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):

Runtime re-exports these under their old names — dispatch and the MCP bridge keep reading the SAME functions, which is the load-bearing property: the inventory can never disagree with the router about what a declaration can address.

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 invariant this file may claim, and no more: the inventory reports a handler as undeclared only when EVERY source the router resolves through answered nothing for it.

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

  • The rung is injected, not re-implemented: ObjectQLPlugin.runGovernanceInventory — the one call site, and the only place with ql in hand — passes lookupRegistryAction: (actionName) => ql.registry?.getItem?.('action', actionName). Dependency direction is untouched (runtime to objectql, never the reverse); the audit cannot import the router.
  • The rules stay in the engine module beside the rest of the addressing vocabulary: standaloneActionOwnerKey (the three-line owner ladder, previously written out three times) and standaloneActionOwnsRoute (the router's ownsRoute, asymmetry included — an object-less declaration owns any route, an object-bound one owns only its own).
  • The probe is by NAME, mirroring rung 2, rather than folding the registry into the declaration set. A handler under key K on object O is dispatchable at /actions/O/K exactly when the router resolves a declaration named K owning O, so this is the same question asked of the same source — and it leaves unboundDeclarations reading the population it read before.
  • Conservative in one direction only: a lookup that throws, or answers a non-object, leaves the handler ON the list. The audit can over-report a broken registry; it cannot clear a handler on an answer it could not read. Warn-only, exception-proof and fingerprint-deduplicated all hold, each pinned — including that the fingerprint is taken from the FILTERED set, so a boot the rung clears reports nothing and remembers nothing.

Ablation — predicted before the run, then measured

Both legs resolve through relative source imports (./action-governance.js, ./plugin.js), so no package exports boundary and no dist/ sits in the resolution path; the mutation reaches the code under test directly. Each leg carried an EXIT INT TERM trap, proved the mutation on disk by anchored counts of the removed and injected text before reading a single result, and proved the restore by git hash-object against the HEAD blob plus an empty git 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=1 then POST anchor-count=0, worktree blob d0051a4 vs HEAD blob 3269418; 3 failed | 17 passed — exactly the three in plugin-action-governance-rung.test.ts, and every test in action-governance.test.ts green. Restore: worktree blob back to 3269418, diff vs HEAD empty.

B — keep the wiring, disable the rung inside the audit (args.lookupRegistryAction becomes undefined at the one call). Predicted: 7 red / 13 green, named individually in the script before it ran. Measured: PRE removed-text=1 injected-text=0 then POST removed-text=0 injected-text=1, worktree blob b348082 vs HEAD blob 29b6f13; 7 failed | 13 passed, the same seven the prediction named. Restore: worktree blob back to 29b6f13, 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, including check: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 --listFiles lists each once, and none of the 242 ledgered errors is in either of them.
  • pnpm --filter @objectstack/objectql testTest Files 256 passed (256), Tests 4418 passed (4418).
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, no narrowing.
  • The 36-family union derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on this HEAD (32 by path + 6 by change kind, 2 shared), plus pnpm check:nul-bytes: 34 green, 3 NOT MEASURED and 0 red. check:system-context-census is green on its own verdict line ("109 elevation read sites … all anchored"), so the plugin.ts line shift needed no re-anchor. check:type-check-debt --re-measure and check:dual-build-cjs-loads were 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").
  • NOT MEASURED, recorded with each gate's own text, never as a pass: node scripts/check-test-completeness.mjs exit 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.mjs exit 3 (needs the GitHub API this session cannot reach). Every gate exit code was captured after a redirect, never through a pipe.
  • Serial re-check against the final file list, zero quota, after a full git fetch: one sibling branch touches packages/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 HEAD against origin/main at bd4096ffa reports no conflict.

Changeset

.changeset/action-governance-registry-rung.md, @objectstack/objectql patch — 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 in engine.ts, registry.ts, packages/runtime/**, packages/spec/** or content/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 of ObjectQLPlugin.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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 7 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 793065de2c03936d4dd88f7026a1530d4c52c462packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4c2bf8fe9d4a0da48a9370b1a3c360d07f2b7bcb — the merge of head 73bd8d568e4b77f9ed8c7e329bc2f3f1e55a237a into base 793065de2c03936d4dd88f7026a1530d4c52c462, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 793065de2c03936d4dd88f7026a1530d4c52c462 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance (domain:engine seat, session session_0112hMx9hjJ9BgB28X97DS68, 06:26Z): flipped to ready and auto-merge (squash) armed on head 73bd8d568. ACCEPT on the card: 5505235600 (#14123). Every check run on this head completed success or skipped (Lint & Repo Gates 06:23:25Z, Test Core (1/6) 06:20:08Z); mergeable_state: clean; governed-surface test on the exact five-file list: NOT governed. Clause-②: no. Landing to-do at MERGED: verify by content on origin/main, strip pm:dispatched from #14123 (the Fixes keyword closes it), landing record.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bd8795eSep 2, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14123-action-governance-registry-rung branch September 2, 2026 06:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(objectql): the action-governance audit resolves declarations through the router's rungs - #14421

Merged
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung
Sep 2, 2026
Merged

fix(objectql): the action-governance audit resolves declarations through the router's rungs#14421
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

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 audit

resolveRouteActionDeclaration (packages/runtime/src/action-execution.ts, read-only reference here) resolves a declaration in three rungs, in this order:

  1. ql.getSchema(objectName) (falling back to registry.getObject), then obj.actions matched by name — the object-embedded declaration.
  2. ql.registry.getItem('action', actionName), accepted when ownsRoute(action)standaloneActionObjectName(deps, action) === objectName || isObjectLessActionKey(owner).
  3. meta.loadDiagnosed('action', name), else meta.load('action', name), under the same ownsRoute test.

The audit built its declaration set in collectEngineActionDeclarations(args.objects, args.loadStandaloneActions): each registry object's embedded actions array (rung 1) plus meta.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.

sourcerouteraudit beforeaudit after
object-embedded actions arrayrung 1yesyes
engine registry standalone action itemsrung 2noyes, injected by the one caller
metadata service action rowsrung 3yes, via loadManyyes, via loadMany

The 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 }) then kernel.bootstrap()), where the metadata plugin's artifact ingestion does not run: meta.loadMany('action') answers [] while ql.registry.getItem('action', name) answers the declaration. On objectstack dev the plane is populated (filer's un-claim comment: GET /api/v1/meta/action returns 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, loadStandaloneActions answers []not reported) for both call forms, object-bound and object-less global; 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):

[action-governance] registered handlers with NO declaration — these are REFUSED at dispatch (ADR-0110 D3) and there is no opt-out; declare each one with defineAction, or drop the registration if nothing should invoke it over HTTP

After:

[action-governance] registered handlers with NO declaration in any source the router resolves through (object-embedded actions[], the engine registry standalone action items, the metadata service action rows). ADR-0110 D3 refuses a handler whose declaration the router cannot resolve, so each of these is expected to answer 404 — expected, not measured: this audit read the sources, it did not dispatch. Declare each one with defineAction; if you believe it IS declared, then its declaration is not reaching this engine, and that is the bug to report rather than dropping a registration that may still be serving traffic

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 green pnpm 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):

Runtime re-exports these under their old names — dispatch and the MCP bridge keep reading the SAME functions, which is the load-bearing property: the inventory can never disagree with the router about what a declaration can address.

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 invariant this file may claim, and no more: the inventory reports a handler as undeclared only when EVERY source the router resolves through answered nothing for it.

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

  • The rung is injected, not re-implemented: ObjectQLPlugin.runGovernanceInventory — the one call site, and the only place with ql in hand — passes lookupRegistryAction: (actionName) => ql.registry?.getItem?.('action', actionName). Dependency direction is untouched (runtime to objectql, never the reverse); the audit cannot import the router.
  • The rules stay in the engine module beside the rest of the addressing vocabulary: standaloneActionOwnerKey (the three-line owner ladder, previously written out three times) and standaloneActionOwnsRoute (the router's ownsRoute, asymmetry included — an object-less declaration owns any route, an object-bound one owns only its own).
  • The probe is by NAME, mirroring rung 2, rather than folding the registry into the declaration set. A handler under key K on object O is dispatchable at /actions/O/K exactly when the router resolves a declaration named K owning O, so this is the same question asked of the same source — and it leaves unboundDeclarations reading the population it read before.
  • Conservative in one direction only: a lookup that throws, or answers a non-object, leaves the handler ON the list. The audit can over-report a broken registry; it cannot clear a handler on an answer it could not read. Warn-only, exception-proof and fingerprint-deduplicated all hold, each pinned — including that the fingerprint is taken from the FILTERED set, so a boot the rung clears reports nothing and remembers nothing.

Ablation — predicted before the run, then measured

Both legs resolve through relative source imports (./action-governance.js, ./plugin.js), so no package exports boundary and no dist/ sits in the resolution path; the mutation reaches the code under test directly. Each leg carried an EXIT INT TERM trap, proved the mutation on disk by anchored counts of the removed and injected text before reading a single result, and proved the restore by git hash-object against the HEAD blob plus an empty git 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=1 then POST anchor-count=0, worktree blob d0051a4 vs HEAD blob 3269418; 3 failed | 17 passed — exactly the three in plugin-action-governance-rung.test.ts, and every test in action-governance.test.ts green. Restore: worktree blob back to 3269418, diff vs HEAD empty.

B — keep the wiring, disable the rung inside the audit (args.lookupRegistryAction becomes undefined at the one call). Predicted: 7 red / 13 green, named individually in the script before it ran. Measured: PRE removed-text=1 injected-text=0 then POST removed-text=0 injected-text=1, worktree blob b348082 vs HEAD blob 29b6f13; 7 failed | 13 passed, the same seven the prediction named. Restore: worktree blob back to 29b6f13, 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, including check: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 --listFiles lists each once, and none of the 242 ledgered errors is in either of them.
  • pnpm --filter @objectstack/objectql testTest Files 256 passed (256), Tests 4418 passed (4418).
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, no narrowing.
  • The 36-family union derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on this HEAD (32 by path + 6 by change kind, 2 shared), plus pnpm check:nul-bytes: 34 green, 3 NOT MEASURED and 0 red. check:system-context-census is green on its own verdict line ("109 elevation read sites … all anchored"), so the plugin.ts line shift needed no re-anchor. check:type-check-debt --re-measure and check:dual-build-cjs-loads were 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").
  • NOT MEASURED, recorded with each gate's own text, never as a pass: node scripts/check-test-completeness.mjs exit 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.mjs exit 3 (needs the GitHub API this session cannot reach). Every gate exit code was captured after a redirect, never through a pipe.
  • Serial re-check against the final file list, zero quota, after a full git fetch: one sibling branch touches packages/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 HEAD against origin/main at bd4096ffa reports no conflict.

Changeset

.changeset/action-governance-registry-rung.md, @objectstack/objectql patch — 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 in engine.ts, registry.ts, packages/runtime/**, packages/spec/** or content/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 of ObjectQLPlugin.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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 7 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 793065de2c03936d4dd88f7026a1530d4c52c462packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4c2bf8fe9d4a0da48a9370b1a3c360d07f2b7bcb — the merge of head 73bd8d568e4b77f9ed8c7e329bc2f3f1e55a237a into base 793065de2c03936d4dd88f7026a1530d4c52c462, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 793065de2c03936d4dd88f7026a1530d4c52c462 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance (domain:engine seat, session session_0112hMx9hjJ9BgB28X97DS68, 06:26Z): flipped to ready and auto-merge (squash) armed on head 73bd8d568. ACCEPT on the card: 5505235600 (#14123). Every check run on this head completed success or skipped (Lint & Repo Gates 06:23:25Z, Test Core (1/6) 06:20:08Z); mergeable_state: clean; governed-surface test on the exact five-file list: NOT governed. Clause-②: no. Landing to-do at MERGED: verify by content on origin/main, strip pm:dispatched from #14123 (the Fixes keyword closes it), landing record.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bd8795eSep 2, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14123-action-governance-registry-rung branch September 2, 2026 06:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(objectql): the action-governance audit resolves declarations through the router's rungs - #14421

Merged
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung
Sep 2, 2026
Merged

fix(objectql): the action-governance audit resolves declarations through the router's rungs#14421
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

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 audit

resolveRouteActionDeclaration (packages/runtime/src/action-execution.ts, read-only reference here) resolves a declaration in three rungs, in this order:

  1. ql.getSchema(objectName) (falling back to registry.getObject), then obj.actions matched by name — the object-embedded declaration.
  2. ql.registry.getItem('action', actionName), accepted when ownsRoute(action)standaloneActionObjectName(deps, action) === objectName || isObjectLessActionKey(owner).
  3. meta.loadDiagnosed('action', name), else meta.load('action', name), under the same ownsRoute test.

The audit built its declaration set in collectEngineActionDeclarations(args.objects, args.loadStandaloneActions): each registry object's embedded actions array (rung 1) plus meta.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.

sourcerouteraudit beforeaudit after
object-embedded actions arrayrung 1yesyes
engine registry standalone action itemsrung 2noyes, injected by the one caller
metadata service action rowsrung 3yes, via loadManyyes, via loadMany

The 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 }) then kernel.bootstrap()), where the metadata plugin's artifact ingestion does not run: meta.loadMany('action') answers [] while ql.registry.getItem('action', name) answers the declaration. On objectstack dev the plane is populated (filer's un-claim comment: GET /api/v1/meta/action returns 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, loadStandaloneActions answers []not reported) for both call forms, object-bound and object-less global; 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):

[action-governance] registered handlers with NO declaration — these are REFUSED at dispatch (ADR-0110 D3) and there is no opt-out; declare each one with defineAction, or drop the registration if nothing should invoke it over HTTP

After:

[action-governance] registered handlers with NO declaration in any source the router resolves through (object-embedded actions[], the engine registry standalone action items, the metadata service action rows). ADR-0110 D3 refuses a handler whose declaration the router cannot resolve, so each of these is expected to answer 404 — expected, not measured: this audit read the sources, it did not dispatch. Declare each one with defineAction; if you believe it IS declared, then its declaration is not reaching this engine, and that is the bug to report rather than dropping a registration that may still be serving traffic

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 green pnpm 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):

Runtime re-exports these under their old names — dispatch and the MCP bridge keep reading the SAME functions, which is the load-bearing property: the inventory can never disagree with the router about what a declaration can address.

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 invariant this file may claim, and no more: the inventory reports a handler as undeclared only when EVERY source the router resolves through answered nothing for it.

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

  • The rung is injected, not re-implemented: ObjectQLPlugin.runGovernanceInventory — the one call site, and the only place with ql in hand — passes lookupRegistryAction: (actionName) => ql.registry?.getItem?.('action', actionName). Dependency direction is untouched (runtime to objectql, never the reverse); the audit cannot import the router.
  • The rules stay in the engine module beside the rest of the addressing vocabulary: standaloneActionOwnerKey (the three-line owner ladder, previously written out three times) and standaloneActionOwnsRoute (the router's ownsRoute, asymmetry included — an object-less declaration owns any route, an object-bound one owns only its own).
  • The probe is by NAME, mirroring rung 2, rather than folding the registry into the declaration set. A handler under key K on object O is dispatchable at /actions/O/K exactly when the router resolves a declaration named K owning O, so this is the same question asked of the same source — and it leaves unboundDeclarations reading the population it read before.
  • Conservative in one direction only: a lookup that throws, or answers a non-object, leaves the handler ON the list. The audit can over-report a broken registry; it cannot clear a handler on an answer it could not read. Warn-only, exception-proof and fingerprint-deduplicated all hold, each pinned — including that the fingerprint is taken from the FILTERED set, so a boot the rung clears reports nothing and remembers nothing.

Ablation — predicted before the run, then measured

Both legs resolve through relative source imports (./action-governance.js, ./plugin.js), so no package exports boundary and no dist/ sits in the resolution path; the mutation reaches the code under test directly. Each leg carried an EXIT INT TERM trap, proved the mutation on disk by anchored counts of the removed and injected text before reading a single result, and proved the restore by git hash-object against the HEAD blob plus an empty git 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=1 then POST anchor-count=0, worktree blob d0051a4 vs HEAD blob 3269418; 3 failed | 17 passed — exactly the three in plugin-action-governance-rung.test.ts, and every test in action-governance.test.ts green. Restore: worktree blob back to 3269418, diff vs HEAD empty.

B — keep the wiring, disable the rung inside the audit (args.lookupRegistryAction becomes undefined at the one call). Predicted: 7 red / 13 green, named individually in the script before it ran. Measured: PRE removed-text=1 injected-text=0 then POST removed-text=0 injected-text=1, worktree blob b348082 vs HEAD blob 29b6f13; 7 failed | 13 passed, the same seven the prediction named. Restore: worktree blob back to 29b6f13, 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, including check: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 --listFiles lists each once, and none of the 242 ledgered errors is in either of them.
  • pnpm --filter @objectstack/objectql testTest Files 256 passed (256), Tests 4418 passed (4418).
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, no narrowing.
  • The 36-family union derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on this HEAD (32 by path + 6 by change kind, 2 shared), plus pnpm check:nul-bytes: 34 green, 3 NOT MEASURED and 0 red. check:system-context-census is green on its own verdict line ("109 elevation read sites … all anchored"), so the plugin.ts line shift needed no re-anchor. check:type-check-debt --re-measure and check:dual-build-cjs-loads were 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").
  • NOT MEASURED, recorded with each gate's own text, never as a pass: node scripts/check-test-completeness.mjs exit 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.mjs exit 3 (needs the GitHub API this session cannot reach). Every gate exit code was captured after a redirect, never through a pipe.
  • Serial re-check against the final file list, zero quota, after a full git fetch: one sibling branch touches packages/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 HEAD against origin/main at bd4096ffa reports no conflict.

Changeset

.changeset/action-governance-registry-rung.md, @objectstack/objectql patch — 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 in engine.ts, registry.ts, packages/runtime/**, packages/spec/** or content/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 of ObjectQLPlugin.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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 7 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 793065de2c03936d4dd88f7026a1530d4c52c462packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4c2bf8fe9d4a0da48a9370b1a3c360d07f2b7bcb — the merge of head 73bd8d568e4b77f9ed8c7e329bc2f3f1e55a237a into base 793065de2c03936d4dd88f7026a1530d4c52c462, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 793065de2c03936d4dd88f7026a1530d4c52c462 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance (domain:engine seat, session session_0112hMx9hjJ9BgB28X97DS68, 06:26Z): flipped to ready and auto-merge (squash) armed on head 73bd8d568. ACCEPT on the card: 5505235600 (#14123). Every check run on this head completed success or skipped (Lint & Repo Gates 06:23:25Z, Test Core (1/6) 06:20:08Z); mergeable_state: clean; governed-surface test on the exact five-file list: NOT governed. Clause-②: no. Landing to-do at MERGED: verify by content on origin/main, strip pm:dispatched from #14123 (the Fixes keyword closes it), landing record.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bd8795eSep 2, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14123-action-governance-registry-rung branch September 2, 2026 06:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(objectql): the action-governance audit resolves declarations through the router's rungs - #14421

Merged
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung
Sep 2, 2026
Merged

fix(objectql): the action-governance audit resolves declarations through the router's rungs#14421
os-musk merged 1 commit into
mainfrom
claude/issue-14123-action-governance-registry-rung

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

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 audit

resolveRouteActionDeclaration (packages/runtime/src/action-execution.ts, read-only reference here) resolves a declaration in three rungs, in this order:

  1. ql.getSchema(objectName) (falling back to registry.getObject), then obj.actions matched by name — the object-embedded declaration.
  2. ql.registry.getItem('action', actionName), accepted when ownsRoute(action)standaloneActionObjectName(deps, action) === objectName || isObjectLessActionKey(owner).
  3. meta.loadDiagnosed('action', name), else meta.load('action', name), under the same ownsRoute test.

The audit built its declaration set in collectEngineActionDeclarations(args.objects, args.loadStandaloneActions): each registry object's embedded actions array (rung 1) plus meta.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.

sourcerouteraudit beforeaudit after
object-embedded actions arrayrung 1yesyes
engine registry standalone action itemsrung 2noyes, injected by the one caller
metadata service action rowsrung 3yes, via loadManyyes, via loadMany

The 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 }) then kernel.bootstrap()), where the metadata plugin's artifact ingestion does not run: meta.loadMany('action') answers [] while ql.registry.getItem('action', name) answers the declaration. On objectstack dev the plane is populated (filer's un-claim comment: GET /api/v1/meta/action returns 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, loadStandaloneActions answers []not reported) for both call forms, object-bound and object-less global; 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):

[action-governance] registered handlers with NO declaration — these are REFUSED at dispatch (ADR-0110 D3) and there is no opt-out; declare each one with defineAction, or drop the registration if nothing should invoke it over HTTP

After:

[action-governance] registered handlers with NO declaration in any source the router resolves through (object-embedded actions[], the engine registry standalone action items, the metadata service action rows). ADR-0110 D3 refuses a handler whose declaration the router cannot resolve, so each of these is expected to answer 404 — expected, not measured: this audit read the sources, it did not dispatch. Declare each one with defineAction; if you believe it IS declared, then its declaration is not reaching this engine, and that is the bug to report rather than dropping a registration that may still be serving traffic

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 green pnpm 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):

Runtime re-exports these under their old names — dispatch and the MCP bridge keep reading the SAME functions, which is the load-bearing property: the inventory can never disagree with the router about what a declaration can address.

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 invariant this file may claim, and no more: the inventory reports a handler as undeclared only when EVERY source the router resolves through answered nothing for it.

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

  • The rung is injected, not re-implemented: ObjectQLPlugin.runGovernanceInventory — the one call site, and the only place with ql in hand — passes lookupRegistryAction: (actionName) => ql.registry?.getItem?.('action', actionName). Dependency direction is untouched (runtime to objectql, never the reverse); the audit cannot import the router.
  • The rules stay in the engine module beside the rest of the addressing vocabulary: standaloneActionOwnerKey (the three-line owner ladder, previously written out three times) and standaloneActionOwnsRoute (the router's ownsRoute, asymmetry included — an object-less declaration owns any route, an object-bound one owns only its own).
  • The probe is by NAME, mirroring rung 2, rather than folding the registry into the declaration set. A handler under key K on object O is dispatchable at /actions/O/K exactly when the router resolves a declaration named K owning O, so this is the same question asked of the same source — and it leaves unboundDeclarations reading the population it read before.
  • Conservative in one direction only: a lookup that throws, or answers a non-object, leaves the handler ON the list. The audit can over-report a broken registry; it cannot clear a handler on an answer it could not read. Warn-only, exception-proof and fingerprint-deduplicated all hold, each pinned — including that the fingerprint is taken from the FILTERED set, so a boot the rung clears reports nothing and remembers nothing.

Ablation — predicted before the run, then measured

Both legs resolve through relative source imports (./action-governance.js, ./plugin.js), so no package exports boundary and no dist/ sits in the resolution path; the mutation reaches the code under test directly. Each leg carried an EXIT INT TERM trap, proved the mutation on disk by anchored counts of the removed and injected text before reading a single result, and proved the restore by git hash-object against the HEAD blob plus an empty git 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=1 then POST anchor-count=0, worktree blob d0051a4 vs HEAD blob 3269418; 3 failed | 17 passed — exactly the three in plugin-action-governance-rung.test.ts, and every test in action-governance.test.ts green. Restore: worktree blob back to 3269418, diff vs HEAD empty.

B — keep the wiring, disable the rung inside the audit (args.lookupRegistryAction becomes undefined at the one call). Predicted: 7 red / 13 green, named individually in the script before it ran. Measured: PRE removed-text=1 injected-text=0 then POST removed-text=0 injected-text=1, worktree blob b348082 vs HEAD blob 29b6f13; 7 failed | 13 passed, the same seven the prediction named. Restore: worktree blob back to 29b6f13, 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, including check: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 --listFiles lists each once, and none of the 242 ledgered errors is in either of them.
  • pnpm --filter @objectstack/objectql testTest Files 256 passed (256), Tests 4418 passed (4418).
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, no narrowing.
  • The 36-family union derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on this HEAD (32 by path + 6 by change kind, 2 shared), plus pnpm check:nul-bytes: 34 green, 3 NOT MEASURED and 0 red. check:system-context-census is green on its own verdict line ("109 elevation read sites … all anchored"), so the plugin.ts line shift needed no re-anchor. check:type-check-debt --re-measure and check:dual-build-cjs-loads were 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").
  • NOT MEASURED, recorded with each gate's own text, never as a pass: node scripts/check-test-completeness.mjs exit 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.mjs exit 3 (needs the GitHub API this session cannot reach). Every gate exit code was captured after a redirect, never through a pipe.
  • Serial re-check against the final file list, zero quota, after a full git fetch: one sibling branch touches packages/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 HEAD against origin/main at bd4096ffa reports no conflict.

Changeset

.changeset/action-governance-registry-rung.md, @objectstack/objectql patch — 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 in engine.ts, registry.ts, packages/runtime/**, packages/spec/** or content/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 of ObjectQLPlugin.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

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 7 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via ObjectQLPlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 793065de2c03936d4dd88f7026a1530d4c52c462packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4c2bf8fe9d4a0da48a9370b1a3c360d07f2b7bcb — the merge of head 73bd8d568e4b77f9ed8c7e329bc2f3f1e55a237a into base 793065de2c03936d4dd88f7026a1530d4c52c462, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 793065de2c03936d4dd88f7026a1530d4c52c462 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Provenance (domain:engine seat, session session_0112hMx9hjJ9BgB28X97DS68, 06:26Z): flipped to ready and auto-merge (squash) armed on head 73bd8d568. ACCEPT on the card: 5505235600 (#14123). Every check run on this head completed success or skipped (Lint & Repo Gates 06:23:25Z, Test Core (1/6) 06:20:08Z); mergeable_state: clean; governed-surface test on the exact five-file list: NOT governed. Clause-②: no. Landing to-do at MERGED: verify by content on origin/main, strip pm:dispatched from #14123 (the Fixes keyword closes it), landing record.


Generated by Claude Code

@os-musk
os-musk added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bd8795eSep 2, 2026
35 checks passed
@os-musk
os-musk deleted the claude/issue-14123-action-governance-registry-rung branch September 2, 2026 06:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-musk@claude