Skip to content

fix(runtime): the enablement door refuses in its own words (#11666) - #12139

Merged
os-trump merged 4 commits into
mainfrom
claude/issue-11666-automation-toggle-deny-message
Aug 26, 2026
Merged

fix(runtime): the enablement door refuses in its own words (#11666)#12139
os-trump merged 4 commits into
mainfrom
claude/issue-11666-automation-toggle-deny-message

Conversation

@os-trump

@os-trumpos-trump commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11666

A caller without manage_metadata that hit POST /api/v1/automation/:name/toggle was
answered with the refusal the three definition writes share. They were disabling a
flow, not authoring one.

before: Authoring automation flows requires the `manage_metadata` capability.
after: Enabling or disabling an automation flow requires the `manage_metadata` capability.

This is option B from the card's table, as ruled at triage: a second refusal constant
for the enable/disable arm, naming the operation actually attempted. Options A and C were
closed at grading and are not reopened here — in particular the shared sentence is not
reworded, because it reads correctly today for POST /, PUT /:name and DELETE /:name
and widening it would degrade all three to fix one arm.

Copy only — nothing about the policy moved

How the arm is identified

refuseUngrantedFlowWrite now receives parts / method and picks the sentence from
isFlowEnablementWrite — the same helper isFlowAuthoringWrite's toggle arm now calls.
The question "is this request the enablement door?" is therefore spelled once, which is
this file's own stated rule (a policy spelled at three call sites is three policies that
happen to agree today). Under that arm's guard the helper reduces to exactly the
parts[0] !== 'trigger' it replaces, so the predicate's truth table is unchanged.

One deviation from the precedent, reported rather than forced

The card pointed at SCREEN_READ_DENY_MESSAGE beside RUN_READ_DENY_MESSAGE (#7968). Its
shape transferred — a second constant, same code and status, a different sentence
for a different question — but its mechanism did not: that precedent is a second
constant read at a second call site (refuseUnrelatedScreenRead, its own gate function).
refuseUngrantedFlowWrite is one gate behind one predicate, so a second call site would
have required splitting isFlowAuthoringWrite — i.e. moving the policy, which the card
fences. The selection therefore happens inside the existing refusal, off the existing
predicate.

Review round 2 — docblock adjacency (0d73d8dc)

Review caught that the new helper had been inserted between the [#10145] docblock and
isFlowAuthoringWrite, stacking two doc comments before one declaration. The nearer one
wins, so the helper took the adjacent block and the gate predicate was left with none —
while the [#10145] route-coverage table (where #10243's toggle arm and the trigger
exclusion are justified, i.e. what the next author reads before touching the gate) sat above
a function it does not describe.

Fixed as a pure move: the helper and its docblock now sit above the [#10145] block, so
each docblock is adjacent to its own subject and the helper is still declared before its
callers. Verified as a relocation rather than a rewrite — the file's multiset of lines is
unchanged, and the [#10145] block is byte-identical before and after (md5
244ddce71dfd4c7245faa8c53ee23316). Its text was not edited.

Verification

Union run at 0d73d8dc, on a clean tree.

The pins assert what the refusal SAYS, on the arm that was refused. A pin asserting only
403 cannot see this defect — the defect ships a 403 today, and
automation-write-capability-gate.test.ts passes over it in both the before and the after
state. New file packages/runtime/src/domains/automation-toggle-deny-message.test.ts (15
tests) drives dispatcher.dispatch('POST', '/automation/…') — the registered domain
route createAutomationDomain installs, not the handleAutomation shortcut — and asserts
both directions: the toggle arm gets the new sentence and does not contain Authoring;
each definition write keeps the original and does not contain Enabling or disabling.

Reverse verification (run at 6df0268e; round 2 changed no executable text, only where
the helper sits). Source reverted to 2ba4329e, pins kept, tree confirmed on disk in both
directions before the run (Enabling or disabling… absent: 0 hits; function isFlowEnablementWrite absent: 0 hits; return parts[0] !== 'trigger'; present: 1 hit;
Authoring automation flows requires present: 1 hit). Result — 4 failed | 37 passed:

FAIL automation-toggle-deny-message.test.ts (15 tests | 4 failed)
× names enabling/disabling, not authoring
× says the same thing in both directions — enabling and disabling
× answers before the body is read, in its own words
× a deeper spelling is gated AND told the same thing — `/:name/toggle/anything`
AssertionError: expected 'Authoring automation flows requires t…' to be 'Enabling or disabling an automation f…'

automation-write-capability-gate.test.ts stayed green under the mutation — named here
as a regression guard, ⛔ never counted as red-before evidence. That it cannot see this
defect is the reason the new file exists. The restore leg was re-verified on disk
(new sentence present: 1; helper present: 1; old inline arm absent: 0) and the tree is clean.

Suites (5 files, 85 tests, all passing — re-run on 0d73d8dc): the new pin file plus
automation-write-capability-gate, automation-screen-read-gate,
automation-run-read-permission-gate, automation-toggle-unknown-flow. Package
typecheck exit 0 on the same tree.

Gates, each read from its own verdict line (exit code captured before any pipe):
check:route-envelope · check:cross-package-test-inputs (both the pnpm and
scripts/ forms) · check:published-files · check:slot-lookup · check:test-source-alias
· check:type-source-resolution · check:changeset-gate-self-tests ·
check:objectui-changeset · check:query-options-erasure · check:engine-double-contract ·
check:where-matcher · check:type-check-coverage · check:nul-bytes ·
check-ci-filter-parity · check-plugin-teardown-shape · check-adr-0087-registration ·
check-changeset-no-major · check-empty-changeset · release-rehearsal-clone --self-test ·
docs-audit/check-affected-docs · docs-audit/check-drift-comment. All exit 0. The
ratchet families were re-run on the new head 0d73d8dc after the round-2 push and are
green there.

pnpm lint full-repo, not narrowedeslint . --no-inline-config over the whole tree,
exit 0, re-run on 0d73d8dc.

One declared narrowing.check:type-check-debt (--re-measure) re-runs tsc for every
ledger entry and needs the entire workspace built. Instead the @objectstack/runtime
slice
— the only entry this diff can reach — was measured directly, twice, over the same
program with the test exclusion lifted: 227 errors with the new pin file, 227 without,
identical to the ledger's recorded errors: 227, with 0 diagnostics naming either changed
file. The ratchet does not move. (A first attempt at this measurement reported a false
0 from a tsconfig placed outside the repo — TS2688: Cannot find type definition file for 'node' — and was discarded as not measured, not as zero.)

Changeset

@objectstack/runtime: patch. A client branching on the human-readable prose of a 403
rather than on error.code is the only thing that can notice.


Generated by Claude Code

A caller without `manage_metadata` that hit `POST /automation/:name/toggle`
was answered with the refusal the three definition writes share — "Authoring
automation flows requires the `manage_metadata` capability." They were
disabling a flow, not authoring one: accurate about the policy #10243 ruled,
and naming a verb the caller did not use.
Adds a second refusal constant for the enablement arm, shaped on this file's
own precedent (`SCREEN_READ_DENY_MESSAGE` beside `RUN_READ_DENY_MESSAGE`,
#7968): a second constant for a second question, rather than a reworded shared
one. The question "is this request the enablement door?" is extracted into
`isFlowEnablementWrite` so the gate and the sentence read the SAME answer —
this file's own rule that a question spelled at two call sites is two
questions that happen to agree today.
Copy only. The accept set is bit-identical, `PERMISSION_DENIED` / 403 is
unchanged on every arm, and #10243's policy classification is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbG3rGVLjZStHQxHDtzJdJ
@github-actions

github-actionsBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via /:name/clone (route))

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

  • content/docs/releases/implementation-status.mdx(via /:name/clone (route))
  • content/docs/releases/v17.mdx(via /:name/toggle (route), /automation/:name/toggle (route))

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 — 23 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 e91e62acba1fc0d165031a4e561a39dc20634155packageMentionDocs.

Which tree this was computed on

This run read content/docs from f366a1cee15d37cf11ce30b2cf540279758c3c14 — the merge of head 2969bb035f62dd7f8b326df2bbb5aaf8c4350a74 into base e91e62acba1fc0d165031a4e561a39dc20634155, 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 f366a1cee15d37cf11ce30b2cf540279758c3c14 && git checkout f366a1cee15d37cf11ce30b2cf540279758c3c14
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e91e62acba1fc0d165031a4e561a39dc20634155 2969bb035f62dd7f8b326df2bbb5aaf8c4350a74 && git checkout -B drift-repro e91e62acba1fc0d165031a4e561a39dc20634155 && git merge --no-ff 2969bb035f62dd7f8b326df2bbb5aaf8c4350a74
node scripts/docs-audit/affected-docs.mjs --json e91e62acba1fc0d165031a4e561a39dc20634155

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

Pure move, no text changed. `isFlowEnablementWrite` and its `[#11666]`
docblock were inserted BETWEEN the `[#10145]` docblock and
`isFlowAuthoringWrite`, which left two doc comments stacked before one
declaration: the nearer one wins, so the helper took the adjacent block and
`isFlowAuthoringWrite` was left with none — while the `[#10145]` route-coverage
table, where #10243's toggle arm and the `trigger` exclusion are justified,
sat above a function it does not describe. That table is exactly what the next
author reads before touching the gate.
The helper now sits above the `[#10145]` block, so each docblock is adjacent to
its own subject and the helper is still declared before its callers.
Verified as a pure relocation: the file's multiset of lines is unchanged, and
the `[#10145]` block is byte-identical before and after (md5 244ddce7…).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbG3rGVLjZStHQxHDtzJdJ
Resolves the two additive conflicts in packages/runtime/src/domains/automation.ts
against #12190 (#12156, the flow clone door), keeping BOTH sides:
* isFlowAuthoringWrite — this branch's delegated toggle arm
(`return isFlowEnablementWrite(parts, method)`) AND main's [#12156] clone
comment block plus `if (method === 'POST' && parts[1] === 'clone')`.
* the route docblock — this branch's `⚑ refused with its OWN sentence
(#11666)` sub-line under the toggle row AND main's `POST /:name/clone` row,
each sub-line under its own row, in table order.
Adds the pin the merge owes: an unentitled `POST /automation/:name/clone` is
refused with the AUTHORING sentence and not the enablement one. The clone door
is a two-segment POST whose verb lives in `parts[1]`, so a future loosening of
isFlowEnablementWrite would hand it this card's own defect on the arm that
arrived after the card.
Also corrects one enumeration this merge falsified: the FLOW_ENABLEMENT_DENY_MESSAGE
docblock and the pin file's header both said the shared sentence reads correctly
for "the three definition writes". Clone is a fourth.
Part of #11666
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbG3rGVLjZStHQxHDtzJdJ
@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

Round 3 — landing repair (2969bb035f)

This PR held packages/runtime/src/domains/automation.ts from ~10:57. #12190 (card #12156,
the ADR-0126 §7.1 flow clone door) landed on that same file at 15:26
, from a different lane,
and left this branch un-mergeable. git merge origin/main — no rebase, no force-push, the
reviewed head preserved.

⚠️The branch tip on the remote was one commit ahead of the local ref (cf517e1025, a
GitHub Update branch merge of an older main at 22:38). The first attempt merged onto the
stale base and was rejected non-fast-forward; it was redone from the real tip rather than
forced. The two independent resolutions of the conflict region came out byte-identical,
and the final tree hash equals the one every measurement below was taken on
(dd658a9083447377cef0fbb021fe492f55cb2e1e).

Both conflicts were additive on both sides — keep-both, not pick-a-side

1. isFlowAuthoringWrite.main appended a [#12156] clone arm after the toggle arm;
this branch had rewritten the toggle arm to delegate. Both are kept, in route order:

if(method==='POST'&&parts[1]==='toggle')returnisFlowEnablementWrite(parts,method);// [#12156] `POST /automation/:name/clone` — the ADR-0126 §7.1 clone door.// … (main's comment block, unedited) …if(method==='POST'&&parts[1]==='clone')returnparts[0]!=='trigger';

2. The route docblock.main added the POST /:name/clone row; this branch added a
⚑ refused with its OWN sentence (#11666) sub-line to the toggle row. Each sub-line is kept
under its own row, in table order.

Losing either side would still have been green, because each side's tests pass without
the other side's arm being reached. Proven mechanically instead of by eye: over the resolved
isFlowAuthoringWrite, every line of the branch-side parent is present, every line of the
main-side parent is present except the one this branch deliberately superseded
(… parts[1] === 'toggle') return parts[0] !== 'trigger';), and no line is present that came
from neither parent
.

The one substantive thing the merge owed

The merge puts a new arm inside a gate whose refusal sentence is now selected by
isFlowEnablementWrite. Read off the merged tree:

functionisFlowEnablementWrite(parts: string[],method: string): boolean{returnmethod==='POST'&&parts[1]==='toggle'&&parts[0]!=='trigger';}

parts[1] === 'toggle' is in the predicate ⇒ clone returns false ⇒ it gets
FLOW_WRITE_DENY_MESSAGE, the authoring sentence, which is correct: a clone authors a flow.
Safe as written — and now pinned, because the clone door is the arm most able to inherit the
wrong sentence (like the enablement door it is a two-segment POST whose verb lives in
parts[1], so any loosening of that helper would hand it this card's own defect, on the arm
that arrived after the card).

One case added to automation-toggle-deny-message.test.ts, in the file's existing
both-directions shape — asserts the sentence it gets and that it is not the other one.

Red-then-green, on the pin that could not have existed before this merge. The mutation
loosens the helper to swallow the clone arm — (parts[1] === 'toggle' || parts[1] === 'clone')
— which is precisely the regression the pin guards. Confirmed on disk before the run
(original predicate 1 → 0 hits, loosened predicate 0 → 1 hit, plus the git diff -U0 line):

Test Files 1 failed (1)
Tests 1 failed | 15 passed (16)
× POST /:name/clone — the arm that arrived AFTER this card — still reads "Authoring …"
AssertionError: expected 'Enabling or disabling an automation f…' to be 'Authoring automation flows requires t…'

Exactly one test failed, and it is the new one — the other 15 stayed green, so the new
pin is the only thing standing between the tree and that regression. The source-only mutation
turning it red also proves the suite resolves automation.ts from src, never a stale dist/.
Restore ran under a trap … EXIT INT TERM and was re-verified anchored in both directions with
git status --porcelain empty.

Verification on the pushed tree

Every heavy command through scripts/pm/os-verify-lock.sh; verdicts read from its own VERDICT
line, exit codes captured before any pipe.

  • Suites — all ten automation*.test.ts in the touched domain, which now includes
    automation-flow-clone and automation-activation-posture-gate from the lanes that landed
    meanwhile: Test Files 10 passed (10) · Tests 174 passed (174). Build of the dependency
    closure first, exit 0; package typecheck exit 0.
  • Gate family re-derived from the actual post-merge change set with
    node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed).
    It named one family the earlier rounds did not: check:page-declaration-shape. All 23
    ran, all exit 0.
  • pnpm lint full-repo, not narrowedeslint . --no-inline-config, exit 0.
  • check:type-check-debt — the same declared narrowing as round 1, re-measured on this
    tree: the @objectstack/runtime slice over the same program with the test exclusion lifted
    gives 227, identical to the ledger's recorded errors: 227, with 0 diagnostics naming
    either changed file. Setup-error guard (TS2688 &c.) clean, so this is a measurement rather
    than a false green.

One accuracy fix the merge forced

The FLOW_ENABLEMENT_DENY_MESSAGE docblock and the pin file's header each said the shared
sentence reads correctly for "the three definition writes". After #12156 there are four.
Both now say "the authoring writes", naming clone. ⛔ No other text moved.

Generated by Claude Code


Generated by Claude Code

@os-trump
os-trump added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit fe3d74fAug 26, 2026
33 checks passed
@os-trump
os-trump deleted the claude/issue-11666-automation-toggle-deny-message branch August 26, 2026 00:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-trump@claude