Repository navigation
fix(runtime,core): the action activation door switches an action no package ships, and keeps ADR-0112's code and status together - #22872
Conversation
…ackage ships, and keeps ADR-0112's code and status together The door wrote `?? ''` as the package of an action no package ships, which the store refuses, and its catch answered 503 for every undeclared status. It also credited an action bound to a packaged object with that object's package. The row now names the package that ships the action (the action's own code-artifact package; for an action declared inside its object, the object's), and none otherwise; the catch answers through the dispatcher's thrown-error rule. The in-memory activation store refuses packageId '' as the durable store does. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
…tion-activation-unpackaged
…ller's bound and is drivable by the double gates The table double now lives in a module-scope factory holding only the store's find and insert, applies `limit` after the filter, and names its table as a literal, so check:objectql-double-limit grades it; it carries no update, so check:engine-double-contract's pinned ledger is unchanged. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
📓 Docs Drift CheckThis PR changes 2 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 44 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4262cc52bf748a91411f08e36656044c2a6f8f0c && git checkout 4262cc52bf748a91411f08e36656044c2a6f8f0c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 69d421805896d8d598bd27a0d035e494f7de6630 58b68fe15e0a842e178d1b59da540f60e1a466c3 && git checkout -B drift-repro 69d421805896d8d598bd27a0d035e494f7de6630 && git merge --no-ff 58b68fe15e0a842e178d1b59da540f60e1a466c3
node scripts/docs-audit/affected-docs.mjs --json 69d421805896d8d598bd27a0d035e494f7de6630
|
Contract reviewServed-tier: Written 2026-10-11T17:49Z, inside the adopting seat's session. Inputs: card #22817 (body and all five comments: retriage ① Derived judgments
② Semver level
Clause-②: yes (widening) ③ Boundary flagsThe report has
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22817
Clause-②: yes
POST /api/v1/actions/_activation/:object/:actionnow switches an action no package ships, as its docblock declares (triage direction A,6110327411). The ledger row names the package that ships the action, and no package (package_idnull) when none does. A refused ledger write keeps ADR-0112's code and status together.InMemoryMetadataActivationStore.setActivenow refusespackageId: ''exactly as the durable store does.The defect, reproduced first on a real boot
Measured on
origin/main6364375b5with a throwaway dogfood case (showcase boot, signed-in admin; the case was deleted after the run and is not in this PR). Two actions were saved throughPUT /api/v1/meta/action/:nameand then switched off through the door:env_ping_global(no object)SERVICE_UNAVAILABLE, with "Activation row for action 'env_ping_global' names the empty string as its package. Omit packageId for an item no package ships; an empty package id is not a package."env_ping_task(on the packagedshowcase_task)package_id: "com.example.showcase"showcase_mark_done(packaged, control)package_id: "com.example.showcase"String(action._packageId ?? obj._packageId ?? ''), objectql'ssetActionActivepasses the row to the store unchanged (ActionActivationProjection.setActive), the store refuses''with aTypeError, and the door'scatchturned every undeclared status into 503.undefinedfor an action no package ships", so this PR covers that shape too.What the declarations carry (same boot): an environment-authored action has
_provenance: "org"and no_packageId. A packaged action has its own_packageIdand_provenance: "package", whether it is embedded or standalone. 73 platform actions declared inside their code objects'actionscarry no stamp of their own (sys_user:invite_user,sys_approval_request:approval_approve,sys_permission_set:clone_permission_set, ...), so the object's package is the only record of who ships them.The fix
packages/runtime/src/domains/actions.ts, step 5.shippingPackageOf(declaration)asks the one "does a code package ship this?" predicate,isCodeArtifactBody(@objectstack/metadata-core, ADR-0029 D9.6). That predicate already excludes the'sys_metadata'sentinel and tenant provenance. The rule:actions(reference identity with the entryresolveRouteActionDeclarationreturns), the object's package, when the object is a code artifact;packageIdkey, so the store writesnull.A tenant-authored action is never credited to a package.
Byte-identity, measured. On the same showcase boot, over all 146 declarations the door can resolve, the new rule matches the old computation on 144. The two it changes are
env_ping_task(com.example.showcasebecomes none) andenv_ping_global(''becomes none).The door's
catchanswers throughdeps.errorFromThrown(err, 503), the dispatcher's one thrown-error rule, which fix(runtime,core,platform-objects): the catalog activation door switches an environment-authored position or permission set, and keeps ADR-0112's code and status together #22811 adopted on the catalog door:VALIDATION_FAILED;SERVICE_UNAVAILABLE) is kept;SERVICE_UNAVAILABLE.The docblock is corrected. It said "Neither does the flow toggle". That has been false since
c8111a575:AutomationEngine.toggleFlowrefuses a flow no package ships (409RESOURCE_CONFLICT), because such a flow has its own server-side switch, its definition'sstatus. The docblock now gives the measured reason the action door differs: an action has no server-side switch of its own.visibleanddisabledare rendering predicates that no dispatch door reads (packages/spec/liveness/action.json: objectui only). So the ledger row is the only switch an environment-authored action has.packages/core/src/utils/metadata-activation-store.ts. Both stores now call onerefuseEmptyPackageId, so the in-memory double cannot accept a row the durable store refuses. The durable store's message is byte-identical to before.Is there a product reason against option A? (the stop clause)
Measured, and none was found. The flow toggle's refusal is the nearest one, and it rests on a property actions lack, as above. ADR-0126 §8 item 2 says the action leg "follows the flow legs". It lists what follows, "same
sys_metadata_activationledger, same §5 write authority, a consult point at action dispatch, a surface row", and none of those is a provenance gate. The door's authority (manage_metadata; the platform operator undergroup/isolated) is unchanged.Pins
packages/runtime/src/domains/action-activation-unpackaged.test.ts(new, 10 cases). It runs over the REAL objectqlActionActivationProjectionandObjectStoreActionActivationStore, writing into a recordingsys_metadata_activation, so it pins the door, objectql's pass-through and core's store together:{ metadata_type: 'action', name, package_id: null, active: false };active: true;package_id: null;app.crmbinding both givepackage_id: null;ACTION_DISABLEDand the handler never runs;crmstill writescrm, and an unstamped action embedded in acrmobject still writescrm;VALIDATION_FAILEDand writes no row; an undeclared store failure answers 503SERVICE_UNAVAILABLEand writes no row.The table double carries the store's
find(bounded bylimit, graded bycheck:objectql-double-limit) andinsert, and noupdate. Socheck:engine-double-contract's pinned ledger is unchanged. The update leg for a package-less row is core's, already pinned by "UPDATES its row with no package on the way back on".packages/core/src/utils/metadata-activation-store.test.ts:''with aTypeErrorwhose message equals the durable store's, apart from the type in the subject, and keeps nothing;packageIdabsent.Verification
Reverse verification, at the final head
58b68fe15e. The fix was committed first;actions.tswas replaced by its6364375b5bytes, then restored withgit checkout HEAD --; blobc989d1e62cmatched HEAD andgit diff HEADwas empty. The new file went7 failed | 3 passed (10). The 3 that stayed green are the two CONTROL pins and the undeclared-failure 503, which also answered 503 before the fix. The failures read exactly like the boot:{"code":"SERVICE_UNAVAILABLE","message":"Activation row for action 'env_ping_global' names the empty string as its package. ...","httpStatus":503}: expected 503 to be 200;expected [ 'sys_metadata' ] to deeply equal [ null ]andexpected [ 'app.crm' ] to deeply equal [ null ];expected 200 to be 409: the disabled action ran;expected 503 to be 400.Ablation of the double's refusal, with
scripts/ablation-replace.mjs --delete. The anchor went from 1 to 0 and the blob changed, so the mutation landed. Result:1 failed | 18 passed (19), withexpected undefined to be an instance of TypeError. Restored: blobb8fe921e55equals HEAD andgit diff HEADis empty. My first attempt passed--replacement "", which the tool refused before running anything, so it measured nothing.Who relied on the double's leniency: nobody. Every construction site is a test: 1 objectql file and 6 service-automation files. All of them are green against the rebuilt core
dist, which carries the marker: objectql18 passed, service-automation152 passed. No fixture needed triage.The real boot after the fix: the same throwaway case answers 200 for all three.
env_ping_globalandenv_ping_taskrows carrypackage_idnull;showcase_mark_donekeepscom.example.showcase.Full suites, on both the pre-merge head
cf606feddfand the head merged withorigin/main55382dc02a(157a4dc138), with the same figures each time:@objectstack/core: 2373 passed;@objectstack/runtime: 5061 passed, 19 skipped;typecheckof both is green, includingcheck:test-typecheck, which reached the new test file.The final head
58b68fe15echanges only the new test file. There, the file passes 10 of 10 and@objectstack/runtimetypecheckis green again.Gates, at
58b68fe15e:dispatch-gates --commandsderives, plus the dispatch-namedcheck:error-status-conformance, each exited 0 with its exit code captured before any pipe;--ranreconciles them: "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero — all 65 recorded an exit code and none of them is 3)";check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3), because eight packages outside this diff had nodist/. They were built and it was rerun: "107 published require entry point(s) across 66 package(s) load".eslint over the 4 changed TypeScript files at
58b68fe15e(--no-inline-config --format json): 4 files reported, 0 errors, 0 warnings, none ignored. This is a narrowed run, not the repo-wide lint, which CI owns. The narrowing is safe becauseeslint.config.mjsnever enables type-aware linting (noparserOptions.project), so this diff cannot move a verdict on an untouched file.Acceptance notes
content/docs/concepts/metadata-lifecycle.mdxlists the action switch as a packaged action's sanctioned path. That stays true, so no docs edit was needed.package_idstill has no reader that decides anything: the action hydrator readsnameandactiveonly, as measured in fix(runtime,core,platform-objects): the catalog activation door switches an environment-authored position or permission set, and keeps ADR-0112's code and status together #22811.Generated by Claude Code