Skip to content

fix(service-automation)!: the toggle door switches packaged flows only; a customer flow is refused, naming its status switch (#20726) - #20780

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-20726-toggle-door-packaged-only
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-20726-toggle-door-packaged-only

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20726
Clause-②: no (narrowing)

What this does

toggleFlow is the automation service's activation switch. It is served as POST /api/v1/automation/NAME/toggle and called by client.automation.toggle. It now switches packaged flows only, as triage's direction on this card reads ADR-0126 §4 and §7.2 (option 1, comment 5901337034).

  • Refuse, don't write. A flow without package provenance is refused with RESOURCE_CONFLICT / 409. That holds in both directions, and with or without an activation ledger attached. The refusal is the first thing toggleFlow does 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.
  • The refusal names the flow's own switch. It says the switch turns packaged flows on and off. It names the customer flow's switch: its definition's status, published through its update door, PUT /automation/NAME, which takes the complete definition. obsolete switches it off and active arms it.
  • The door does not write the definition. It never rewrites a customer flow's status itself.
  • Packaged flows toggle exactly as before.

The refusal as the wire carries it, measured at the dispatcher seam (HttpDispatcher.handleAutomation, the real engine, a flow with no package envelope):

code: RESOURCE_CONFLICT · status: 409
Flow 'customer_flow' cannot be disabled through this switch: the switch turns packaged flows on and off, and 'customer_flow' was authored in this deployment, not shipped by a package. The switch records an installation's choice about a packaged flow in the activation ledger (sys_metadata_activation, ADR-0126 §7.2). This flow's switch is its own definition's status: publish it through its update door, PUT /automation/customer_flow, which takes the complete definition, with status 'obsolete' to switch it off or 'active' to arm it. Nothing was changed.

The transport maps that thrown shape (err.status, err.code) to the HTTP answer in errorResponseBase. #20678's disable-half dev measured this live, on this door, for the same Object.assign(new Error(…), { code, status }) shape.

The code: RESOURCE_CONFLICT / 409 (G3)

Chosen from the standard catalog (StandardErrorCode). No ledger entry is minted, and pnpm check:error-code-casing is green.

  • The flow exists and the request is well-formed, but the target's provenance does not admit the act. That is the catalog's "the request conflicts with the resource's current state" member.
  • This same door already answers RESOURCE_CONFLICT / 409 for its other state conflict, the §7.3 enable guard. The door keeps one dialect.
  • The other members do not fit:
    • not a 400 (VALIDATION_ERROR): nothing in the request is malformed, and today's 400 is the defect;
    • not a 403: no caller could be authorized into it;
    • not a 404: the flow exists;
    • not DELETE_RESTRICTED: that member means dependencies;
    • not 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:

  • The door used to accept a customer flow whose package id was non-empty: the sys_metadata runtime-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 customer overlay can shadow a packaged flow that the ledger switched off.

A status does not clear such a row: isFlowEnabled composes the two, and neither overrides the other. Measured at the engine seam:

  1. After a boot that reads such a row, the flow is enabled: false.
  2. The door refuses it.
  3. Republishing it active leaves it enabled: 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 the FLOW_DISABLED refusal 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)

  • G1 holds. "No package provenance" is describeFlowContender(flow).source !== 'package' (isCodeArtifactBody), the discriminator the §7.3 guards already ask. No second reading was added.
    • The measurement widened the premise: provenance, not an empty package id, decides.
    • A flow with the sys_metadata sentinel 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.
    • Pin 1 covers all three shapes.
  • G2 holds. toggleFlow wrote the ledger row first, with packageId: String(flow._packageId ?? ''). The refusal sits before that write and before the in-process change.
    • Degraded mode (no flowActivationStore): at base a customer flow flipped in process only, with the IN PROCESS ONLY warning. Now it is refused identically, nothing moves, and the warning is never reached (pin 1, degraded case).
  • G3: above.
  • G4 holds, with one boundary. The door is PUT /:name in packages/runtime/src/domains/automation.ts (updateFlow). It calls registerFlow(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.
    • The boundary, read from source and not measured live: this door re-registers the definition in process. The route calls 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.
  • G5: three lanes, prose only (below). The .mdx was regenerated, not hand-edited.
  • G6: below, measured on the built output.
  • G7: Clause-②: no (narrowing), measured on this diff. It is not copied from the claim.
    • Nothing widens. No new export, member, key or accepted input. The spec and client edits are docblock prose.
    • The accept set narrows:
      • a customer flow with a non-empty package id was accepted, and is now refused;
      • with no ledger attached, a customer flow's flip was accepted in process, and is now refused;
      • a customer flow with no package id changes refusal: 400 VALIDATION_FAILED becomes 409 RESOURCE_CONFLICT.
    • So the declaration is no (narrowing), and the service-automation changeset is minor with 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:

    • code and status;
    • the named subjects: packaged flows, status, and PUT /automation/NAME;
    • setActive never called and the ledger rows unchanged;
    • /_status state unchanged, the trigger still bound, and execute still 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, and active arms it again. The ledger is never written.

Order of commits:

  • 9c9eb7b6c pins red: Tests 7 failed | 2 passed (9). Each failure read "expected the toggle door to refuse, and it accepted".
  • 7b35222ec the fix.
  • c11a4f1a6 the held-off pin, red: 1 failed | 9 passed (10), on the missing clone step.
  • 94a4e3af6 its message branch.

Ablations: scripts/ablation-replace.mjs wrap mode, run from the committed state, with an outer trap on EXIT/INT/TERM restoring by absolute path. In every leg the anchor hit 1 → 0, the blob changed, and the restore read "blob == HEAD and git diff HEAD is empty". There is no dist leg: the pins import ./engine.js relatively, so they resolve src.

leg mutation red
M1 refusal call deleted 8 (all of pin 1)
M2 provenance test inverted 9 (pin 1, and pin 2, the control)
M3 obsolete no longer disables 1 (pin 3)
M4 ledger-held branch dropped 1 (the held-off pin)
M5 clone notice back to the toggle 1 (the clone notice pin, runtime)

Docs: three lanes, prose only (G5)

  • packages/spec/src/api/automation-api.zod.ts module docblock.
    • The toggle line reads "Enable/disable a packaged flow".
    • A new paragraph says which flows the door switches and what a customer flow uses instead.
    • content/docs/references/api/automation-api.mdx comes from pnpm --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 onto toggle and says which flows the door switches.
  • The route's description in packages/runtime/src/domains/automation.ts: the route list on handleAutomationRequest and 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.

package level evidence
@objectstack/service-automation minor, BREAKING behaviour; the refusal text is in dist/index.js and dist/index.cjs (2 files; control 2)
@objectstack/spec patch the module docblock is in src/api/automation-api.zod.ts, shipped by files[] (src/**/*.zod.ts); the contract docblock is in dist/contracts/index.d.ts and .d.mts (control 2)
@objectstack/client patch the docblock is in dist/index.d.ts, .d.mts, index.js and index.mjs (4; control 4)
@objectstack/runtime patch only for the clone notice (below). The route docblocks reach no published file: 0 hits in dist, and a docblock control also 0, while a string-literal control (Flow definition body required) hits dist/index.js and dist/index.cjs; the maps carry no sourcesContent

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

  • FROM POST …/NAME/toggle on a customer flow TO PUT /api/v1/automation/NAME with status: 'obsolete' or 'active';
  • in the SDK, FROM client.automation.toggle(name, false) TO client.automation.update(name, { ...definition, status: 'obsolete' }).

Outside the claim's declared file surface: deviations, each for a named reason

The claim declared engine.ts (toggleFlow only), a pin file, the three docs lanes and .changeset/20726-*.md. These files moved beyond it, one reason each:

  1. A private helper beside toggleFlow (refuseCustomerAuthoredToggle) holds the refusal, on the pattern of refuseEnableOntoDisabledSubflow. toggleFlow is its only caller.

  2. 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-messages and node-type-vocabulary-seal-warning ×1 each.

    The two §7.3 non-packaged pins in flow-activation-ledger.test.ts pinned 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.

  3. 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.

  4. A DELIBERATE CORRECTION of a pending release note, .changeset/20678-subflow-disable-sequence.md, for confirmation on this PR.

    • That note lists "A flow the customer authored" under "Not refused". In the release that ships it, the switch refuses a customer-authored flow before that guard is asked.
    • The sentence is corrected in its own entry, per AGENTS.md. The customer-authored subflow half stays true, and stays.
    • node scripts/check-empty-changeset.mjs is red on it by design (its DELIBERATE CORRECTION class). ⛔ Do not restore it from base: that would republish the false sentence.
  5. The clone door's notice (packages/runtime/src/flow-clone.ts, FLOW_CLONE_NOTICE, plus its pin in automation-flow-clone.test.ts and a runtime patch changeset).

    • Every successful clone told the admin to switch the clone off through the toggle. A clone carries no package envelope, so the toggle refuses it.
    • Measured at the dispatcher seam with the real engine: the clone door answered 200 with that notice, the clone carried no _packageId, and the named toggle answered RESOURCE_CONFLICT / 409.
    • The notice now names the clone's own switch (its status, through 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.
    • The old pin asserted the notice contains toggle, which pinned the prescription. It now asserts status: 'obsolete' and PUT /api/v1/automation/ (red against the old notice, ablation M5).
  6. IAutomationService.toggleFlow's docblock (packages/spec/src/contracts/automation-service.ts, published in dist/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 d7eb865aa

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

package test typecheck
service-automation Test Files 157 passed (157), Tests 1974 passed (1974); base 0d9349fea read 156 / 1964 exit 0; --listFiles counts the new pin file once in tsconfig.json and once in tsconfig.test.json
spec 576 passed (576), 16991 passed, 1 todo exit 0
client 50 passed (50), 641 passed (641) exit 0
runtime 290 passed (290), 4200 passed, 1 skipped exit 0
dogfood the two toggle suites, automation-toggle-tenant-scope and packaged-activation-ledger-reach: 2 passed, 19 passed

Derived gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived on the real diff: 111 commands. All 111 ran, and --ran reads: "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)".

  • 110 exit 0.
  • Three of those first exited 3 (PREREQUISITE NOT MET, no dist): check:skill-examples, check:dual-build-cjs-loads and check:type-check-debt. They exit 0 after a full packages build (turbo run build, 71/71 tasks).
  • One exit 1, by design: 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.mjs
  • pnpm --filter @objectstack/spec run check:meta-url-spelling
  • pnpm --filter @objectstack/spec run check:spec-changes
  • pnpm check:authz-resolver
  • pnpm check:error-code-casing ("no unlisted lowercase error codes in 7012 scanned file(s)")
  • pnpm check:filter-alias-parity
  • pnpm check:route-ledger-census

Extra, each exit 0:

  • pnpm check:durability-log-level
  • pnpm check:startup-registry-verdict
  • node scripts/check-changeset-no-major.mjs --base origin/main --event with a synthetic pull_request payload carrying this body: "LEVEL AXIS: this PR declares clause-② no (narrowing), and it grades a package whose packages/**/src/** it moves at minor or above"
  • check-adr-0087-registration with 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 --fix of content/docs/references/**.

Lint, a declared narrowing. eslint --no-inline-config --format json over the 21 changed files: 21 files, 0 errors, 7 warnings. Every warning is "File ignored because no matching configuration was supplied", on the .md / .mdx files.

  • The population is eslint.config.mjs's files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'].
  • The config never enables type-aware linting: it says so itself, with no parserOptions.project and 0 hits for projectService. So this diff cannot move a verdict on an untouched file.

Bytes. pnpm check:nul-bytes exit 0, and a control-byte self-scan of the 21 files found nothing.

NOT MEASURED, and why:

  • The six CI invocations whose argv comes from the workflow: check-issue-citations --census, three check-shard-attestation --emit and two check-test-completeness.
  • The dogfood suite beyond the two toggle files. The required Dogfood Regression Gate runs it.
  • CI on this PR, not awaited.

Acceptance notes

  • carrier: none. A boundary, measured at the engine seam: a customer flow that a ledger row already holds off stays off. No status clears the row, and no door clears it now. The refusal names the clone step. Whether the door should clear such a row, or the consult points should ignore rows for flows without package provenance, is the dev report's open question 1. Noted, not filed.
  • carrier: none. A boundary, read from source and not measured live: the named update door re-registers in process. PUT /automation/NAME persists 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.
  • carrier: none. Drift: the route ledger row for POST /automation/:name/toggle is out of date (packages/runtime/src/route-ledger.ts). It still describes toggleFlow as 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.
  • carrier: none. Dead code: refuseEnableOntoDisabledSubflow's early return for a non-packaged flow is now unreachable through toggleFlow, its only caller. It is harmless, and left in place. Noted, not filed.
  • The 17.3 release notes' description of client.automation.toggle stays as published (content/docs/releases/** is release-owned).

Patch round 1 (appended by the domain:services seat)

  • Why: the at-tier contract review on d7eb865a (record 5904799342) FAILed on one clause. The clone-notice rider asserted that the clone's source is packaged ("such as the one it was copied from"), but POST /:name/clone takes any registered flow as its source.
  • The fix: 84334191, one fast-forward commit (2 files, +5 / −4). The claim about the source is dropped from FLOW_CLONE_NOTICE, its docblock and .changeset/20726-clone-notice-status-switch.md. The prescription (the clone's own status, through PUT /api/v1/automation/NAME) is unchanged.
  • Readings at 84334191:
    • the clone-notice pin file: 18 passed;
    • @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-authoring among them), and the one exit 1 is check-empty-changeset on the confirmed DELIBERATE CORRECTION of the 20678 note, unchanged.
  • The head moved, so the at-tier record is re-taken on 84334191 before enqueue.

Generated by Claude Code

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

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/client, @objectstack/runtime, @objectstack/service-automation, @objectstack/spec, touching 16 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-automation/README.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/ai/actions-as-tools.mdx (via /api/v1/automation (route, a path literal in FLOW_CLONE_NOTICE), /api/v1/automation/:name (route, a path literal in a comment on a changed line))
  • content/docs/api/declarative-endpoints.mdx (via /api/v1/automation (route, a path literal in FLOW_CLONE_NOTICE), /api/v1/automation/:name (route, a path literal in a comment on a changed line), /api/v1/automation/:name/trigger (route, bridged from symbol toggleFlow — its route source's handler names it))
  • content/docs/api/error-catalog.mdx (via RESOURCE_CONFLICT (literal, a string literal in refuseCustomerAuthoredToggle))
  • content/docs/api/error-handling-server.mdx (via RESOURCE_CONFLICT (literal, a string literal in refuseCustomerAuthoredToggle))
  • content/docs/automation/approvals.mdx (via /api/v1/automation (route, a path literal in FLOW_CLONE_NOTICE), /api/v1/automation/:name (route, a path literal in a comment on a changed line))
  • content/docs/automation/connectors.mdx (via /api/v1/automation (route, a path literal in FLOW_CLONE_NOTICE), /api/v1/automation/:name (route, a path literal in a comment on a changed line))
  • content/docs/automation/flows.mdx (via /api/v1/automation (route, a path literal in FLOW_CLONE_NOTICE), /api/v1/automation/:name (route, a path literal in a comment on a changed line), /api/v1/automation/:name/trigger (route, bridged from symbol toggleFlow — its route source's handler names it))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/automation (route, a path literal in FLOW_CLONE_NOTICE), /api/v1/automation/:name (route, a path literal in a comment on a changed line), /api/v1/automation/:name/trigger (route, bridged from symbol toggleFlow — its route source's handler names it))
  • content/docs/ui/actions.mdx (via /api/v1/automation (route, a path literal in FLOW_CLONE_NOTICE))

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

  • content/docs/releases/v15.mdx (via /api/v1/automation (route, a path literal in FLOW_CLONE_NOTICE), /api/v1/automation/:name (route, a path literal in a comment on a changed line))
  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), IAutomationService (symbol, a top-level interface))
  • content/docs/releases/v17/17-1.mdx (via /api/v1/automation (route, a path literal in FLOW_CLONE_NOTICE), /api/v1/automation/:name (route, a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-3.mdx (via automation.toggle (sdk, the route ledger binds it to POST /automation/:name/toggle), /:name/toggle (route, a path literal in a comment on a changed line), /api/v1/automation (route, a path literal in FLOW_CLONE_NOTICE), /api/v1/automation/:name (route, a path literal in a comment on a changed line), /api/v1/automation/:name/toggle (route, a path literal in a comment on a changed line), /automation/:name/toggle (route, a path literal in a comment in automation))
  • content/docs/releases/v17/17-5.mdx (via /api/v1/automation (route, a path literal in FLOW_CLONE_NOTICE))

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
  • 1 changed file(s) yielded no anchor (packages/services/service-automation/README.md) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: /automation/:name (route, 36 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 144 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 085ca6bc1c446e6484713823ce92284b4ec0ad54 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ed9aa7da373c40c312bc32ca69c9699849d7b5c0 — the merge of head 843341912c6e7744d0d0d1f71b81011fcbf64c3c into base 085ca6bc1c446e6484713823ce92284b4ec0ad54, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d7eb865aa51fa5f95dddb8f24a7f04705e144ef9
Local-runs: none

Inputs: card #20726 (body; triage direction 5901337034, option 1; claim 5903568667; dev report 5904536465; seat ACCEPT 5904580950), PR #20780 (body, file list, net diff against main, read from refs/review/pr-20780 fetched into the shared object store; merge base 0d9349fea; main tip at the reading 1bcba27d2, no file overlap with this diff), and the head's check-runs. Nothing built, run or re-run.

Checks on the head, read at 2026-09-30T05:31Z: 35 check-runs, one per name, none in progress. 32 success (all seven required contexts among them: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard), 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in)), 1 failure: Check Changeset. It is the only non-success, non-skipped check. Its signature (job log, run 36672082792): step Reject an empty-frontmatter changeset added by this PR, first error line .changeset/20678-subflow-disable-sequence.md exists on the merge base and was not added by this PR — the foreign-changeset rule's DELIBERATE CORRECTION class (scripts/check-empty-changeset.mjs; route 0 in .github/workflows/pr-automation.yml). Red by design; confirmed below.

① Derived judgments

Read 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.

  1. Refusal discriminator — right. refuseCustomerAuthoredToggle returns early on describeFlowContender(flow).source === 'package', i.e. isCodeArtifactBody (ADR-0029 D9.6: falsy _packageId, the sys_metadata sentinel and _provenance: 'org' are all non-package). It is the discriminator the §7.3 guards already ask (engine.ts lines 4999, 4945, 4747); no second reading. So the three customer shapes pin 1 drives (no envelope, sentinel, app-bound tenant row) are refused for provenance, not for an empty id. The premise widening over the card's "empty package id" mechanism is a measurement, and it is the right one.
  2. Placement — right. In toggleFlow at head: unknown-flow check, then the refusal (line 5263), then the §7.3 enable guard / disable guard, then flowActivationStore.setActive, then the flowLedgerDisabled mutation and the trigger (de)activation. The refusal is synchronous, so the disable guard's run-store read is never awaited for a customer flow. The rest of toggleFlow is untouched by the diff.
  3. Code — right. RESOURCE_CONFLICT / 409, an ADR-0112 envelope with both code and status; a standard-catalog member, no ledger entry minted; the same code this door already answers for the §7.3 enable guard (line 5012), so one dialect. Not a 400 (nothing malformed), not a 404 (the flow exists).
  4. Text — right. Says the switch turns packaged flows on and off; names the flow's own switch, its definition's status, through PUT /automation/NAME with the complete definition, 'obsolete' off / 'active' on; ends "Nothing was changed." Carries no tracker number (an ADR cite only). Nit, not a defect: on a disable request for a flow a ledger row already holds off, the branch still says "To run this flow, clone it…" — true, merely odd for that direction.
  5. "Nothing is written" — right. The throw precedes setActive, the in-process set and the trigger unbind, with or without a store. Pin 1 asserts setActive never called, store.list() unchanged, the /_status row unchanged, the trigger still bound and execute still running; the degraded-mode case asserts the IN PROCESS ONLY warning is never reached.
  6. Packaged flows unchanged — right. Early return on package provenance; pin 2 (row written, disarmed, FLOW_DISABLED, re-enabled, re-armed) is the control. Boundary, accepted: a packaged flow SHADOWED by a sys_metadata or org row registers as the shadowing body (flow-precedence.ts: runtime beats package), so the door now refuses that name. ADR-0126 §2 / §6.1 put an overlay read path for a behavioural type out of the model, so the refusal is consistent with the ledger's own scope ("the package that ships the base artifact"); "exactly as before" holds for every un-shadowed packaged flow.
  7. Legacy-ledger-row branch naming the clone step — right. hydrateFlowActivations adds every inactive row's name to flowLedgerDisabled with no provenance filter, and isFlowEnabled is status AND ledger, so a row written under a customer flow's name before this change (the sentinel / app-bound shapes the door used to accept) holds it off and no status clears it — the dev's engine-seam measurement reads true from source. For that state alone the refusal names POST /automation/NAME/clone (the envelope is dropped on clone, so the copy carries no row and arms), the same step the FLOW_DISABLED refusal already names (line 4685). Still no write. Beyond triage's letter, inside its rule: refuse, don't write, name a step that completes.
  8. Fixture triage — right. Ten cases in six files gain _packageId: 'crm' where the subject was a vehicle for toggle semantics. The two §7.3 non-packaged pins are re-spelled truthfully: the customer caller is refused by the door before §7.3 (message names no subflow) and armed by its status; the customer subflow is switched off by status: 'obsolete' and still does not guard the packaged caller's re-enable.
  9. README — right. The published example no longer toggles an in-process-registered customer flow (the refused call); it re-registers with status: 'obsolete' (the door pin 3 drives) and shows the toggle on a packaged flow; the route table line says packaged flow, customer flow via PUT.
  10. Clone-notice rider (packages/runtime) — prescription right, one clause WRONG. The old notice told the admin to switch the clone off through the toggle; a clone carries no envelope (FLOW_CLONE_DROPPED_KEYS), so the door now refuses it — the rider was owed, and its prescription (status: 'obsolete' in the complete definition to PUT /api/v1/automation/NAME) is right; the pin asserts that. But the notice goes on: "switches packaged flows only, such as the one it was copied from, and refuses the clone." That asserts the clone's source is a packaged flow. The clone door (POST /:name/clone in runtime/src/domains/automation.ts) takes any registered flow as its source — automationService.getFlow(name), 404 if absent, no provenance predicate — and the notice is returned on every successful clone. So for a clone of a customer-authored flow the notice tells the caller a false thing about their own flow. The flow-clone.ts docblock states the same premise as fact ("which is the packaged one") and the runtime changeset repeats it ("such as the flow the clone was copied from"). The claim is wider than the enforcement; the seat accepted the rider on that unmeasured premise. This is the FAIL — see ③, remedy there.
  11. Docs, regenerated not hand-edited — right. content/docs/references/api/automation-api.mdx matches the automation-api.zod.ts module docblock line for line; Type Check · source gates runs pnpm --filter @objectstack/spec check:docs (lint.yml:5580) and is success on this head, so the page equals the generator's output. The new paragraph carries no tracker number.
  12. packages/spec/src/** non-test faces — right, and public surface unchanged. The module docblock (toggle line reads "Enable/disable a packaged flow"; the new paragraph names the ledger, the status switch via PUT /api/v1/automation/:name and the 409) and the IAutomationService.toggleFlow docblock ("Enable or disable a PACKAGED flow…", switch published through registerFlow) are prose; no schema, type, export or accepted key moves, so api-surface and authorable-surface are untouched by construction.
  13. client.automation.toggle docblock — right. Moved back onto toggle from above listActions; names the 409 and automation.update(name, definition), which is PUT ${route}/${name} at line 5466.
  14. Runtime route docblocks — right. Comment-only, unpublished (0 hits in dist, the dev's control phrase hit); no changeset owed for them.
  15. The DELIBERATE CORRECTION, .changeset/20678-subflow-disable-sequence.md — confirmed, every rewritten sentence judged. It is the @objectstack/service-automation minor note for the §7.3 enable-direction guard, added by 679f95ec5, still pending on the main tip, same package as this PR's breaking note, so both land in one release. One bullet under "Not refused:" moves:
    • OLD "A flow the customer authored, or a subflow the customer authored." — its first half ("enabling a flow the customer authored is not refused") is made FALSE by this diff: the door refuses that flow before the guard is asked. Its second half ("a subflow the customer authored") stays true. Restoring the base text would republish the false half.
    • NEW "A subflow the customer authored." — TRUE: refuseEnableOntoDisabledSubflow → disabledPackagedSubflows skips a child whose source is not package (line 4945); pinned by the re-spelled test.
    • NEW "A flow the customer authored is not this switch's to enable at all:" — TRUE: refused in both directions.
    • NEW "the activation switch switches packaged flows only," — TRUE: item 1.
    • NEW "and it refuses a customer-authored flow for that reason before this guard is asked" — TRUE: line 5263 precedes line 5273.
    • NEW "(see the entry "the toggle door refuses a flow no package ships, naming its status switch")." — TRUE: that is the title line of .changeset/20726-toggle-door-packaged-only.md, same package, same CHANGELOG.
      Nothing else in the note is touched, and its remaining sentences (all about packaged flows) stay true. No frontmatter or level change.

② Semver level

  • Clause-②: no (narrowing) — right, read on the diff, not the claim. Nothing widens: no export, member, key or accepted input is added (the one new method is private; the spec/client edits are docblocks; FLOW_CLONE_NOTICE keeps its type). The accept set narrows three ways: a customer flow with a non-empty package id (sentinel / app-bound) was accepted and is refused; with no ledger the in-process flip was accepted and is refused; the no-id case changes refusal, 400 VALIDATION_FAILED → 409 RESOURCE_CONFLICT.
  • @objectstack/service-automation minor, **BREAKING** banner, Clause-② line, migration (FROM POST …/toggle TO PUT …/NAME with status; SDK toggle(name, false) becomes update(name, { …definition, status: 'obsolete' })), and exactly one marker adr-0087: not-required (no-migration-prescription) … — right. Launch-window convention (major refused by check-changeset-no-major); the category is in the closed vocabulary; the why is true (no metadata changes shape, nothing an author wrote is renamed or removed; objectstack migrate meta has nothing to rewrite); and it is the disposition the direct precedent on this same door already carries on main (the 20678 note). Static reading of findMigrationPrescription over this body: no uppercase FROM/TO label, no →/-> rewrite pair between code operands, no migration-framed heading, no table — the detector returns null, so the gate's contradiction check would not refuse it. Flag: the body's **Migration.** section is a source-code-consumer prescription, which ADR-0087 says is not the ledger's business, and the CI step that would confirm this marker never ran on this head (③).
  • @objectstack/spec patch — right (docblock prose in shipped src/**/*.zod.ts and dist/contracts/*.d.ts; no surface change). @objectstack/client patch — right (docblock in dist). @objectstack/runtime patch — level right (an exported constant's value moves; response shape unchanged), content faulted in ①-10 and ③.
  • check-changeset-no-major's LEVEL AXIS on a no (narrowing): one package moved under packages/**/src/** graded minor accounts for the declared narrowing and the three patch grades are not refused (its discharged branch); no major anywhere. Per AGENTS.md post-task checklist 3 and scripts/pm/clause2-line.mjs (value first after the colon, arm from the closed pair), the declaration reads declared / no / narrowing on line 2 of the PR body and in the breaking note's body.

③ Boundary flags

Dev flags and open_questions, each answered or escalated:

  • Q1, legacy-row-held customer flow (seat: A, keep as delivered) — stands. Verified at source (①-7). The refusal is loud, names a completable step, writes nothing. B (an enable-only writer for legacy rows) and C (consult points ignoring rows for non-package flows, re-arming at upgrade) re-open "refuse, don't write" for a population nobody has named. Acceptance note, as the seat filed it.
  • Q2, PUT /automation/NAME is not durable for metadata-plane flows (seat: A here, defect to the door's owner) — stands. The route calls registerFlow in process; that is a property of the update door, pre-existing, and the population it bites is exactly the one this narrowing newly refuses, so the cross-lane notice to access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679's seat (the automation write-door parity class) is the right routing; extending this refusal's text to a metadata-plane door nobody measured would be consumer-side tolerance.
  • Q3(a), the DELIBERATE CORRECTION (seat: A, keep) — CONFIRMED by this record, per ①-15: the note is named, each old sentence judged made-false or kept, each new sentence judged true. Check Changeset stays red by design (route 0: no skip-changeset, the red is the point). Landing over it is the seat's red-landing conditions, not this record's.
  • Q3(b), the clone-notice rider (seat: A, keep) — stands for the prescription, does not stand for the clause. ①-10. The seat's checklist condition for an out-of-scope adjacent fix — same defect class, mechanical, unclaimed, same gate family — holds; what fails is the four-word premise nobody measured. Remedy, prose only, no mechanism: in FLOW_CLONE_NOTICE drop "such as the one it was copied from" (read: "…switches packaged flows only, and refuses the clone."); drop "which is the packaged one" from the flow-clone.ts docblock; drop "such as the flow the clone was copied from" from .changeset/20726-clone-notice-status-switch.md. The runtime pin asserts only status: 'obsolete' and PUT /api/v1/automation/, so it stays green. A new head, then a new record on it.
  • Other declared deviations: the private helper (fine; one caller); fixture triage (①-8, right); the README (①-9, right); the IAutomationService docblock (①-12, right, a review face); one --force-with-lease before the PR existed on an unshared branch and one probe outside the verify lock — outside this record's faces, noted as the seat noted them.
  • Out-of-scope findings: legacy rows stay off (Q1, accepted); PUT not durable (Q2, routed); route-ledger.ts's stale note for POST /automation/:name/toggle (routed to runtime: POST /api/v1/automation/:name/clone is not mounted on the HTTP server — every flow clone, from the API and from the Setup packaged-automation page, answers 404 ENDPOINT_NOT_FOUND #20676's seat, whose PR fix(runtime): mount POST /automation/:name/clone on the dispatcher bridge, at both bases #20779 edits that file — right, this card must not touch it); refuseEnableOntoDisabledSubflow's non-package early return now unreachable through its only caller (harmless, accepted).
  • Reviewer flag — CI reading gap on this head. In the Check Changeset job the failing step precedes Require an ADR-0087 disposition on a declared-breaking changeset and the check-changeset-no-major guard, and neither carries if: always(); the job log ends at the foreign-changeset refusal and post-job cleanup. lint.yml runs only their self-tests. So on this head neither gate has a CI conclusion; the dev's readings are local runs with a synthetic payload, and this record's ② readings are static. That is a property of the workflow for every DELIBERATE CORRECTION PR (the precedent PR fix(service-automation)!: a switched-off packaged caller guards its subflow's disable only while it holds a parked run #20724 landed the same way) — a tooling finding for the seat to file, not a defect of this diff.
  • Reviewer flag — accepted boundary: the overlay-shadowed packaged flow (①-6).
  • Reviewer nit: the ledger-held branch's wording on a disable request (①-4). No action.

Implemented-by: claude/issue-20726-toggle-door-packaged-only
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 843341912c6e7744d0d0d1f71b81011fcbf64c3c
Local-runs: none

A re-record on a new head. Record 5904799342 FAILed head d7eb865 on one clause (the clone-notice rider asserted the clone's source is packaged). A record covers only the head it names and the head moved, so the whole diff is re-judged here; where the delta d7eb865..8433419 touches nothing, the earlier record's judgments are carried forward, each re-read against this head.

Inputs: card #20726 (body; triage direction 5901337034, option 1; claim 5903568667; dev report 5904536465; seat ACCEPT 5904580950; patch-round report 5905204497), PR #20780 (the patched body, the 21-file list, the net diff against main, the delta d7eb865..8433419, and record 5904799342), and this head's check-runs. The branch was fetched into an owned ref, refs/review/pr-20780-r2, which resolves to the sha above; the PR's head on GitHub equals it. Merge base with main: 0d9349fea. The main tip at the reading, 4b4ee88fb (fetched into refs/review/main-at-r2), has moved 87 files since the merge base, none of them in this PR's file list. Nothing built, run or re-run.

The delta d7eb865..8433419, verified: ONE commit, 2 files, +5 / −4: packages/runtime/src/flow-clone.ts (three docblock lines and one line of FLOW_CLONE_NOTICE) and .changeset/20726-clone-notice-status-switch.md (one sentence). It touches nothing else: not engine.ts, not a pin, not .changeset/20678-subflow-disable-sequence.md, not the spec, client or README faces. The seat patched the PR body in the same round (item 5's third bullet under "Outside the claim's declared file surface", plus an appended "Patch round 1" section); the body's "Local verification" section is labelled as the round-1 reading, and the round-2 readings sit in the appended section.

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 pr-automation family re-ran on the body edit. Latest run per name: 30 success, 4 skipped, 1 failure. All seven required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Skipped: Console Pin Gate, Packed-tarball smoke (opt-in), and Auto Label / Check PR Size on the re-run only (both success on this head's first run; both advisory). The failure is Check Changeset, on both of its runs (jobs 109756203212 and 109763460895), same signature: step Reject an empty-frontmatter changeset added by this PR, error line .changeset/20678-subflow-disable-sequence.md exists on the merge base and was not added by this PR, the DELIBERATE CORRECTION class of scripts/check-empty-changeset.mjs, which in the same run reports "No empty-frontmatter changeset introduced by this diff". Check Changeset is the only non-success, non-skipped check. Red by design; confirmed in ①-15.

① Derived judgments

Read against triage's option 1 (5901337034): refuse, don't write; name the customer flow's own switch; the door never rewrites a definition; packaged flows unchanged; the docs say which flows the door switches.

Carried forward from record 5904799342 (items 1 to 9 and 11 to 14): the delta touches none of their files, and each was re-read on this head.

  1. Refusal discriminator, right. refuseCustomerAuthoredToggle (engine.ts line 5194) returns early on describeFlowContender(flow).source === 'package' (line 5195). describeFlowContender (flow-precedence.ts) delegates to isCodeArtifactBody from @objectstack/metadata-core, the canonical ADR-0029 D9.6 test, and its own docblock forbids re-deriving it from _packageId. It is the discriminator the §7.3 guards ask (the same describeFlowContender(...).source reads at lines 4747, 4945 and 4999, unchanged since the previous record because the delta does not touch engine.ts). The three customer shapes pin 1 drives (no envelope, the sys_metadata sentinel, an app-bound _provenance: 'org' row) are refused for provenance, not for an empty id.
  2. Placement, right. In toggleFlow (line 5251): the unknown-flow check, then the refusal (line 5263), then the §7.3 enable guard (line 5273) and disable guard, then flowActivationStore.setActive (line 5292), then the in-process set and the trigger (de)activation. Synchronous, so the disable guard's run-store read is never awaited for a customer flow.
  3. Code, right. RESOURCE_CONFLICT / 409, an ADR-0112 envelope with code and status; a standard-catalog member, no ledger entry minted; the code this door already answers for the §7.3 enable guard, so one dialect. Not a 400 (nothing malformed), not a 404 (the flow exists).
  4. Text, right. Says the switch turns packaged flows on and off; names the flow's own switch, its definition's status, through PUT /automation/NAME with the complete definition, 'obsolete' off and 'active' on; ends "Nothing was changed." An ADR cite only, no tracker number. Nit, not a defect: on a disable request for a flow a ledger row already holds off, the branch still says "To run this flow, clone it".
  5. Nothing is written, right. The throw precedes setActive, the in-process set and the trigger unbind, with or without a store. Pin 1 asserts setActive never called, store.list() unchanged, the /_status row unchanged, the trigger still bound, execute still running; the degraded-mode case asserts the IN PROCESS ONLY warning is never reached.
  6. Packaged flows unchanged, right. Early return on package provenance; pin 2 is the control (row written, disarmed, FLOW_DISABLED, re-enabled, re-armed). Accepted boundary: a packaged flow SHADOWED by a sys_metadata or org row registers as the shadowing body (flow-precedence.ts: runtime beats package), so the door refuses that name; consistent with the ledger's own scope, and "exactly as before" holds for every un-shadowed packaged flow.
  7. The legacy-ledger-row branch naming the clone step, right. hydrateFlowActivations adds every inactive row's name to flowLedgerDisabled with no provenance filter and isFlowEnabled is status AND ledger, so a row written under a customer flow's name before this change holds it off and no status clears it. For that state alone the refusal names POST /automation/NAME/clone, the step the FLOW_DISABLED refusal already names. Still no write. Beyond triage's letter, inside its rule.
  8. Fixture triage, right. Ten cases in six files gain _packageId: 'crm' where the subject was a vehicle for toggle semantics. The two §7.3 non-packaged pins are re-spelled truthfully: the customer caller is refused before §7.3 (its message names no subflow) and armed by its status; the customer subflow is switched off by status: 'obsolete' and still does not guard the packaged caller's re-enable.
  9. README, right. The published example no longer toggles an in-process-registered customer flow; it re-registers with status: 'obsolete' and shows the toggle on a packaged flow; the route table line says packaged flow, customer flow via PUT.

10. The clone-notice rider (packages/runtime), RE-JUDGED on this head: the previous FAIL is cured in all three places, and every sentence the patch wrote is true whatever the clone's source.

  • The mechanism behind every sentence, read at this head. POST /:name/clone (packages/runtime/src/domains/automation.ts) takes its source from automationService.getFlow(name): 404 when absent, 409 on a taken target name, and no provenance predicate anywhere in the arm. cloneFlowDefinition(source, ...) then deletes every key in FLOW_CLONE_DROPPED_KEYS, which is the read decorations plus every MetadataProtectionFields key (_provenance, _packageId, _packageVersion, _lock, _lockReason, _lockSource, _lockDocsUrl), sets status: 'draft', and hands the copy to registerFlow; the route stamps no _packageId or _provenance of its own (0 hits in the file). So the clone reaches the engine with no package envelope regardless of what it was copied from (a packaged flow, a customer flow, a sentinel or app-bound row, another clone), isCodeArtifactBody reads it as non-package, and toggleFlow refuses it first (items 1 and 2), in both directions, with or without a ledger. The dev's dispatcher-seam measurement (the clone door answered 200, the clone carried no _packageId, its toggle answered 409) is consistent with the source.
  • FLOW_CLONE_NOTICE. OLD: "switches packaged flows only, such as the one it was copied from, and refuses the clone." The dropped clause asserted the source is packaged, which is false for a clone of a customer-authored flow. NEW: "The activation toggle (POST /api/v1/automation/NAME/toggle) switches packaged flows only and refuses the clone." TRUE for every clone: both halves hold by the mechanism above. The only "copied from" left in the string is the pre-existing "will run alongside the flow it was copied from", which claims nothing about provenance. The prescription sentence, "switch the clone off through its own status: send its complete definition with status: 'obsolete' to PUT /api/v1/automation/NAME", is unchanged and right: PUT /:name drives registerFlow with the complete definition, and status: 'obsolete' disarms (pin 3).
  • The flow-clone.ts docblock. OLD: "The toggle is named for the flow it was copied from, which is the packaged one." False as a general statement. NEW: "That holds whatever the clone was copied from: the clone door takes any registered flow as its source, packaged or not, so the notice makes no claim about the source's provenance." TRUE in each clause: the door's source read is getFlow(name) with no provenance test, and the notice text no longer names the source's provenance. The sentence before it, "A clone carries no package envelope (see FLOW_CLONE_DROPPED_KEYS), so it is a flow authored in this deployment, and the activation toggle, which switches packaged flows only, refuses it", is TRUE by the dropped-keys set.
  • .changeset/20726-clone-notice-status-switch.md. OLD: "It also says that the toggle switches packaged flows only, such as the flow the clone was copied from." NEW: "It also says that the toggle switches packaged flows only and refuses the clone, whatever flow the clone was copied from." TRUE: that is what the notice now says, and what it says is true. The rest of the note is unchanged from the previous head and stays right; "The response shape is unchanged; only the notice text moves" still holds (a string literal's value; the notice key and its type are unchanged).
  • Residue: the dropped clause survives only in the message of commit 42d7a9a, history rather than a published face, and landed history is not rewritten. A grep of the head tree over packages/runtime and .changeset for the three dropped phrasings finds none; every remaining "copied from" is either the pre-existing notice sentence or an ancestry comment in the test and the route.
  • The pin (automation-flow-clone.test.ts) asserts not re-pointed, off-switch, status: 'obsolete' and PUT /api/v1/automation/; none reads the dropped clause, so it still pins the prescription and stays green (Test Core is success on this head). The notice carries no tracker number (check:doc-authoring runs inside Lint & Repo Gates, success).
  1. Docs regenerated, not hand-edited, right. content/docs/references/api/automation-api.mdx matches the automation-api.zod.ts module docblock line for line; Type Check · source gates runs check:docs and is success on this head. The new paragraph carries no tracker number.
  2. packages/spec/src/** non-test faces, right; public surface unchanged. The module docblock (toggle line "Enable/disable a packaged flow"; the new paragraph names the ledger, the status switch via PUT /api/v1/automation/:name and the 409) and the IAutomationService.toggleFlow docblock are prose; no schema, type, export or accepted key moves, so api-surface and authorable-surface are untouched by construction.
  3. client.automation.toggle docblock, right. Moved back onto toggle from above listActions; names the 409 and automation.update(name, definition), which is PUT ROUTE/NAME.
  4. Runtime route docblocks, right. Comment-only and unpublished (0 hits in dist, the dev's control phrase hit); no changeset owed for them.

15. The DELIBERATE CORRECTION, .changeset/20678-subflow-disable-sequence.md, CONFIRMED on this head, every rewritten sentence judged. The delta d7eb865..8433419 does not touch this file, so its content at this head equals its content at d7eb865; on the main tip 4b4ee88fb the note is still pending and byte-identical to the merge base, so this is still a correction of a pending note and not a collision. It is the @objectstack/service-automation minor note for the §7.3 enable-direction guard, the same package as this PR's breaking note, so both land in one release. One bullet under "Not refused:" moves:

  • OLD "A flow the customer authored, or a subflow the customer authored." Its first half ("enabling a flow the customer authored is not refused") is made FALSE by this diff: the door refuses that flow before the guard is asked (line 5263 precedes line 5273). Its second half stays true. Restoring the base text would republish the false half.
  • NEW "A subflow the customer authored." TRUE: refuseEnableOntoDisabledSubflow returns for a non-packaged caller (line 4999) and its disabledPackagedSubflows walk skips a child whose source is not package (line 4945, the previous record's reading, unchanged because the delta does not touch engine.ts); pinned by the re-spelled test.
  • NEW "A flow the customer authored is not this switch's to enable at all:" TRUE: refused in both directions (pin 1, enabled: true cases).
  • NEW "the activation switch switches packaged flows only," TRUE: item 1.
  • NEW "and it refuses a customer-authored flow for that reason before this guard is asked" TRUE: line 5263 precedes line 5273.
  • NEW "(see the entry "the toggle door refuses a flow no package ships, naming its status switch")." TRUE: that is the title line of .changeset/20726-toggle-door-packaged-only.md, same package, same CHANGELOG.
    Nothing else in the note is touched; its remaining sentences, all about packaged flows, stay true. No frontmatter or level change. Check Changeset red on it is the point of the gate's DELIBERATE CORRECTION class, and it must not be restored from base.

② Semver level

  • Clause-②: no (narrowing), right, read on the diff and unchanged by the delta. Nothing widens: no export, member, key or accepted input is added (the one new method is private; the spec and client edits are docblocks; FLOW_CLONE_NOTICE keeps its type and the delta moves only its value). The accept set narrows three ways: a customer flow with a non-empty package id (sentinel or app-bound) was accepted and is refused; with no ledger the in-process flip was accepted and is refused; the no-id case changes refusal, 400 VALIDATION_FAILED to 409 RESOURCE_CONFLICT. The declaration reads no with the (narrowing) arm from the closed pair on line 2 of the PR body and in the breaking note's body, the single spelling scripts/pm/clause2-line.mjs reads.
  • @objectstack/service-automation minor with the **BREAKING** banner, the Clause-② line, the migration (FROM POST .../toggle TO PUT .../NAME with status; SDK toggle(name, false) becomes update(name, { ...definition, status: 'obsolete' })) and exactly one marker adr-0087: not-required (no-migration-prescription), right. Launch-window convention (major refused); the category is in the closed vocabulary; the why is true (no metadata changes shape, nothing an author wrote is renamed or removed); it is the disposition the direct precedent on this same door already carries on main (the 20678 note). Static reading of the body: no uppercase FROM/TO label, no arrow rewrite pair between code operands, no migration-framed heading or table, so the gate's contradiction check would not refuse it. The CI step that would confirm the marker still has no conclusion on this head (③).
  • @objectstack/spec patch, right (docblock prose in shipped src/**/*.zod.ts and dist/contracts/*.d.ts; no surface change). @objectstack/client patch, right (docblock in dist). @objectstack/runtime patch, right in level and now right in content (an exported constant's value moves; response shape unchanged; the note's one false clause is gone, ①-10).
  • check-changeset-no-major's LEVEL AXIS on a no (narrowing): one package moved under packages/**/src/** graded minor accounts for the declared narrowing and the three patch grades are not refused; no major anywhere.

③ Boundary flags

Dev flags and open_questions, each answered or escalated; the patch round raised none.

  • Q1, a customer flow a legacy ledger row holds off (seat: A, keep as delivered): stands. Verified at source (①-7): loud, names a completable step, writes nothing. B and C re-open "refuse, don't write" for a population nobody has named. Acceptance note, as filed.
  • Q2, PUT /automation/NAME is not durable for metadata-plane flows (seat: A here, defect to the door's owner): stands. A property of the update door, pre-existing; extending this refusal's text to a door nobody measured would be consumer-side tolerance. Routed by the seat in its cross-lane notice.
  • Q3(a), the DELIBERATE CORRECTION (seat: A, keep): CONFIRMED by this record on this head, ①-15. Check Changeset stays red by design (route 0: no skip-changeset; the red is the point). Landing over it is the seat's red-landing conditions, not this record's.
  • Q3(b), the clone-notice rider (seat: A, keep): stands in full now. The prescription stood on the previous head; the one false clause is dropped in all three places and every sentence written in its place is true whatever the clone's source (①-10). Nothing else was owed.
  • Reviewer flag, CI reading gap, still on this head. In the Check Changeset job the failing step precedes Require an ADR-0087 disposition on a declared-breaking changeset and Guard against accidental major bumps (launch window), neither carries if: always(), and both job logs end at the foreign-changeset refusal and post-job cleanup; pr-automation.yml is unchanged on main since the merge base. So on this head neither gate has a CI conclusion; the dev's readings (both rounds) are local runs with a synthetic payload and this record's ② readings are static. A property of the workflow for every DELIBERATE CORRECTION PR; a tooling finding for the seat to file, not a defect of this diff.
  • Reviewer flag, accepted boundary: the overlay-shadowed packaged flow (①-6).
  • Reviewer nit: the ledger-held branch's wording on a disable request (①-4). No action.
  • Other declared deviations, unchanged from the previous head: the private helper (one caller); fixture triage (①-8); the README (①-9); the IAutomationService docblock (①-12); one --force-with-lease before the PR existed on an unshared branch; one probe outside the verify lock. The patch round's one deviation, re-creating the worktree on the existing local branch after checking both branch tips stood at d7eb865, is outside this record's faces and is noted as the dev noted it. The dev's STALE TREE caveat (main ahead of the merge base; the order allowed one commit and no rebase) is answered by the check-runs, which ran on the merge ref, and by the file overlap read above (none).
  • Out-of-scope findings, unchanged: legacy rows stay off (Q1, accepted); PUT not durable (Q2, routed); route-ledger.ts's stale note for POST /automation/:name/toggle (routed to the seat whose open PR edits that file; this card must not touch it); refuseEnableOntoDisabledSubflow's non-package early return now unreachable through its only caller (harmless, accepted).
  • No governed path is on the file list; the at-tier record is owed by the claim's own path limb (packages/spec/src/**, non-test), and this is it. The PR is a draft; readying, arming and the red-landing conditions on Check Changeset are the owning seat's acts.

Implemented-by: claude/issue-20726-toggle-door-packaged-only
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landing with one red check, by design · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-09-30T06:23Z

The gate: Check Changeset (.github/workflows/pr-automation.yml, job changeset-check). It is the only non-success, non-skipped check on 84334191 (latest run per name).

  • The seven required contexts are green.
  • Auto Label and Check PR Size are skipped on the body-edit re-run, and both were success on this head's first run.

The reason: this PR corrects .changeset/20678-subflow-disable-sequence.md. That is a PENDING release note for the same package, and it exists on the merge base.

  • The first half of one bullet under "Not refused" ("A flow the customer authored") is made false by this diff, because the toggle door now refuses a customer-authored flow before the §7.3 guard is asked.
  • That is the DELIBERATE CORRECTION class of scripts/check-empty-changeset.mjs's foreign-changeset rule. By that gate's own design it stays red for this class (route 0 in the workflow).

The confirmation: the at-tier contract review PASS on this same head, 5905383996. It follows the FAIL 5904799342 on the previous head, whose one clause the patch round removed.

  • It names the note and judges each sentence: the old sentence's first half is made false by the diff, and each of the five new sentences is true of the delivered behaviour.
  • It also verifies that the patch-round delta does not touch this note.

The three conditions for landing with it red, each met:

  1. The gate's source says it is red by design on the pushed branch: pr-automation.yml route 0, and check-empty-changeset.mjs's DELIBERATE CORRECTION remedy.
  2. It does not run on merge_group. pr-automation.yml triggers on pull_request only (its on: block, read on main at this landing).
  3. This comment records the gate and the reason.

Also recorded: that job stops at this step. So neither the ADR-0087 disposition step nor the launch-window major guard ran in CI on this head.


Generated by Claude Code

Merged via the queue into main with commit c8111a5 Sep 30, 2026
42 of 44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20726-toggle-door-packaged-only branch September 30, 2026 06:53
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/l tests tooling

Projects

None yet

2 participants