Repository navigation
fix(service-automation)!: the toggle door switches packaged flows only; a customer flow is refused, naming its status switch (#20726) - #20780
Conversation
…first) Three pins beside the activation-ledger suite, read off the door's outputs: a customer-authored flow toggled through the door is refused with RESOURCE_CONFLICT / 409 naming its status switch, and neither the ledger nor the flow moves (three provenance shapes, both directions, and the no-ledger degraded mode); a packaged flow still toggles (the control); and a customer flow published with status 'obsolete' is not armed (the switch the refusal names works). Red against the unfixed engine by design: the fix follows. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…hips, naming its status switch toggleFlow records an installation's choice about a PACKAGED flow in the activation ledger (ADR-0126 §4, §7.2). For a flow authored in the deployment it wrote a row anyway: with no package id the durable store refused it with a validation error naming a field the caller never sent, and with a sentinel or app package id it recorded a second off-switch for a flow whose switch is its own status. Now, first and ahead of both §7.3 guards, a flow whose provenance is not 'package' (describeFlowContender, the discriminator the §7.3 guards ask) is refused with RESOURCE_CONFLICT / 409 in either direction, before any ledger write and before any in-process change, with or without a ledger attached. The refusal says the door switches packaged flows and names the flow's own switch: its status, published through PUT /automation/NAME. The door never rewrites the definition. Fixture triage: ten existing cases toggled a flow with no package envelope only as a vehicle for toggle semantics; their subject now ships from a package. The two §7.3 non-packaged pins are re-spelled through the status switch: a customer subflow is switched off by its status, and a customer caller is refused by the door and armed by its status. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…r row already holds off (red first) The door used to accept a customer flow whose package id was non-empty (the sys_metadata sentinel, an app-bound tenant row) and wrote a ledger row for it. After the refusal, such a flow is held off by a row its status does not clear, so a refusal naming only the status prescribes a step that completes nothing. The pin asserts the refusal still writes nothing and names the step that does complete (a clone under a new name), and shows that step arms the copy. Red against the previous commit by design: the message branch follows. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…a customer flow a ledger row holds off A ledger row can already stand under a customer flow's name (written by this door before it refused customer-authored flows, or by a packaged flow the customer overlay shadows), and a status does not clear it. For that flow the refusal no longer stops at the status switch: it says the row holds the flow off and names the step the FLOW_DISABLED refusal already names for a ledger-held flow, a clone under a new name. Still nothing is written. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
… customer flow's switch is its status Three published lines said the toggle door enables or disables "a flow". It switches packaged flows only, and a flow authored in the deployment is refused with 409 RESOURCE_CONFLICT. Each line now says which flows the door switches and what a customer flow uses instead: its status, 'obsolete' or 'active', published with the complete definition through PUT /automation/:name. - the Automation API module docblock (the source of the generated API reference page), prose only; - client.automation.toggle, whose docblock had drifted above an unrelated member and is moved back onto toggle; - the automation domain's route list and authoring-write predicate list. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Output of `pnpm --filter @objectstack/spec check:generated --fix`, which found exactly one stale artifact (content/docs/references/**) and regenerated it with gen:docs from a spec dist it built. Not hand-edited. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…e package README stops toggling a customer flow One changeset per package whose published bytes move, measured on the built output: service-automation (behaviour, minor, BREAKING, with its migration), spec (the docblock ships in src/**/*.zod.ts) and client (the docblock reaches dist/index.d.ts). The runtime route docblocks reach no published file, so runtime has none. The service-automation README (published) registered a flow in process and then toggled it off, the exact call the door now refuses. It now switches that flow off through its status, and shows the toggle on a packaged flow. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
The pending note for the enable guard lists "a flow the customer authored" under "Not refused". In the release that ships it, the activation switch refuses a customer-authored flow before that guard is asked. The sentence is corrected in its own entry rather than by an erratum elsewhere; the customer-authored subflow half stays true and stays. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…(red first) The pin asserted the notice contains 'toggle', which pinned the prescription itself: switch the clone off through the activation toggle. A clone carries no package envelope, and that switch refuses a flow no package ships. The pin now asserts the notice names the switch the clone has: its status, 'obsolete', through PUT /api/v1/automation/NAME. Red against the current notice by design: the notice follows. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…h, its status The notice every successful clone answers told the admin to switch the clone off through the activation toggle. A clone carries no package envelope, so the toggle, which switches packaged flows only, refuses it: the notice prescribed a step the platform refuses. It now names the clone's own switch, status 'obsolete' through PUT /api/v1/automation/NAME with the complete definition, and says the toggle is for packaged flows such as the one the clone was copied from. Measured at the dispatcher seam with the real engine: the clone door answered 200 with the old notice, the clone carried no _packageId, and the toggle it named answered RESOURCE_CONFLICT / 409 for it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
…es packaged flows The service contract's docblock read "Enable or disable a flow", the same published line the API page carried. It now says the switch records the installation's choice for packaged flows, that a flow authored in the deployment is refused with RESOURCE_CONFLICT / 409, and that such a flow's switch is its own status, published through registerFlow. Prose only. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
📓 Docs Drift CheckThis PR changes 4 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 144 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 ed9aa7da373c40c312bc32ca69c9699849d7b5c0 && git checkout ed9aa7da373c40c312bc32ca69c9699849d7b5c0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 085ca6bc1c446e6484713823ce92284b4ec0ad54 843341912c6e7744d0d0d1f71b81011fcbf64c3c && git checkout -B drift-repro 085ca6bc1c446e6484713823ce92284b4ec0ad54 && git merge --no-ff 843341912c6e7744d0d0d1f71b81011fcbf64c3c
node scripts/docs-audit/affected-docs.mjs --json 085ca6bc1c446e6484713823ce92284b4ec0ad54
|
Contract reviewServed-tier: Inputs: card #20726 (body; triage direction Checks on the head, read at 2026-09-30T05:31Z: 35 check-runs, one per name, none in progress. 32 ① Derived judgmentsRead against triage's option 1: refuse, don't write; name the customer flow's own switch; the door never rewrites a definition; packaged flows unchanged; docs say which flows the door switches.
② Semver level
③ Boundary flagsDev flags and
Implemented-by: VERDICT: FAIL One published sentence to narrow (③ Q3(b)); everything else on this head — option 1 as ruled, the corrected release note sentence by sentence, the semver declaration and the four changesets' levels — is confirmed, so the record on the corrected head should be short. Generated by Claude Code |
The clone door takes any registered flow as its source, with no provenance test, so "the toggle switches packaged flows only, such as the one it was copied from" is false when a customer-authored flow is cloned. The notice, its docblock and the runtime changeset now say only what holds for every clone: the toggle switches packaged flows only and refuses the clone. The prescription is unchanged: switch the clone off through its own status, via PUT /api/v1/automation/NAME. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Contract reviewServed-tier: A re-record on a new head. Record Inputs: card #20726 (body; triage direction The delta d7eb865..8433419, verified: ONE commit, 2 files, +5 / −4: Checks on the head, read at 2026-09-30T06:15Z: 42 check-runs over 35 names, none in progress; seven names ran twice because the ① Derived judgmentsRead against triage's option 1 ( Carried forward from record
10. The clone-notice rider (
15. The DELIBERATE CORRECTION,
② Semver level
③ Boundary flagsDev flags and
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Landing with one red check, by design · The gate:
The reason: this PR corrects
The confirmation: the at-tier contract review PASS on this same head,
The three conditions for landing with it red, each met:
Also recorded: that job stops at this step. So neither the ADR-0087 disposition step nor the launch-window
Generated by Claude Code |
…the commits that decided them (objectstack-ai#20789) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the thirteenth stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/triggers/trigger-record-change/src/**` and nothing else. By the seat's claim (`5904332626`), it is the largest package in the lane that no in-flight work holds, while `service-automation` stays held behind objectstack-ai#20726. Later stages cover the other packages, so this PR says `Part of` and the card stays open. Every comment or docblock site in scope that cited a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), by the method of stages 1 to 12 (PR objectstack-ai#20609 as `422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as `9b384f63a`, PR objectstack-ai#20717 as `cbaf04c1f`, PR objectstack-ai#20729 as `d2820876f`, PR objectstack-ai#20737 as `4dfff176b`, PR objectstack-ai#20742 as `697845d19`, PR objectstack-ai#20757 as `cba417a8f`, PR objectstack-ai#20775 as `91e8fa194`). That is **29 sites on 29 lines in 5 files, covering 3 numbers**: - 6 census sites (every census site this package has, all `objectstack-ai#14744`); - 23 sites in test comments, which the census defers: 17 more of `objectstack-ai#14744`, 1 of `objectstack-ai#13657`, and 5 of `objectstack-ai#11081`. `objectstack-ai#11081` stands only in a test file here, so the census never judged it; it was read on its own and answers 404. Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and says in its own words what was decided: **4 distinct shas**. None of the three numbers has an ADR or ruling record of its own, so every anchor is a commit, per ruling C's order (see the per-number table). No number was dropped. Only comments changed. Every touched source file keeps its line count (30 lines out, 30 in, over 5 files), so no line citation into these files moves. 29 of the 30 changed lines carried a dead citation; the thirtieth keeps a referent the rewrite would otherwise have removed (see Wordings). No code token moves (see the guard below). **No citation number is added.** The only tracker numbers on added lines are the live `objectstack-ai#15356` (3 times) and `objectstack-ai#8738` (once), each on the line it already stood on. Added minus removed is negative for the three dead numbers and zero for every other number, and no number is new to the diff. No PR number is the citation on an added line. 4 dead sites are left on purpose, all test titles (see the list below). One more file: a `patch` changeset for `@objectstack/trigger-record-change`, because the rewritten prose ships (see Changeset below). ## Census: `trigger-record-change`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count below is its `allocated-but-absent` findings under `packages/triggers/trigger-record-change/`. Each run counts as a reading only because its board frontier equals the newest issue or pull-request number, read by a separate request just before and just after the run. In all three runs a new number was opened while the run was enumerating; each frontier equals the newest number at the run's end, which is the criterion (stages 7 and 11 met the same shape). | reading | tree | board | whole-repo `allocated-but-absent` | trigger-record-change sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `91e8fa194`, run 2026-09-30T04:58:08Z to 05:01:28Z | enumerated, 187 pages, frontier objectstack-ai#20779 (newest objectstack-ai#20778 before, objectstack-ai#20779 after) | 802 | **6** | 6 | 2 | 1 | | after | `bb9d39a87` (the comments commit), run 05:07:54Z to 05:11:48Z | enumerated, 187 pages, frontier objectstack-ai#20780 (newest objectstack-ai#20779 before, objectstack-ai#20780 after) | 796 | **0** | 0 | 0 | 0 | | after, final head | head `bbfe7cb24`, run 05:39:10Z to 05:42:26Z | enumerated, 187 pages, frontier objectstack-ai#20784 (newest objectstack-ai#20783 before, objectstack-ai#20784 after) | 796 | **0** | 0 | 0 | 0 | The before count matches the seat's census and A1 (6 sites, all `objectstack-ai#14744`: `decouple-flow-record.ts` ×1 and `record-change-trigger.ts` ×5). The whole-repo drop is 6, exactly this diff's census sites. The `resolves` tally is 33,055 in all three runs, and `resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did not move either. No run was truncated or discarded: all three enumerations read 187 pages at the newest frontier. **Supplementary instrument, the whole scope.** The census does not read test files or strings, and this stage's scope includes test comments. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `namesThisRepository` over every `.ts` file under `trigger-record-change/src` (14 files). It takes its verdicts from the before census's own board reading rather than from a second enumeration: a number is dead when that census reported it `allocated-but-absent`, and alive when the gate's own census-scope extraction (36,836 citations over 2,617 files) judged it and the census did not report it. Five numbers are covered by neither, because they stand only in test files: each was read on its own. `objectstack-ai#11081` answers 404; `objectstack-ai#5715` and `objectstack-ai#17982` answer 200 as pull requests; `objectstack-ai#5785` and `objectstack-ai#17985` answer 200 as issues. The three dead numbers were also read one by one, and each answers 404. | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `91e8fa194` | 186 | **32** | 6 | 23 | 0 | 3 | | after, `bbfe7cb24` | 157 | **3** | 0 | 0 | 0 | 3 | Its src-comment column equals the census's 6, which is the control on the second instrument. The 154 live citations are the same in both readings, and the drop of 29 citations is exactly the rewritten sites. A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it) finds 195 occurrences before and 166 after. Beyond the gate's grammar it sees 9 tokens, the same at base and head: the second number of five `#A/#B` pairs (only one is dead, the kept title at `before-update-flow-payload-reach.test.ts:872`), two `/objectstack-ai#3457/` regex literals in assertions (live), and two `PD objectstack-ai#12` ordinals. ## Per-number table Sites and files count every dead occurrence in scope at the base (comments and strings, tests included). `rewritten / left` counts the sites rewritten and the sites left. Each anchor was read in its message and diff, not only its subject. | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#14744` | 27/4 | 22/4 | `4f85e4d11` (PR objectstack-ai#15475): the flow-facing `record` (and its `params` alias) and `previous` are decoupled from the engine's own objects before a flow runs (`decoupleFromEngineState`: arrays, plain objects, `Date`, `RegExp`, `Map` and `Set` are copied, primitives, functions and other class instances shared), so a flow mutating a nested value in place no longer writes the batch payload that ADR-0058 Addendum II D3 shares across every row of a `multi: true` update. A COPY rather than a FREEZE, because `expandDeclaredLookups` writes into the record it is handed. The engine's write shape is unchanged, and the same-key per-row-value residue is deliberately left unguarded. Its changeset records the maintainer's option-A ruling on `objectstack-ai#14744` in its own words, its diff names `objectstack-ai#14744` on 29 added lines, and it created `decouple-flow-record.ts` and both of this package's pin files. `git blame` at the base puts every one of the 22 lines in this commit. New to the sweep | | `objectstack-ai#14744` (the census line) | (in the row above) | 1/0 | `03c1b0f6f` (PR objectstack-ai#15301): the census of same-key / per-row-VALUE `beforeUpdate` rewrites, which found ZERO across 23 production registration sites and recorded the `buildContext` overlay conclusion as a source reading, not a measurement. Its message names `objectstack-ai#14744` four times and states that result word for word. `before-update-flow-payload-reach.test.ts:29` describes this census, not the fix, so it cites the census commit, by the per-arm precedent of stages 5 and 9. The line was written by `4f85e4d11`, which descends from `03c1b0f6f` (`merge-base --is-ancestor` exit 0). New to the sweep | | `objectstack-ai#13657` | 1/1 | 1/0 | `b003cf2e8` (PR objectstack-ai#13864): the post-hook half of the declared-field door, which refuses an undeclared field a before-hook writes, with one envelope on every driver. Its message names `objectstack-ai#13657` seven times. The runtime and lint stages' anchor for the same number. The line was written by `4f85e4d11`, which descends from it (exit 0) | | `objectstack-ai#11081` | 5/1 | 5/0 | `c28e4cfae` (PR objectstack-ai#11570): the two SqlDriver-backed fixtures stop blanket-silencing their kernel and carry `@objectstack/runtime`'s shared expected-noise capture, which withholds only a declared table's own `no such table` line, forwards every other driver fault, and lets `afterAll` assert each channel fired. Its message names `objectstack-ai#11081`, and its diff writes the five `[objectstack-ai#11081]` tags in this very file; `git blame` at the base puts all five lines in it. Stage 7's anchor for the same number | Every cited sha matches exactly one commit (`git rev-parse --disambiguate`, count 1 for each of the 4), and all 4 are ancestors of the base (`merge-base --is-ancestor`, exit 0 for each; reverse leg, base against each anchor, exit 1 for each; control legs exit 0: stage 1's landing `422db788a`, and the repository's root commit, which lies deeper than every anchor; the history is complete, `--is-shallow-repository` false, 15,167 commits; the anchors lie 2,516, 2,585, 3,082 and 4,207 commits behind the base). Each of the 3 numbers answers 404 on the issues endpoint, which serves pull requests too. No ADR, `scripts/adr-anchors/` file or other `docs/` page records any of the three as its decision. `docs/audits/2026-09-multi-update-per-row-value-census.md` names `objectstack-ai#14744`, but it states that it is "measurement only — ships nothing … implements no guard", the input to a decision rather than its record, so the census line cites the commit that landed it. ## Wordings to check - **Tag swaps in brackets or parentheses.** 「[objectstack-ai#14744]」 became 「[commit 4f85e4d]」 at `decouple-flow-record.test.ts:4` and `before-update-flow-payload-reach.test.ts:805`. 「[objectstack-ai#11081]」 became 「[commit c28e4cf]」 on 5 lines. 「(objectstack-ai#14744, measured by objectstack-ai#15356)」 became 「(commit 4f85e4d, measured by objectstack-ai#15356)」 at `decouple-flow-record.ts:5`. 「(objectstack-ai#14744)」 became 「(commit 4f85e4d)」 at `record-change-trigger.ts:340`. 「(objectstack-ai#8738 pre-hook / objectstack-ai#13657 post-hook)」 became 「(objectstack-ai#8738 pre-hook / commit b003cf2 post-hook)」. - **Headings `:4` and `:859`.** 「[objectstack-ai#15356 measured, objectstack-ai#14744 closed]」 and 「[objectstack-ai#15356 measured it, objectstack-ai#14744 closed it]」 keep the live `objectstack-ai#15356` and put the sha where the dead number stood. - **`before-update-flow-payload-reach.test.ts:10`.** 「objectstack-ai#14744 then ruled the door closed」 became 「The option-A ruling (commit 4f85e4d) then closed the door」: the ruling is named in words beside the commit that carried it, whose changeset records it, the form stages 2, 6 and 7 used for a ruling. - **`:22` and `:87`.** 「the objectstack-ai#14744 residue shape」 and 「the objectstack-ai#14744 pinned residue shape」 became 「the residue shape commit 4f85e4d pins」: the positive control that pins it is in that commit's diff. - **`:23`.** 「because objectstack-ai#14744's fix is about aliasing」 became 「because commit 4f85e4d fixes aliasing」: a commit fixes something, it does not have a fix. - **`:29` and `:34`, the census paragraph.** 「objectstack-ai#14744's census found」 became 「The census in commit 03c1b0f found」. That removed the referent of 「The conclusion recorded on that card」 five lines down, so `:34` became 「The conclusion recorded in that census」. This is the one changed line that carried no dead number. It is true as written: the census record `03c1b0f6f` landed carries that very conclusion, "On a source reading, `buildContext` materialises a *new* record object by overlay … a reading, not a measurement" (`docs/audits/2026-09-multi-update-per-row-value-census.md:308-311`). - **`:455`.** 「that is precisely the blind spot objectstack-ai#14744 is weighing」 became 「… the blind spot commit 4f85e4d left unguarded」. The present tense described a card still being weighed; that commit's changeset says the key-set refusal "is untouched and is not widened — a hook that assigns the same key with per-row values still passes it". - **「Before objectstack-ai#14744」 / 「before objectstack-ai#14744」** at `:686`, `:705`, `:738`, `:924` (the word 「Before」 sits at the end of the line above at `:685` and `:704`) became 「before commit 4f85e4d」: before that commit the flow-facing record shared its nested values with the payload, which is the reading each sentence quotes. - **「objectstack-ai#14744 made」, 「objectstack-ai#14744 carries the fix」, 「objectstack-ai#14744 closed the door」** at `:47`, `:95`, `:642`, 「Until objectstack-ai#14744」 at `record-change-trigger.ts:341`, 「and objectstack-ai#14744.」 at `:124`, 「objectstack-ai#14744 — DECOUPLE」 at `:453`, 「(unchanged by objectstack-ai#14744 —」 at `:496`: the number became the commit, and each sentence already states what the commit did. ## The 4 sites left - **Test strings, 4 sites on 4 lines**, all `describe` / `it` titles carrying `objectstack-ai#14744`, left as stages 1 to 12 left theirs: `before-update-flow-payload-reach.test.ts:825` and `:872` (the second number of `[objectstack-ai#15356/objectstack-ai#14744]`, a spelling the gate's grammar cannot see), `decouple-flow-record.test.ts:78` and `:136`. - No source string, operator log string, assertion message, quoted maintainer ruling or generated file in this package carries a dead number. - Outside `src`, the package's `CHANGELOG.md` names `objectstack-ai#14744` on 2 lines (467, 478). It is release-owned and deliberately not edited here (see Acceptance notes). The package `README.md`, which also ships, names none of the three. ## Mechanical guard: no code token moves The guard compares, base `91e8fa194` against head, over all 5 touched `.ts` files: - **Reading 1**, the TypeScript parser's leaf nodes (a `forEachChild` walk, so comments are trivia and JSDoc nodes are never visited). String and template literals are therefore read in full. - **Reading 2**, the full token stream in parser context (a `getChildren` walk, so punctuation and keywords are included; JSDoc nodes skipped). Results: - Real run at the final head `bbfe7cb24`: 6,110 base leaf tokens, **0 files with a token change** on either reading (exit 0). - Comment control in `record-change-trigger.ts` (「reach nothing outside its own run.」 to 「reach nothing beyond its own run.」): 0 files changed, as expected (exit 0). - Positive control, a code token added in `record-change-trigger.ts` (`params: isolatedRecord,` given `as typeof isolatedRecord`): DIFFER, 953 to 954 leaf tokens and 2,130 to 2,133 full tokens (exit 1). - Positive control, one digit changed inside a kept test title (`decouple-flow-record.test.ts:78`, `objectstack-ai#14744` to `objectstack-ai#14745`): DIFFER on the string literal (exit 1). Every mutation went through `scripts/ablation-replace.mjs` (wrap mode) under a shell trap that restores by absolute path, and each landed (anchor 1 to 0, blob changed). Each restore was proven byte-identical to the HEAD blob (`f3235a962fc5`, `9a8bf70abbcc`), with `git diff HEAD` empty and a clean tree afterwards. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/trigger-record-change` (`.changeset/20596-trigger-record-change-provenance-anchors.md`) is included. Its body is stage 12's, word for word, with the package name changed. Measured on the built package (A3), after a full workspace build in which this package was a cache miss: `files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is not private. - `4f85e4d11` appears 3 times in each of `dist/index.js` and `dist/index.mjs`: the `buildContext` docblock (`record-change-trigger.ts:340` and `:341`) and the inline comment at `:496`, which the bundle keeps. - It appears twice in each of `dist/index.d.ts` and `dist/index.d.mts`: the same `buildContext` docblock. - The other three anchors appear nowhere in `dist`: their lines are in test files. The rewrites at `record-change-trigger.ts:124` and `:453` and `decouple-flow-record.ts:5` are stripped by the bundle. - Positive controls, one unchanged line beside each rewrite, land exactly where their neighbours do: the line after `:341` once in all four files, the line before `:496` once in each JS file and 0 in the declaration files, and the neighbours of the three stripped rewrites 0 everywhere. - A never-written negative phrase appears nowhere in `dist`. - None of the three dead numbers is left in `dist`. ## Gates (final head `bbfe7cb24`) - **Citation judging, as CI runs it:** `pnpm check:issue-citations` exits 0 (self-test, 114 cases, 8 batteries). `node scripts/check-issue-citations.mjs` exits 0: the diff-scoped run judged 1 added citation across 2 files, the live `objectstack-ai#15356` at `decouple-flow-record.ts:5`, and it resolves. - **Doc authoring:** `pnpm check:doc-authoring` exits 0 (the sibling-package prose-id baseline holds, no growth). - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `bbfe7cb24` (after a fresh fetch) derived 59 commands. They are all 53 derived at dispatch, plus `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. - Each ran with its exit code captured before any pipe, and all 59 exit 0; none exited 3. - `--ran`, fed each command with its exit code, reports 59 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. - A full `turbo run build` of `./packages/*` and `./packages/*/*` ran first under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an unbuilt workspace. - **Roster families the derivation lists outside its commands** (their rosters sit in directories this diff touches): `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit 0. - **Tests and typecheck, under the verify lock, at `bbfe7cb24`:** - `pnpm --filter @objectstack/trigger-record-change test`: 10 files pass and 101 tests pass. `vitest list --filesOnly` names 10 files, all the tracked test files, the 3 touched ones included. - `pnpm --filter @objectstack/trigger-record-change typecheck` exits 0. `tsc --listFiles` on `tsconfig.test.json` holds all 14 files under `src/`, and on `tsconfig.json` the 4 non-test files, so all 5 touched files are compiled. - **Lint, as a proven narrowing:** eslint with inline config disabled, over the 5 touched `.ts` files, gives 5 files, 0 errors and 0 warnings (its `--format json` output). All 5 are in eslint's own population (`isPathIgnored` is false for each; a `dist` file, as the control, is ignored). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as its own lines 327-328 state), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of the 6 changed files for control bytes finds none. ## Acceptance notes - **The gate-invisible spellings, grepped as the claim asked.** `CITATION_RE` refuses a hyphen after the digits and a `/` before the `#`, `NON_CITATION_HEADS` excuses a number after the word 「option」, and a URL-spelled link carries no `#` at all (objectstack-ai#20636). In this package, at the base and at the head: `#N-word` none, `#A/#B` 5 lines, `option #N` none, URL-spelled none, which is the claim's 0 / 5 / 0 / 0. Of the five `#A/#B` second numbers (`objectstack-ai#4251` twice, `objectstack-ai#5038`, `objectstack-ai#4649`, `objectstack-ai#14744`), only `objectstack-ai#14744` is dead, and it stands in a kept test title. - **`CHANGELOG.md` is left.** `packages/triggers/trigger-record-change/CHANGELOG.md` names `objectstack-ai#14744` on 2 lines. It is release-owned (AGENTS.md, Documentation Guardrails), a deferred surface of the citation gate, and ⛔ not part of this stage. - **A live number in a runtime string, left for its lane.** `record-change-trigger.ts:239`'s operator `warn` for an array-form trigger event ends with the live `objectstack-ai#3457`, and two tests assert the message carries it. That is form D, not this card's comment-only form C, and the shrink-only `doc-authoring-prose-id` baseline already holds it (`record-change-trigger.ts`: `objectstack-ai#3457: 1`), so `check:doc-authoring` sees no growth. - **「The card」 phrases are left.** 3 other comment lines in 2 files of this package speak of 「the card」. They carry no number, neither instrument sees them, and none of them lost a referent in this diff. They are unchanged, as in stages 8 to 12. - **The census instrument did not truncate in this stage.** All three enumerations read 187 pages at the newest frontier. - **Anchors the next stages can reuse**, each checked here: `objectstack-ai#14744` → `4f85e4d11` (the decoupling) or `03c1b0f6f` (its census), both new to the sweep; `objectstack-ai#13657` → `b003cf2e8` and `objectstack-ai#11081` → `c28e4cfae` reuse the runtime and lint stages' anchor and stage 7's. - **Base.** The branch is on `main` at `91e8fa194`. `main` has since moved six commits (`cd6d8a5ff`, `1bcba27d2`, `a3d7588b5`, `9ad654487`, `274e16271`, `085ca6bc1`). Their 50 files touch nothing under `trigger-record-change`, nor `scripts/check-issue-citations.mjs`, `.changeset/config.json` or the `doc-authoring-prose-id` baseline, and none is a path in this diff. Three of them are gate inputs (`scripts/engine-double-contract.pinned.json`, `scripts/objectql-double-limit.baseline.json`, `scripts/sdui-manifest.record.json`), so those families ran here against the base's copies; this diff moves no code token, so nothing here can interact with them. No merge was taken; the merge queue rebuilds on the merged generation. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…idge, at both bases (objectstack-ai#20779) Fixes objectstack-ai#20676 Clause-②: no ## What was broken ADR-0126 §7.1's flow clone door answered `404 ENDPOINT_NOT_FOUND` on every live server, for every caller and every body. The domain arm (`packages/runtime/src/domains/automation.ts`, `POST /:name/clone`) exists, but `registerAutomationRoutes` in `packages/runtime/src/dispatcher-plugin.ts` mounts every `/automation` route explicitly and never mounted this one, so the transport's `notFound` answered before `dispatch()` ran. The arm's unit test (`domains/automation-flow-clone.test.ts`) stayed green because it drives `HttpDispatcher` directly, below the mount. The route was also missing from `route-ledger.ts`, so the live-mount parity gate had no row to flag. ## What changed - `packages/runtime/src/dispatcher-plugin.ts`: `POST ${base}/automation/:name/clone` mounted beside `/:name/toggle`, dispatching to `POST /automation/:name/clone`. `registerAutomationRoutes` runs for both bases, so the environment-scoped twin (`/api/v1/environments/:environmentId/automation/:name/clone`) is mounted by the same line. Registered after `trigger/:name`: for a flow literally named `clone`, `POST /automation/trigger/clone` still reaches the legacy execution door, and either mount rebuilds the identical dispatch path, which the domain answers `trigger` first. - `packages/runtime/src/route-ledger.ts`: a `POST /automation/:name/clone` row, `server-only`, with its rationale (the operational driver is the Setup page, which calls the platform API directly; the same posture as the `POST /actions/_activation/:object/:action` row). There is no `client.automation.clone` SDK method, and `gap` is ratcheted at 0. Census regenerated with `--fix`: 81 to 82 rows. - `.changeset/20676-mount-flow-clone.md`: `@objectstack/runtime` patch. No domain arm, gate, response shape or spec file changed. `packages/runtime/src/domains/automation.ts` is untouched. ## Sweep: domain arms against bridge mounts Every `handleAutomationRequest` arm, diffed against the `registerAutomationRoutes` mounts on `origin/main` `f284ab26`: | Domain arm | Bridge mount | Verdict | |---|---|---| | `POST /` (create) | `POST /automation` | mounted | | `GET /actions`, `GET /connectors`, `GET /_status` | the three literal mounts, before `/:name` | mounted | | `GET /:name`, `PUT /:name`, `DELETE /:name` | `/automation/:name` x3 | mounted | | `POST /trigger/:name` (legacy) | `/automation/trigger/:name` | mounted | | `POST /:name/trigger` | `/automation/:name/trigger` | mounted | | `POST /:name/toggle` | `/automation/:name/toggle` | mounted | | **`POST /:name/clone`** | none | **mounted by this PR** | | `GET /:name/runs`, `GET /:name/runs/:runId` | both mounted | mounted | | `POST /:name/runs/:runId/resume` | mounted | mounted | | `POST /:name/runs/:runId/cancel`, `/restore-suspension` | both mounted | mounted | | `GET /:name/runs/:runId/screen` | mounted | mounted | | `GET /` (flow list) | none | retired (objectstack-ai#19543 door 4), correctly unmounted | The clone door was the only unmounted arm. No undeclared door was found, so nothing was mounted beyond the card. ## Pins - `packages/qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts` boots the CRM app with the automation service through `bootStack` (the real Hono app) and clones the shipped `crm_convert_lead_wizard`. It pins these cases: - an anonymous caller gets `401 UNAUTHENTICATED` (the domain floor, not the transport 404); - a legal clone gets `200` with `data.notice === FLOW_CLONE_NOTICE` (imported, not restated) and `status: 'draft'`, and the clone reads back on `GET /automation/:name`; - an illegal machine name gets `400 VALIDATION_FAILED`, and nothing is registered under it; - a missing `name` gets `400 VALIDATION_FAILED`; - a taken name gets `409 RESOURCE_CONFLICT`. - `packages/runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts` covers the environment-scoped twin, which `bootStack` never mounts because it boots without project scoping. It uses `plugin-hono-server` and the dispatcher with `enableProjectScoping: true` over a real socket. The discriminator is the anonymous floor's `401 UNAUTHENTICATED`, which only the dispatcher mints. Both bases are probed, with a positive control (`/:name/trigger`, changed from `/:name/toggle` in patch round 1 so it holds whichever of this PR and PR objectstack-ai#20780 lands first) and a negative control (an unmounted sibling segment answering the transport 404). - With the row in the ledger, `route-ledger-live-mount-parity.dogfood.test.ts` now also guards this mount. ## Reverse verification (ablation, one-off, nothing left in the tree) The fix was committed first. `scripts/ablation-replace.mjs` then renamed the mount path (`automation/:name/clone` became `automation/:name/clone-ablated-20676`, anchor 1 to 0). Runtime was rebuilt, and `ablation-dist-preflight.mjs` found the marker in `dist/index.js` and `dist/index.cjs`. - The runtime pin went red on the 2 clone cases, each with `404 {"code":"ENDPOINT_NOT_FOUND"}`. Both controls stayed green. - The dogfood pin went red on 5 of 5 cases, each with `404 ENDPOINT_NOT_FOUND`, the card's own symptom byte for byte. - The ledger parity gate went red on 2 cases: `POST /automation/:name/clone — LEDGERED BUT NOT MOUNTED`, and the ablated mount unledgered. Restore: the blob equals the HEAD blob (`b6dc62c9`), whole-tree `git status --porcelain` is empty, runtime was rebuilt, and `--absent` preflight shows the marker absent from all 6 built files. Re-run: runtime pin 4/4 green; dogfood (the clone pin, the ledger parity gate and `automation-toggle-tenant-scope`) 21/21 green. ## Downstream prose this makes true - The `FLOW_DISABLED` refusal ("...or run a clone of it under a new name", `service-automation/src/engine.ts`) and the Setup page copy now point at a door that answers. - `content/docs/capabilities/integrations.mdx` promises "switch it off and clone your own to edit in Studio". The clone half is now true. The **edit in Studio** half is not, as measured on the same harness, one-off and not committed: - after a `200` clone, `GET /api/v1/meta/flow/CLONE` answers `404 RESOURCE_NOT_FOUND`, while the source answers `200`; - after a cold boot on the same database file, `GET /api/v1/automation/CLONE` answers `404`, while the source answers `200`. The clone is engine-only. That is FOLLOW-UPS §8a D18, outside this card, and not fixed here. Carrier: objectstack-ai#20761's stage 2. The maintainer's ruling there (`5904938166`) pins "a clone of a shipped flow is saved as a tenant row", and the seat has posted this measurement on that card. ## Acceptance notes - **Fixed here** (a bounded in-place fix, patch round 1): the `/automation` enforcement prose in `packages/qa/dogfood/test/authz-conformance.matrix.ts` said "four gated flow writes". It now names five, adding the ADR-0126 §7.1 clone `POST /:name/clone`. Evidence: `isFlowAuthoringWrite` in `packages/runtime/src/domains/automation.ts` returns true for exactly five route shapes: `POST /` (`parts.length` 0), and `POST /:name/toggle`, `POST /:name/clone`, `PUT /:name` and `DELETE /:name` (`parts.length` 1). - **Fixed here** (a bounded in-place fix, patch round 1): the note on `route-ledger.ts`'s `POST /automation/:name/toggle` row. - BEFORE: 'The enabled bit is not a ROW, so no organization wall scopes it: `toggleFlow` writes an in-process map keyed by flow name only, `getFlowRuntimeStates()` reads it with no caller and no organization, and the automation service is ONE instance per environment'. - AFTER: 'No organization wall scopes the enabled bit: `toggleFlow` writes the ADR-0126 §7.2 activation ledger first — one deployment-wide `sys_metadata_activation` row per flow, keyed by `(metadata_type, name)`, carrying the flow's package id and no organization column — and only then updates the engine's in-process projection, which `getFlowRuntimeStates()` reads with no caller and no organization; the automation service is ONE instance per environment'. - Evidence: `toggleFlow` in `service-automation`'s `engine.ts` calls `flowActivationStore.setActive` before it updates `flowLedgerDisabled`, and core's `metadata-activation-store.ts` has the columns `metadata_type`, `name`, `package_id` and `active`, matched on `(metadata_type, name)`. - "Packaged flows only" is NOT added here: that is PR objectstack-ai#20780's behaviour, and whichever of the two PRs lands second adds it. - The dogfood census pins were re-derived by the census file's own method (patch round 1). In `authz-probe-blind-spot.census.ts`, the `route-ledger.ts` probe row went from population/reach 81/81 to 82/82, with the blind spot 0 and keys 21 unchanged; `BLIND_SPOT_TOTAL_STATIC` 67 / `_RUNTIME` 72 are unchanged. The `authz-conformance.matrix.ts` docblock now reads (82 rows / 21 domains). - `objectstack-ai#20679` (the packaged-flow lock on PUT/DELETE) is not addressed here. The clone pins use a new, customer-owned name and do not exercise that lock. - `docs/qa/platform-checklist/FOLLOW-UPS.md` §8a D22 ("`POST /automation/:name/clone` is unledgered") goes stale when this lands. The file is outside this card's surface. Carrier: none; noted, not filed. ## Verification **Patch round 1 — final head `99b3cfa4`** (a merge of `origin/main` over `ab7d5015` and `5518c808`): - Runtime pins (`dispatcher-plugin.automation-clone-mount.integration`, `route-ledger.conformance`, `automation-api-contract-mounts`, `domains/automation-flow-clone`): 4 files, 31 tests passed. Runtime and dogfood typecheck are green. - The full dogfood package: 141 files passed and 1 skipped (142); 1155 tests passed and 3 skipped. The two formerly red files (`authz-conformance.test.ts`, and `authz-probe-blind-spot.test.ts`, the shard-3 file) pass. - `dispatch-gates --ran` reconciles 67 of 67 with 0 NOT-MEASURED. `check:route-ledger-census` reads 82, and the array holds 82. **Round 0 (head `0b1c343e`), kept for the record:** Head `0b1c343e`. The full runtime suite ran at `d663c2fe`, whose only difference from `0b1c343e` is one string in the new ledger note. Every suite that reads the ledger was re-run at `0b1c343e`. - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` at `d663c2fe`: 291 files passed, 4204 tests passed, 1 skipped. - At `0b1c343e`: `route-ledger.conformance`, `automation-api-contract-mounts`, the new clone-mount pin and `domains/automation-flow-clone`, 4 files and 31 tests passed. Dogfood: the clone pin and the ledger parity gate, 13/13 passed. - `pnpm --filter @objectstack/runtime typecheck` (`tsc --noEmit` plus `check:test-typecheck`) and `pnpm --filter @objectstack/dogfood typecheck`: both green at `0b1c343e`. `tsc --listFiles` confirms each program contains its new test file (1 hit each). - `dispatch-gates --commands` (67 commands) at `d663c2fe`: 64 exited 0. The other three were resolved as follows: - `check:doc-authoring` was a real finding: a tracker id inside the new ledger note string. It is removed in `0b1c343e`, and the gate now exits 0. - `check-plugin-teardown-shape --self-test` refused on the shallow clone. After fetching its pinned fixture commit it passed, 48 cases. - `check:dual-build-cjs-loads` refused with a prerequisite error: 8 packages outside the build closure had no `dist/`. They are now built. The final-head re-run of all 67, and the `--ran` reconciliation, are in the report comment on the card. - Lint, as a proven narrowing and not a full `pnpm lint`. eslint `--format json` over the 4 touched TS files returned 4 results, 0 errors, 0 warnings. Each file is inside eslint's own population (`--print-config` returns 6/6/5/5 rules). `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no `projectService`), and its only file reads are two baseline JSONs this diff does not touch, so the diff cannot move any untouched file's verdict. The full-tree `pnpm lint` is left to CI. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ned Check Changeset red (objectstack-ai#20976) Fixes objectstack-ai#20784 Clause-②: no ## What changes `.github/workflows/pr-automation.yml`, job `changeset-check` (`Check Changeset`). The three steps after `Reject an empty-frontmatter changeset added by this PR` now take: ```yaml if: >- !cancelled() && steps.labels.outputs.skip != 'true' && steps.labels_settled.outputs.skip != 'true' && steps.diffbase.outputs.merge_base != '' # the major guard alone adds: && steps.allow_major.outputs.allow != 'true' ``` - `Require an ADR-0087 disposition on a declared-breaking changeset` (`check-adr-0087-registration.mjs`) - `Re-read this PR's allow-major label live` (id `allow_major`; it only feeds the guard below) - `Guard against accidental major bumps (launch window)` (`check-changeset-no-major.mjs`, the level axis) There is no `always()`, so a cancelled run still stops. The job stays red on the DELIBERATE CORRECTION class. It now also shows the ADR-0087 and level-axis verdicts on that same run. The route-0 text that `Require a changeset` prints said "the steps after it are skipped". This change makes that false, so the text now says which step is still skipped and that the two verdict steps after it run. The pin is the file's existing shape pin: `scripts/check-empty-changeset.mjs --self-test`, battery "The consumer: this gate's own CI step". It gains 11 cases, and the battery floor moves from 23 to 34. It asserts four things: - each of the three steps begins its `if:` with `!cancelled() &&` and has no `||`; - each of them requires `steps.diffbase.outputs.merge_base != ''`; - no other step of the job names a status function; - nothing in the workflow uses `always()`. No gate-script logic changed. Only the self-test block and its floor did. ## The three hypotheses, measured before building **H1: which steps follow the empty-changeset step.** There are exactly three, the ones listed above. All three should run past its red. None of them is a consequence of that red: two are independent verdicts on the same diff, and the allow-major read is an input to one of them. A second designed red hides the same three steps. On a correction-only PR (it adds no changeset of its own), route 0 makes `Require a changeset` fail by design. Both later gates diff with `--diff-filter=AMR`, so such a PR still gives them something to judge. The triage direction's literal form, `!cancelled() && LABEL_CONDITIONS`, runs the steps past both reds, and this PR keeps that reach. Fixture F2 below shows the verdicts that were being hidden. **H2: the outputs the later steps read.** No later step reads an output of the empty-changeset step, which has no `id`. When that step is red, every step before it succeeded, so `merge_base` is set, `labels.skip` is `false`, and `labels_settled.skip` is `false` or unwritten (never `'true'`). The later steps see correct inputs. The literal form has a wider reach than that, though. It would also run the three steps past the unusable-base red (`Require a usable diff base`), where `steps.diffbase.outputs.merge_base` is the empty string. I measured all three changeset scripts on this tree with an empty base. Each one treats it as "no base" and answers against `origin/main` with exit 0: ```text node scripts/check-adr-0087-registration.mjs --base "" -> exit 0 "this PR adds no declared-breaking changeset" node scripts/check-changeset-no-major.mjs --base "" -> exit 0 "Diffing HEAD from 3693a1b (merge base with origin/main)" node scripts/check-empty-changeset.mjs --base "" -> exit 0 (same fallback) ``` That verdict would be about a base this job had already refused to trust. So each step also requires `merge_base != ''`. This is the same step-level `!cancelled()` plus "the output that proves the input exists" pattern that `release.yml` and `cut-rc.yml` already use. This is not the dispatch's stop condition: `merge_base` is not an output of the empty-changeset step. It is the minimal guard the literal form needs. No other input needs a guard. The two scripts import only node builtins and repo-local modules, so a failed `pnpm install` cannot change their verdict. An unwritten label output is not `'true'`, which is the enforcing direction. **H3: where the shape pin lives.** It lives in `scripts/check-empty-changeset.mjs --self-test`, in the battery "The consumer: this gate's own CI step". That battery already pins this job's step conditions: the settling read, both label guards on every step that can fail, and the allow-major read. `check-workflow-status-functions` does not fit: its own scope note limits it to job-level `if:`. `check-changeset-no-major.mjs`'s wiring battery pins the guard step's `run:` and `env:`, and it passes unchanged. ## Evidence **Ablation.** Each leg ran once, on the committed tree, through `scripts/ablation-replace.mjs`. After every leg the tool proved the file was restored: its blob equals HEAD (`e0b36ccdfa02`) and `git diff HEAD` is empty. | Leg | Mutation | Self-test result | |---|---|---| | a | ADR-0087 step without `!cancelled()` | exit 1, 2 failures: the leading conjunct, and the closed set (found steps 13 and 14 instead of 12 to 14) | | b | major guard with `base_error == ''` in place of the `merge_base` read | exit 1, 1 failure | | c | allow-major read with `always()` | exit 1, 2 failures: the leading conjunct, and no `always()` | | d | `Require a changeset` also given `!cancelled()` | exit 1, 1 failure: the closed set (found steps 10 and 12 to 14) | | e | whole workflow as it was at the base commit, i.e. without this change (trap-guarded swap; blob proven equal to HEAD after restore) | exit 1, 7 failures: 3 leading conjunct, 3 `merge_base`, and the closed set (found none) | The first attempt at leg b did nothing. Its replacement text was a substring of its anchor, so the tool refused before running anything. The leg was re-run with a replacement that can be told apart. **Fixture run.** This used a shared clone in scratch whose commits were never pushed. Each script ran with `--base` set to this branch's head. | Scenario | empty-changeset | ADR-0087 | no-major | |---|---|---|---| | F1: the shape of PR objectstack-ai#20780. It edits a pending note and adds its own `minor` changeset that carries a **BREAKING** banner and no disposition. | exit 1 (foreign-changeset refusal) | exit 1 (no disposition) | exit 0 | | F2: correction only. It edits a pending note from `minor` to `major` and adds nothing. The count step would report 0, so route 0 turns `Require a changeset` red. | exit 1 | exit 1 (turned breaking, no disposition) | exit 1 (majored in place) | Under the old conditions, none of the ADR-0087 or no-major verdicts in F1 or F2 would have run in CI. ## Gates at HEAD 4888e25 `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derived 52 commands from this diff. All 52 were run and every one exited 0. `dispatch-gates --ran` reports: "52 derived famil(ies) accounted for, 52 run, 0 NOT-MEASURED". Among them: ```text exit 0 node scripts/check-empty-changeset.mjs --self-test (170 assertions) exit 0 pnpm check:changeset-gate-self-tests (empty-changeset, adr-0087 and no-major self-tests; no-major 339 assertions) exit 0 pnpm check:workflow-status-functions exit 0 pnpm check:workflow-step-name-quoting exit 0 node scripts/check-self-test-workflow-commands.mjs (+ --self-test) exit 0 node scripts/check-step-collectors.mjs (+ --self-test) exit 0 pnpm check:nul-bytes exit 0 pnpm check:pm-dispatch-gates (1976 cases; 1243 s on this box) ``` **Narrowed lint.** eslint's population is `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`, and `.github/workflows/pr-automation.yml` falls outside it. eslint itself reports "File ignored because no matching configuration was supplied". The `--format json` output has 2 entries: `scripts/check-empty-changeset.mjs` with 0 errors and 0 warnings, and the ignored YAML. Nothing in the config enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot change the verdict on any file it does not touch. No changeset: this diff changes workflow wiring and a root `scripts/` self-test, and neither publishes anything. ## Acceptance notes - **Live proof on GitHub is NOT MEASURED in this PR.** A `pull_request` run takes its workflow from the PR's own merge ref. A correction-class PR will show both verdicts on one run only after this lands. PR objectstack-ai#20780 would then need a new event such as a push or a `main` merge. A re-run is not enough: `rerun_failed_jobs` replays the original merge ref. - **This PR's own `Check Changeset` run carries `skip-changeset`.** That only exercises the exempt direction: the three steps must stay skipped when either label read exempts the PR. - **Boundary, not widened:** `Reject an empty-frontmatter changeset added by this PR` keeps the implicit `success()`. On a correction-only PR it is still skipped behind route 0, and route 0's own text says so. - **Dormant, not filed:** the three changeset scripts treat an explicit empty `--base ""` as absent and fall back to `origin/main` with exit 0. With the `merge_base` conjunct, CI never reaches that path. Carrier: none. - **Base behind `origin/main`:** this branch sits on `3693a1b50`. No upstream commit since then touches either changed file or the three changeset scripts, and `merge-tree` is clean. CI judges the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20726
Clause-②: no (narrowing)
What this does
toggleFlowis the automation service's activation switch. It is served asPOST /api/v1/automation/NAME/toggleand called byclient.automation.toggle. It now switches packaged flows only, as triage's direction on this card reads ADR-0126 §4 and §7.2 (option 1, comment5901337034).RESOURCE_CONFLICT/409. That holds in both directions, and with or without an activation ledger attached. The refusal is the first thingtoggleFlowdoes after the unknown-flow check. It runs ahead of both ADR-0126 §7.3 guards, the ledger write and any in-process change, so nothing half-flips.status, published through its update door,PUT /automation/NAME, which takes the complete definition.obsoleteswitches it off andactivearms it.statusitself.The refusal as the wire carries it, measured at the dispatcher seam (
HttpDispatcher.handleAutomation, the real engine, a flow with no package envelope):The transport maps that thrown shape (
err.status,err.code) to the HTTP answer inerrorResponseBase. #20678's disable-half dev measured this live, on this door, for the sameObject.assign(new Error(…), { code, status })shape.The code:
RESOURCE_CONFLICT/ 409 (G3)Chosen from the standard catalog (
StandardErrorCode). No ledger entry is minted, andpnpm check:error-code-casingis green.RESOURCE_CONFLICT/ 409 for its other state conflict, the §7.3 enable guard. The door keeps one dialect.VALIDATION_ERROR): nothing in the request is malformed, and today's 400 is the defect;DELETE_RESTRICTED: that member means dependencies;METHOD_NOT_ALLOWED: the route serves the method.A customer flow that a ledger row already holds off
The ledger is keyed by name, so a row can already stand under a customer flow's name, in two ways:
sys_metadataruntime-row sentinel, or a tenant-authored row bound to an app package. Pin 1 at base shows both were accepted, and it wrote a row for each.A
statusdoes not clear such a row:isFlowEnabledcomposes the two, and neither overrides the other. Measured at the engine seam:enabled: false.activeleaves itenabled: false.So for that flow alone, a refusal that stopped at "publish it
active" would name a step that completes nothing. In that state the refusal says the row holds the flow off. It names the step that does complete, which theFLOW_DISABLEDrefusal already names for a ledger-held flow: clone it under a new name (POST /automation/NAME/clone), which arms the copy, then remove the old one. Still nothing is written. Whether the door should instead clear such a row is not this refusal's to decide. It is the first open question in the dev report.Measured premises (G1 to G7)
describeFlowContender(flow).source !== 'package'(isCodeArtifactBody), the discriminator the §7.3 guards already ask. No second reading was added.sys_metadatasentinel or a tenant-authored app-bound row carries a non-empty_packageId, so the ledger's "Package is required" never refused it. At base it toggled and wrote a row.toggleFlowwrote the ledger row first, withpackageId: String(flow._packageId ?? ''). The refusal sits before that write and before the in-process change.flowActivationStore): at base a customer flow flipped in process only, with theIN PROCESS ONLYwarning. Now it is refused identically, nothing moves, and the warning is never reached (pin 1, degraded case).PUT /:nameinpackages/runtime/src/domains/automation.ts(updateFlow). It callsregisterFlow(name, definition)with the complete definition. The refusal names it as service-automation: the packaged-subflow disable refusal tells the admin to disable the calling flow first, but a disabled caller still blocks the disable — the prescribed remedy can never complete #20678's refusals name doors (PUT /automation/NAME). Pin 3 drives that registration path.registerFlow, and the engine persists no definition. A flow whose definition lives on the metadata plane re-registers its stored definition at the next boot or metadata reload. Dev report, open question 2..mdxwas regenerated, not hand-edited.Clause-②: no (narrowing), measured on this diff. It is not copied from the claim.VALIDATION_FAILEDbecomes 409RESOURCE_CONFLICT.no (narrowing), and the service-automation changeset isminorwith a BREAKING banner.Pins: red first, and an ablation for each
New file
packages/services/service-automation/src/toggle-door-packaged-only.test.ts, beside the ledger suite.Pin 1. A customer-authored flow toggled through the door gets the named refusal, and the ledger and the flow are unchanged. It asserts:
codeandstatus;status, andPUT /automation/NAME;setActivenever called and the ledger rows unchanged;/_statusstate unchanged, the trigger still bound, andexecutestill running.Cases: three provenance shapes × both directions, the no-ledger degraded mode, and a customer flow that a ledger row already holds off.
Pin 2 (the control). A packaged flow still toggles: row written, disarmed,
FLOW_DISABLED, then re-enabled and re-armed.Pin 3. A customer flow published with
status: 'obsolete'through the registration path is not armed, andactivearms it again. The ledger is never written.Order of commits:
9c9eb7b6cpins red:Tests 7 failed | 2 passed (9). Each failure read "expected the toggle door to refuse, and it accepted".7b35222ecthe fix.c11a4f1a6the held-off pin, red:1 failed | 9 passed (10), on the missing clone step.94a4e3af6its message branch.Ablations:
scripts/ablation-replace.mjswrap mode, run from the committed state, with an outertrapon EXIT/INT/TERM restoring by absolute path. In every leg the anchor hit 1 → 0, the blob changed, and the restore read "blob == HEAD andgit diff HEADis empty". There is no dist leg: the pins import./engine.jsrelatively, so they resolvesrc.obsoleteno longer disablesDocs: three lanes, prose only (G5)
packages/spec/src/api/automation-api.zod.tsmodule docblock.content/docs/references/api/automation-api.mdxcomes frompnpm --filter @objectstack/spec check:generated --fix: exactly one stale artifact, regenerated from a dist that run built.client.automation.toggle. Its one-line docblock had drifted above an unrelated member (listActions). It is moved back ontotoggleand says which flows the door switches.packages/runtime/src/domains/automation.ts: the route list onhandleAutomationRequestand the authoring-write predicate's list.content/docs/releases/**is untouched.Changesets, one per package whose published bytes move (G6)
Each marker was grepped over the package's
files[]after a build, with a control phrase from the same file.@objectstack/service-automationminor, BREAKINGdist/index.jsanddist/index.cjs(2 files; control 2)@objectstack/specpatchsrc/api/automation-api.zod.ts, shipped byfiles[](src/**/*.zod.ts); the contract docblock is indist/contracts/index.d.tsand.d.mts(control 2)@objectstack/clientpatchdist/index.d.ts,.d.mts,index.jsandindex.mjs(4; control 4)@objectstack/runtimepatchFlow definition body required) hitsdist/index.jsanddist/index.cjs; the maps carry nosourcesContentADR-0087 disposition on the breaking changeset:
not-required (no-migration-prescription). No metadata changes shape and nothing an author wrote is renamed or removed. Its body carries the migration:POST …/NAME/toggleon a customer flow TOPUT /api/v1/automation/NAMEwithstatus: 'obsolete'or'active';client.automation.toggle(name, false)TOclient.automation.update(name, { ...definition, status: 'obsolete' }).Outside the claim's declared file surface: deviations, each for a named reason
The claim declared
engine.ts(toggleFlowonly), a pin file, the three docs lanes and.changeset/20726-*.md. These files moved beyond it, one reason each:A private helper beside
toggleFlow(refuseCustomerAuthoredToggle) holds the refusal, on the pattern ofrefuseEnableOntoDisabledSubflow.toggleFlowis its only caller.Fixture triage (necessary to stay green). Ten existing cases toggled a flow with no package envelope, only as a vehicle for toggle semantics. Their subject now ships from a package (
_packageId: 'crm'):engine.test.ts×6;engine-residual-log-cause,flow-label-on-result,flow-terminal-messagesandnode-type-vocabulary-seal-warning×1 each.The two §7.3 non-packaged pins in
flow-activation-ledger.test.tspinned toggling a customer flow. They are re-spelled through its status: a customer subflow is switched off by its status, and a customer caller is refused by the door and armed by its status.packages/services/service-automation/README.md(published). Its example registered a flow in process and then toggled it off, which is exactly the call this change refuses. It now switches that flow off through its status and shows the toggle on a packaged flow.A DELIBERATE CORRECTION of a pending release note,
.changeset/20678-subflow-disable-sequence.md, for confirmation on this PR.node scripts/check-empty-changeset.mjsis red on it by design (its DELIBERATE CORRECTION class). ⛔ Do not restore it from base: that would republish the false sentence.The clone door's notice (
packages/runtime/src/flow-clone.ts,FLOW_CLONE_NOTICE, plus its pin inautomation-flow-clone.test.tsand a runtimepatchchangeset)._packageId, and the named toggle answeredRESOURCE_CONFLICT/ 409.PUT), and says the toggle switches packaged flows only and refuses the clone, whatever the clone was copied from: the clone door takes any registered flow as its source.toggle, which pinned the prescription. It now assertsstatus: 'obsolete'andPUT /api/v1/automation/(red against the old notice, ablation M5).IAutomationService.toggleFlow's docblock (packages/spec/src/contracts/automation-service.ts, published indist/contracts/*.d.ts) read "Enable or disable a flow", the same line as the API page. It now says the same as the other lanes. Prose only.Local verification at
d7eb865aaEvery reading below was taken on this tree at
d7eb865aa, the head this PR opens with. The exit code was captured before any pipe.Package suites. Each package this diff touches got its test and typecheck (
pnpm --filter PKG):service-automationTest Files 157 passed (157),Tests 1974 passed (1974); base0d9349fearead 156 / 1964--listFilescounts the new pin file once intsconfig.jsonand once intsconfig.test.jsonspec576 passed (576),16991 passed, 1 todoclient50 passed (50),641 passed (641)runtime290 passed (290),4200 passed, 1 skippeddogfoodautomation-toggle-tenant-scopeandpackaged-activation-ledger-reach:2 passed,19 passedDerived gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived on the real diff: 111 commands. All 111 ran, and--ranreads: "111 derived famil(ies) accounted for — 111 run, 0 NOT-MEASURED (a DERIVED zero — all 111 recorded an exit code and none of them is 3)".check:skill-examples,check:dual-build-cjs-loadsandcheck:type-check-debt. They exit 0 after a full packages build (turbo run build, 71/71 tasks).node scripts/check-empty-changeset.mjs --base origin/main. That is its DELIBERATE CORRECTION class on.changeset/20678-subflow-disable-sequence.md(deviation 4, for confirmation).The seven roster families printed outside the runnable list, each exit 0:
node scripts/check-changeset-fixed.mjspnpm --filter @objectstack/spec run check:meta-url-spellingpnpm --filter @objectstack/spec run check:spec-changespnpm check:authz-resolverpnpm check:error-code-casing("no unlisted lowercase error codes in 7012 scanned file(s)")pnpm check:filter-alias-paritypnpm check:route-ledger-censusExtra, each exit 0:
pnpm check:durability-log-levelpnpm check:startup-registry-verdictnode scripts/check-changeset-no-major.mjs --base origin/main --eventwith a syntheticpull_requestpayload carrying this body: "LEVEL AXIS: this PR declares clause-②no (narrowing), and it grades a package whosepackages/**/src/**it moves atminoror above"check-adr-0087-registrationwith the same payload: 1 declared-breaking changeset,not-required (no-migration-prescription)Generated artifacts.
pnpm --filter @objectstack/spec check:generated: all 15 up to date, after one--fixofcontent/docs/references/**.Lint, a declared narrowing.
eslint --no-inline-config --format jsonover the 21 changed files: 21 files, 0 errors, 7 warnings. Every warning is "File ignored because no matching configuration was supplied", on the.md/.mdxfiles.eslint.config.mjs'sfiles: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'].parserOptions.projectand 0 hits forprojectService. So this diff cannot move a verdict on an untouched file.Bytes.
pnpm check:nul-bytesexit 0, and a control-byte self-scan of the 21 files found nothing.NOT MEASURED, and why:
check-issue-citations --census, threecheck-shard-attestation --emitand twocheck-test-completeness.Dogfood Regression Gateruns it.Acceptance notes
PUT /automation/NAMEpersists no definition. A flow whose definition lives on the metadata plane re-registers its stored definition at the next boot or metadata reload. The dev report's open question 2. Noted, not filed.POST /automation/:name/toggleis out of date (packages/runtime/src/route-ledger.ts). It still describestoggleFlowas writing "an in-process map keyed by flow name only", which has been untrue since the activation ledger landed, and it does not say "packaged only". Outside this PR's declared surface. Noted, not filed.refuseEnableOntoDisabledSubflow's early return for a non-packaged flow is now unreachable throughtoggleFlow, its only caller. It is harmless, and left in place. Noted, not filed.client.automation.togglestays as published (content/docs/releases/**is release-owned).Patch round 1 (appended by the
domain:servicesseat)d7eb865a(record5904799342) FAILed on one clause. The clone-notice rider asserted that the clone's source is packaged ("such as the one it was copied from"), butPOST /:name/clonetakes any registered flow as its source.84334191, one fast-forward commit (2 files, +5 / −4). The claim about the source is dropped fromFLOW_CLONE_NOTICE, its docblock and.changeset/20726-clone-notice-status-switch.md. The prescription (the clone's ownstatus, throughPUT /api/v1/automation/NAME) is unchanged.84334191:@objectstack/runtime: 4200 passed, 1 skipped; typecheck exit 0;dispatch-gates --commands: the same 111 as round 1, all run. 110 exit 0 (check:doc-authoringamong them), and the one exit 1 ischeck-empty-changeseton the confirmed DELIBERATE CORRECTION of the 20678 note, unchanged.84334191before enqueue.Generated by Claude Code