feat(actions): durable packaged-action disable — same activation ledger, dispatch-time consult - #12348
Conversation
…er, dispatch-time consult Generalizes the packaged-flow disable machinery to actions (ADR-0126 §8 item 2, maintainer amendment ruling 3). A packaged action can be switched off for an installation; the flip writes an install-level row to the SAME `sys_metadata_activation` object with `metadata_type: 'action'` — no schema change, no new column, no clone machinery. The consult sits at action DISPATCH, on every door that dispatches a declared action: the REST `/actions/:object/:action` route and the MCP `run_action` bridge, both through one shared guard. A disabled action is refused `409 ACTION_DISABLED` before the handler body runs (it executes trusted), before a flow-type action reaches the automation engine, before the param contract and before the record load — and after the ADR-0066 D4 capability gate, so the switch is not an oracle for unentitled callers. The code is registered rather than borrowed: `FLOW_DISABLED` would tell an operator to go hunting for a flow that does not exist. It is one census row in the ADR-0112 ledger, in the `*_DISABLED` family that already names which thing is off. The projection lives on the ObjectQL engine and is hydrated at boot by its plugin — the component ADR-0110 D5 already established as unconditionally present wherever actions execute — so a disable survives a restart and the handler re-registration every metadata reload performs. A ledger object absent from the composition is read as absent, not probed, and re-checked on the next reload rather than recorded as a verdict. The write door is `POST /actions/_activation/:object/:action`, its segment reserved because machine names cannot begin with `_`. It carries the same two authority tiers as the flow toggle — `manage_metadata`, then the ADR-0126 §5 posture rule — now sharing ONE gate implementation with `POST /automation/:name/toggle` instead of a second copy; the flow refusal text is byte-identical to what it shipped with. An ambiguous action name is refused `409 RESOURCE_CONFLICT` rather than switching off artifacts the caller did not name, and a flip that cannot be made durable is reported as a failure instead of a 200. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh
📓 Docs Drift CheckThis PR changes 3 package(s): 30 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 132 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 3771372ef79f0f8e913fbf22fb983dd6c5bf13bc && git checkout 3771372ef79f0f8e913fbf22fb983dd6c5bf13bc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 311433f6b16f60ce8463a6a80011d264d7bb7d19 d8fa340a62e762106b6ab096f942ff0762ba2ef5 && git checkout -B drift-repro 311433f6b16f60ce8463a6a80011d264d7bb7d19 && git merge --no-ff d8fa340a62e762106b6ab096f942ff0762ba2ef5
node scripts/docs-audit/affected-docs.mjs --json 311433f6b16f60ce8463a6a80011d264d7bb7d19
|
|
Clause-② contract review — PASS (PM session Surfaces read in full: Contract judgments reviewed and accepted, on the record:
Remaining before merge: CI green on this head. Per the maintainer's standing autonomy ruling this PR will be marked ready and enqueued once CI completes clean. Generated by Claude Code |
…the activation door is actually served Two CI failures, both real. 1. `check-engine-double-contract` — the activation-ledger fake's `update()` did not route through ObjectQL's own dispatch predicate, so the double could accept call shapes the engine refuses. Opened with `assertEngineUpdateDispatch(data, options)` from `@objectstack/metadata-core` (the predicate's home since #5619; importing objectql's own re-export from inside objectql would be a self-import), matching the flow twin's fake. The new pinned coverage is recorded in the pinned ledger via the sanctioned `--write`; the shrink-only baseline is untouched. 2. `route-ledger-live-mount-parity` (#7526) — the activation door was "LEDGERED BUT NOT MOUNTED, and DISGUISED". Root-caused on a booted showcase rather than guessed: nothing registers `/api/v1/actions/_activation/:object/:action`, and a 3-segment activation path is matched by `/api/v1/actions/:object/:action/:recordId` with `_activation` bound to `:object`. It still reaches the activation arm because that mount rebuilds the dispatch path from its matched params, byte for byte — measured on the wire: the 2-segment shape answers this door's own 400 naming the required shape, and a 3-segment undeclared action answers its own 404 ("nothing to switch off"), neither of which the invocation path can produce; a 4-segment path resolves to nothing and 404s at the router. So the honest fix is the declaration, not a second mount: the ledger row carries `servedBy: /api/v1/actions/:object/:action/:recordId` with the mechanism written down. Because a `servedBy` note cannot say the arm still ANSWERS — and the unit suites drive the domain handler directly, which is why they stayed green through this — the two wire assertions are pinned end-to-end in the actions dogfood suite, on a boot that already exists. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh
…nators it does not implement
`pnpm check:where-matcher` flagged the double's `matches` as silently wrong:
with no combinator branch it read `$or` / `$in` as a FIELD NAME, compared
`row.$or` (undefined) against the operand, matched nothing, and would have left
a suite asserting on an empty result set with nothing erroring — the ABSENCE
shape of that defect class, which no syntactic guard can see.
Took the gate's own preferred remedy for a double that only ever sees scalar
equality (the store's two reads are `{ metadata_type }` and
`{ metadata_type, name }`): refuse rather than implement. The predicate now
throws on a `$`-prefixed key or an object-valued comparand, the same refusal the
flow twin's fake carries. ⛔ The gate's baseline is untouched.
The matcher also moved to MODULE scope, and that is load-bearing rather than
tidying: the gate judges a matcher by LIFTING it — transpiling it with the
declarations it references and running a combinator battery against it. Declared
inside the factory, the lift carried that factory's scope, reached `vi` and could
not evaluate, so the first attempt at this fix turned the verdict from "silently
wrong" into "unjudged" — which the gate never treats as passing.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh
|
Clause-② contract review — incremental verification for the three CI-fix commits (head The three rounds of CI fixes since the reviewed head
All 33 checks green on Generated by Claude Code |
…on-ledger row contract (#12350) ADR-0126 section 4 declares ONE activation ledger for the whole disable+clone family. It had two independent implementations of that one row contract: ObjectStoreFlowActivationStore @objectstack/service-automation #12296 ObjectStoreActionActivationStore @objectstack/objectql #12348 They agreed on every load-bearing detail because the second was written from the first, and nothing structurally held them together. Section 8 pre-charts `tool`, `skill` and `position` as later consumers, and a third and fourth copy is where the row semantics start drifting — the org-row skip and the `0`-is-false read are exactly the kind of detail a copy loses quietly, in the direction (an artifact silently re-arming) nothing else measures. Neither consumer could import the other: `service-automation` does not depend on `@objectstack/objectql` (devDependency only), and the engine must not depend on a service. `@objectstack/core` is the package BOTH already depend on, so it is the one home that needs no new edge — the same reasoning that put `recordNotFoundError` there for the ADR-0076 D2 closure. NOT `@objectstack/platform-objects`, which declares the OBJECT: `objectql` does not depend on it and that edge would invert the tiering. Where the code lives is a module-import question; where the object's registration lives is a composition question with its own answer. `ObjectStoreMetadataActivationStore(engine, metadataType)` carries the row semantics once. Each consumer keeps its own name, its own one-argument constructor and its own docs, and fixes the discriminator — a binding, not an implementation. A caller that had to pass the discriminator could pass the wrong one; each leg has exactly one correct value. Byte-equivalent semantics: install-level rows only (`organization_id` never written), org-carrying rows skipped on read AND ignored when deciding insert-vs-update, a driver `0` read as false, read-then-write rather than a blind upsert, no `delete` in the engine slice because re-enabling rewrites the row. Both existing pin suites stay green UNCHANGED — they pin the contract from each binding's side, so their staying green is the proof the consolidation lost nothing. The new suite beside the implementation pins what neither of them can: that the discriminator is a PARAMETER rather than a constant, that two bindings over one table see none of each other's rows in either direction, and that a type nobody has written yet behaves identically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh
Closes #12160
L6 of the #12150 program: packaged actions can now be switched off for an installation, on the same ledger as flows — ADR-0126 §8 item 2, on the maintainer's amendment ruling 3 (2026-08-25, verbatim and untranslated):
⛔ Disable only. The action-clone half stays unchartered by §8, so nothing here copies, designates or links an artifact; authoring a sibling action is open exactly as it is today. ⛔ No
packages/specbehaviour change beyond one census row (below), no objectui, no ledger schema change.Clause ②: YES
This PR changes refusal behaviour on the action dispatch contract and adds one member to the ADR-0112 wire vocabulary. Contract review rides the PM session's PR review. The two contract-visible facts:
409 ACTION_DISABLED, on both action doors, before anything runs;ACTION_DISABLEDis registered under@objectstack/runtimeinERROR_CODE_LEDGER. It is a census row demanded by the admission gate the moment a producer emits the code — the onlypackages/specedit here, and the L1-precedent exception to this leg's spec wall.Why a new code rather than reusing
FLOW_DISABLED(the flow leg's own choice, deliberately not copied): ascriptaction refused under a code naming a flow sends an operator hunting a flow that does not exist — a machine-readable surface lying about which artifact it is talking about. It joins the family that already exists for exactly this (OBJECT_API_DISABLED,OBJECT_PACKAGE_DISABLED,FLOW_DISABLED), each naming which thing is off, and it passes thestandardSynonymOfadmission detector with no waiver.Reuse map — what came from #12296, what is new
refuseUngrantedActivationWrite(ADR-0126 §5 posture gate,domains/automation.ts)domains/activation-gate.tsand parameterized by one per-artifact clause; both doors now run ONE implementation. The flow refusal string is byte-identical to what it shipped with, andautomation-activation-posture-gate.test.tsstill passes untouched.ObjectStoreFlowActivationStorerow semantics (org rows skipped,active === 0falsy, read-then-write, nodelete)metadata_type: 'action'inpackages/objectql.service-automationdoes not depend onobjectql, and the engine must not depend on a service, so neither direction exists today. Called out in the module header, and filed as a follow-up rather than smuggled in here.flowLedgerDisabledprojection +hydrateFlowActivationsActionActivationProjectionon the ObjectQL engine, hydrated at boot byObjectQLPlugin.execute()as "the one seam every entry path crosses"FLOW_DISABLEDcode reusetoggleFlow's degrade-to-in-process fallbacktoggleFlowmust answer on a host with no ObjectQL. This projection lives inside ObjectQL, so "no durable plane" is a broken deployment, not a mode — it throws instead.The decisions worth reviewing
Where the consult sits, and why not one seam deeper. Actions have no single seam that can answer the question. The two primitives below the doors are addressed by handler key (
ql.executeAction) and by target flow name (dispatchFlowAction), and ADR-0110 D2 is explicit that a registration key is not an action's identity — a body action registers undername, a target-bound one undertarget. A check insideexecuteActionwould therefore miss every target-bound action while looking present. So the consult sits where a resolved declaration exists — the REST route and the MCP bridge — through one function, with a test per door. Anobjectqltest pins thatexecuteActiondeliberately does not consult, so a later "helpful" edit has to argue with a test.One invocation path is deliberately NOT a consult point, and it is recorded, not hidden. ObjectQL's
ScopedRepo.execute()— a hook or action body reaching another handler in-process viactx.api.object(x).execute(...)— is the thirdexecuteActioncaller. It dispatches by key with no declaration and no caller identity: package code calling package code, the class ADR-0126 §2 keeps outside the model forhook. Filed separately for triage rather than decided here.Identity is the declarative name, and ambiguity refuses. ADR-0126 §4 gives the ledger one
namecolumn and ADR-0110 D1 says identity is the declarativename. Two objects may declare the same action name, and then one row addresses both — so the write door refuses that with409 RESOURCE_CONFLICTnaming the objects, rather than encoding a composite key into a column declared to hold one fact (the per-object dimension, like the per-org one, would be an additive column later) or silently switching off artifacts nobody named. Measured first: name collisions across objects are rare in the platform's own catalog, so this refuses an edge case, not the common path.Why the projection lives on the ObjectQL engine. It has to be per-environment, reachable from both doors, and alive at boot. ADR-0110 D5 already answered this exact question for action-adjacent boot work and moved it off
AppPluginbecause that plugin is registered conditionally — "on the platform's own dev loop the inventory never ran". An activation projection some boots never hydrate is strictly worse: a disabled action would arm.Boot logging is split, and the split is the point. A composition with no
sys_metadata_activationobject is not probed at all (afindagainst a missing table is a driver fault the engine logs aterror— printing one on every boot of every deployment that never asked for this capability is how operators learn to skimerror). Registered-but-unreadable iswarnplus a write door that refuses loudly; readable-but-hydrate-failed iserror, because rows may exist and every action would be armed. The "not registered" answer is not recorded as a verdict:metadata:reloadedre-attempts the attach, so an object registered afterkernel:readyis picked up (AGENTS.md, startup registry reads).The write door's path shape.
POST /actions/_activation/:object/:action. The reserved segment is first, not deep in the path, because a deeper spelling would sit in the:recordIdposition where the value is an arbitrary string — a record whose id is literallytogglewould collide, silently. Machine names are^[a-z][a-z0-9_]*$, so_activationcan never be an object, an action or a name at all. The gate predicate has no depth bound and matches the arm exactly (a gate narrower than its route is a bypass).Verification — all runs on
8b01371f, the head of this branchHeavy steps serialized through
scripts/pm/os-verify-lock.sh; verdicts quoted from each gate's own line, never a bare$?.Gate families derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(re-derived after the diff grew) and run green:check:route-envelope(needed the roster row this PR adds),check:dispatcher-error-vocabulary,check:error-code-casing,check:error-status-conformance(no baseline movement — no doc page publishes a status for this code),check:durability-log-level,check:test-source-alias,check:cross-package-test-inputs,check:type-source-resolution,check:published-files,check:page-declaration-shape,check:slot-lookup,check:merge-driver,check:stack-collection-maps,check:plugin-teardown-shape,check:spec-parsed-alias, the changeset family (check-empty-changeset,check-changeset-no-major,check-adr-0087-registration,check:changeset-gate-self-tests,check:objectui-changeset), the docs family (check:doc-anchors,check:doc-authoring,check:docs-single-h1,check:doc-route-spelling,check:doc-frontmatter,check:docs-section-name,check:section-landing-index,check:docs-redirects,check:docs-audit-scope,check:quick-reference-counts,check:doc-security-posture,check:doc-formula-expressions),docs-audit/check-affected-docs,check:nul-bytes, pluscheck:role-word,check:entry-guard,check:parse-guard,check:pnpm-filter-targets,check:agent-test-spelling,check:cli-command-ids,check:published-readme-links,check:react-page-adapter-contract,check:ci-filter-parity,release-rehearsal-clone --self-test.packages/specartifacts: rebuilt, thencheck:generatednamed exactly one stale artifact (check:docs) and only that one was regenerated. Its diff is this PR's single new code plus the union count moving 287 → 288.check-dev-prereqsandcheck:skill-examplesboth reported ONE unmet precondition on first run — this worktree had built only the runtime dependency closure ("The workspace is not built", 38 of 67 packages withoutdist). A fullpnpm build(71/71 tasks successful) settled it:check-dev-prereqs→ "67 package build artifacts present";check:skill-examples→ "256 prose examples type-check across 3 surfaces", both exit 0 on this same head.Declared narrowings — measurements NOT taken, stated so they are not read as green:
pnpm lint(repo-wide eslint) was not run locally; CI runs the farm.packages/spec's own suite was narrowed tosrc/api/error-code-ledger.test.ts(17 passed) — the admission gate for the one line this PR adds there.check-engine-split-ratiorefuses on this shallow worktree by design ("a ratio derived here would be real, plausible and WRONG") rather than printing a caveated number; CI runs it on a full clone.CI follow-up — two failures, both real, fixed in
19c50eba1.
check-engine-double-contract— the activation-ledger fake'supdate()did not route through ObjectQL's own dispatch predicate, so the double could accept call shapes the engine refuses (the #4434 class: a fake looser than the engine it stands in for). Opened withassertEngineUpdateDispatch(data, options)from@objectstack/metadata-core, the same pin the flow twin's fake carries; new pinned coverage recorded via the sanctioned--write. ⛔ The shrink-only baseline is untouched. Verdict:check-engine-double-contract: 393 (file, verb) row(s) held by the RETAINED ledger, exit 0, withpinned [update] packages/objectql/src/action-activation.test.tsin the report.2.
route-ledger-live-mount-parity(#7526) — the activation door was "LEDGERED BUT NOT MOUNTED, and DISGUISED". Root-caused on a booted showcase, not reasoned about:resolveMountedRoute('POST', '/api/v1/actions/_activation/crm_lead/convert_lead')/api/v1/actions/:object/:action/:recordIdresolveMountedRoute('POST', '/api/v1/actions/_activation/convert_lead')/api/v1/actions/:object/:actionresolveMountedRoute('POST', '/api/v1/actions/_activation/a/b/c')POST /actions/_activation/showcase_mark_done(authed)Path must be /actions/_activation/:object/:actionPOST /actions/_activation/showcase_task/no_such_action(authed)… has no declaration — there is nothing to switch off … (ADR-0126 §4)It is shape (a): the request does reach the
_activationarm and is served correctly end to end. The reason is in the mount —dispatcher-plugin.tsrebuilds the dispatch path from the matched params (/actions/${object}/${action}/${recordId}), so the path the domain parses is byte-identical to the one the caller sent even though_activationwas bound to:object. Both wire answers above are ones only the activation arm can produce; the invocation path's answer for those URLs would name a missingdefineAction.So the fix is the declaration, not a second mount: the ledger row now carries
servedBy: /api/v1/actions/:object/:action/:recordIdwith that mechanism written down. And because aservedBynote cannot assert that the arm still answers — the unit suites drive the domain handler directly, which is exactly why they stayed green through this — the two wire assertions are pinned end-to-end inqa/dogfood/test/action-params-contract.dogfood.test.ts, on a boot that already exists rather than a new one. Both dogfood files green:Test Files 2 passed (2) / Tests 13 passed (13).503 SERVICE_UNAVAILABLE— "no activation ledger is attached to this engine" — becausesys_metadata_activationis registered by the automation service's manifest, and that boot has no automation service. The refusal is the designed one (loud, naming the remedy, never a flip that silently reverts), but it means packaged-action disable is unavailable in a composition that has actions and no automation. Where that shared object should be registered now that a second consumer exists is a decision that touches the flow leg too, so it is filed rather than guessed.Re-verified on
19c50eba: objectql suites 23 passed, runtime suites 59 passed (route-ledger conformance included), dogfood 13 passed,check:engine-double-contractandcheck:nul-bytesexit 0, typecheck green for runtime + objectql + dogfood.Generated by Claude Code
Generated by Claude Code