Skip to content

feat(actions): durable packaged-action disable — same activation ledger, dispatch-time consult - #12348

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-12160-action-disable
Aug 25, 2026
Merged

feat(actions): durable packaged-action disable — same activation ledger, dispatch-time consult#12348
os-support-ai merged 3 commits into
mainfrom
claude/issue-12160-action-disable

Conversation

@claude

@claude claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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

「动作 可能是需要开关的,因为有的 action 我不想启用。」

⛔ 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/spec behaviour 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:

  • a declared action that the installation has switched off is refused at dispatch with 409 ACTION_DISABLED, on both action doors, before anything runs;
  • ACTION_DISABLED is registered under @objectstack/runtime in ERROR_CODE_LEDGER. It is a census row demanded by the admission gate the moment a producer emits the code — the only packages/spec edit 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): a script action 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 the standardSynonymOf admission detector with no waiver.

Reuse map — what came from #12296, what is new

#12296 piece Disposition here
refuseUngrantedActivationWrite (ADR-0126 §5 posture gate, domains/automation.ts) Consumed, by extraction. Moved to domains/activation-gate.ts and parameterized by one per-artifact clause; both doors now run ONE implementation. The flow refusal string is byte-identical to what it shipped with, and automation-activation-posture-gate.test.ts still passes untouched.
ObjectStoreFlowActivationStore row semantics (org rows skipped, active === 0 falsy, read-then-write, no delete) Consumed as the row contract, re-implemented for metadata_type: 'action' in packages/objectql. ⚠️ Not imported: service-automation does not depend on objectql, 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.
flowLedgerDisabled projection + hydrateFlowActivations Consumed as the pattern: ActionActivationProjection on the ObjectQL engine, hydrated at boot by ObjectQLPlugin.
execute() as "the one seam every entry path crosses" Not available for actions — see below. Replaced by one shared guard called at each door, with a test per door.
FLOW_DISABLED code reuse Deliberately not copied (above).
toggleFlow's degrade-to-in-process fallback Deliberately not copied: that exists because toggleFlow must 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.
§7.3 subflow cascade guard Not applicable — no action analog is chartered.

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 under name, a target-bound one under target. A check inside executeAction would 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. An objectql test pins that executeAction deliberately 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 via ctx.api.object(x).execute(...) — is the third executeAction caller. 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 for hook. Filed separately for triage rather than decided here.

Identity is the declarative name, and ambiguity refuses. ADR-0126 §4 gives the ledger one name column and ADR-0110 D1 says identity is the declarative name. Two objects may declare the same action name, and then one row addresses both — so the write door refuses that with 409 RESOURCE_CONFLICT naming 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 AppPlugin because 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_activation object is not probed at all (a find against a missing table is a driver fault the engine logs at error — printing one on every boot of every deployment that never asked for this capability is how operators learn to skim error). Registered-but-unreadable is warn plus a write door that refuses loudly; readable-but-hydrate-failed is error, because rows may exist and every action would be armed. The "not registered" answer is not recorded as a verdict: metadata:reloaded re-attempts the attach, so an object registered after kernel:ready is 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 :recordId position where the value is an arbitrary string — a record whose id is literally toggle would collide, silently. Machine names are ^[a-z][a-z0-9_]*$, so _activation can 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 branch

Heavy steps serialized through scripts/pm/os-verify-lock.sh; verdicts quoted from each gate's own line, never a bare $?.

pnpm --filter @objectstack/objectql test      235 files / 4174 tests passed  (VERDICT command-exit 0)
pnpm --filter @objectstack/runtime test       193 files / 2846 tests passed  (VERDICT command-exit 0)
typecheck: objectql + runtime + spec          all "Done"                     (VERDICT command-exit 0)
new suites: 59 tests across 4 files           action-activation (18) · plugin-action-activation (5) ·
                                              dispatch per-door (11) · posture matrix + door contract (25)

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, plus check: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/spec artifacts: rebuilt, then check:generated named 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-prereqs and check:skill-examples both 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 without dist). A full pnpm 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 to src/api/error-code-ledger.test.ts (17 passed) — the admission gate for the one line this PR adds there.
  • check-engine-split-ratio refuses 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 19c50eba

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 (the #4434 class: a fake looser than the engine it stands in for). Opened with assertEngineUpdateDispatch(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, with pinned [update] packages/objectql/src/action-activation.test.ts in 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:

probe (real boot) result
resolveMountedRoute('POST', '/api/v1/actions/_activation/crm_lead/convert_lead') /api/v1/actions/:object/:action/:recordId
resolveMountedRoute('POST', '/api/v1/actions/_activation/convert_lead') /api/v1/actions/:object/:action
resolveMountedRoute('POST', '/api/v1/actions/_activation/a/b/c') nothing — 404 at the router
POST /actions/_activation/showcase_mark_done (authed) 400 Path must be /actions/_activation/:object/:action
POST /actions/_activation/showcase_task/no_such_action (authed) 404 … has no declaration — there is nothing to switch off … (ADR-0126 §4)

It is shape (a): the request does reach the _activation arm and is served correctly end to end. The reason is in the mount — dispatcher-plugin.ts rebuilds 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 _activation was 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 missing defineAction.

So the fix is the declaration, not a second mount: the ledger row now carries servedBy: /api/v1/actions/:object/:action/:recordId with that mechanism written down. And because a servedBy note 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 in qa/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).

⚠️ One thing the probe measured that is NOT fixed here, and is filed instead: on that showcase boot the flip itself answers 503 SERVICE_UNAVAILABLE — "no activation ledger is attached to this engine" — because sys_metadata_activation is 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-contract and check:nul-bytes exit 0, typecheck green for runtime + objectql + dogfood.


Generated by Claude Code


Generated by Claude Code

…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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 65 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

30 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 311433f6b16f60ce8463a6a80011d264d7bb7d19.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 64 pages)
  • 15 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 177: 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; 107 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 — 132 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 311433f6b16f60ce8463a6a80011d264d7bb7d19packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3771372ef79f0f8e913fbf22fb983dd6c5bf13bc — the merge of head d8fa340a62e762106b6ab096f942ff0762ba2ef5 into base 311433f6b16f60ce8463a6a80011d264d7bb7d19, 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 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

⚠️ 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 311433f6b16f60ce8463a6a80011d264d7bb7d19 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator

Clause-② contract review — PASS (PM session session_01KWRU3s15AJz7PGW7a7wdCh, CONTRACT_REVIEW_TIER, per the claim on #12160).

Surfaces read in full: domains/activation-gate.ts (the extracted §5 gate — flow refusal wording byte-identical to #12296's, the action remedy honestly declines to advertise the unchartered clone, and the /actions door gains the manage_metadata capability tier its domain lacked), the ACTION_DISABLED census row in error-code-ledger.zod.ts, disabledActionRefusal + both door consults in action-execution.ts, and the route-envelope roster row. The objectql store/projection and test files accepted on the PR's evidence (row contract consumed from the flow leg: org rows skipped, 0 falsy, read-then-write, no delete) and the per-door pinning tests.

Contract judgments reviewed and accepted, on the record:

  1. ACTION_DISABLED as a new ledger code rather than reusing FLOW_DISABLED — correct, and the asymmetry with the flow leg is principled, not drift: the flow leg reused an existing code for the same artifact type with the distinction riding the message; here the artifact type differs, and a machine-readable code naming the wrong artifact is a lying surface. Joins the existing *_DISABLED family; registered through the admission gate with no waiver; the census row is the L1-precedent mechanical spec exception, not new protocol surface.
  2. Consult at the two declaration-bearing doors, not one seam deeper — forced by ADR-0110 D2 (registration key ≠ identity); a deeper check would silently miss target-bound actions. The pin that executeAction deliberately does not consult is the right defense against a future "helpful" edit.
  3. Ordering: capability gate → activation consult → param contract — a refused caller learns nothing about the switch or the param shape. Correct oracle discipline at both doors.
  4. ScopedRepo.execute() not gated, recorded and filed for triage — package code calling package code by key with no declaration is the class ADR-0126 §2 keeps outside the model; deciding it here would be scope invention.
  5. Same-name-across-objects write refused (409, objects named) rather than a composite key smuggled into the ledger's one name column — consistent with §4's column semantics and the additive-dimension rule; measured as an edge case.
  6. No degrade-to-in-process mode for the projection (throws instead) — correct inversion of the flow leg's fallback: this projection lives inside ObjectQL, so "no durable plane" is a broken deployment, not a supported mode.

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

Copy link
Copy Markdown
Collaborator

Clause-② contract review — incremental verification for the three CI-fix commits (head d8fa340a), rides the original PASS (comment 5415603049).

The three rounds of CI fixes since the reviewed head 8b01371f add no new wire-contract surface; the reviewed contract stands unchanged (409 ACTION_DISABLED at both action doors + one ERROR_CODE_LEDGER census row):

  1. check-engine-double-contract — test-double tightening only: the ledger fake's update() now routes through assertEngineUpdateDispatch, with new pinned coverage recorded via the gate's sanctioned --write. Shrink-only baseline untouched.
  2. route-ledger-live-mount-parity — declarative honesty, not a behavior change: root-caused on a booted showcase as shape (a) (the sibling mount forwards the byte-identical path, so the activation arm answers correctly end-to-end); the ledger row now carries servedBy with the mechanism written down, and two wire assertions are pinned end-to-end in the dogfood suite so a mount that stops forwarding faithfully turns red instead of the note going stale.
  3. check:where-matcher — test-fake conformance only: the fake's matcher hoisted to module scope (load-bearing for the gate's lift-and-judge model, commented as such) and made to refuse combinators it does not implement, same refusal the flow twin's fake carries. Gate baseline untouched.

All 33 checks green on d8fa340a. En-route finding #12359 (single-registrant ledger object → action disable unavailable without the automation service) is filed for the maintainer's decision and does not change this PR's contract: the 503 refusal it documents is the designed §6-wall-3 loud refusal, already reviewed. Incremental verdict: PASS — enqueueing per the program's standing authorization.


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review August 25, 2026 20:44
@os-support-ai
os-support-ai added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit e5ce2ed Aug 25, 2026
35 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-12160-action-disable branch August 25, 2026 21:05
os-support-ai pushed a commit that referenced this pull request Aug 26, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

L6: action disable — dispatch-time consult on the same ledger (ADR-0126 §8.2, amendment ruling 3)

2 participants