fix(spec): refuse decision mode beside a non-empty conditions list (#20168) - #20279
objectstack-fleet[bot] merged 4 commits into
Conversation
…20168) Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…ontrols; changeset (#20168) Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…inements ledger (212 schemas, 605 sites) (#20168) Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…ode describe (#20168) Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 136 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 f2d1cfb2fb54c7a8e92fbb20d3e588df28dd950e && git checkout f2d1cfb2fb54c7a8e92fbb20d3e588df28dd950e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 ae8185032a188422643476a3861d6d9e9a8d2c2f && git checkout -B drift-repro 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 && git merge --no-ff ae8185032a188422643476a3861d6d9e9a8d2c2f
node scripts/docs-audit/affected-docs.mjs --json 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 |
Contract reviewServed-tier: Read: card #20168 body and all 6 comments (premise refresh 5852968135, ruling 5856786357, hold 5856799823, confirmation 5856865124, claim 5857419814, dev report 5858409074); PR #20279 object, body, 5-file list, 4 commits, full diff vs merge-base e462186, thread (1 advisory bot comment, 0 reviews); check-runs on the head at 18:33Z and 19:08Z; at the head: schemaless-node-config.zod.ts, its test, strict-object.ts, lazy-schema.ts, refinement-projection.ts, dropped-refinements.baseline.json + scripts/dropped-refinements.test.ts + build-schemas.ts, engine.ts validateNodeConfigKeys, logic-nodes.ts, flow.zod.ts, reference-sites.ts, the regenerated mdx, both changesets, the changeset-gate headers and pr-automation.yml WHICH LEVEL prose; PR #20251 diff; PR #17076 state; npm dist-tags and the 17.4.0 tarball. Ran (detached worktree at the head, pnpm install --frozen-lockfile, every build/test through os-verify-lock.sh): target test + a 17-case probe (51/51), four ablations with proven restores, OS_SKIP_DTS=1 spec build, check:docs, a merge-tree of the two ledger PRs, a ledger recount at base/head/joint. NOT MEASURED: the objectui designer form (sibling not checked out, objectui#10750); full pnpm test/typecheck, check:dual-build-cjs-loads and check:type-check-debt locally (CI green at the head, not re-run); no derived gate family re-run. ① Derived judgments
② Semver levelRe-measured 2026-09-27T18:33Z: npm The pending #19867 bullet "A ③ Boundary flagsBlocking: none CI at this head: 35 check-runs, all Implemented-by: VERDICT: PASS |
…ull — at all three doors (objectstack-ai#19961) (objectstack-ai#20315) Fixes objectstack-ai#19961 Clause-②: no (narrowing) A `decision` branch with no `expression` (the key absent, or `expression: null`) is now refused at all three doors: `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate`. It goes through the same walk, the same function and the same lead sentence that already refuse a blank branch predicate (objectstack-ai#17493 / PR objectstack-ai#19960). ## What was wrong, measured on `origin/main` `a9fb83ef` `DecisionConditionSchema` declares a branch `{ label, expression }` with `expression` a required `z.string()`. Nothing parses a decision node's open `config` against that schema. The expression ledger's resolver also skipped an absent value as "not authored". So the build accepted a branch that the run refuses. | branch | `FlowSchema.parse` | `registerFlow` | `objectstack validate --json` | |:--|:--|:--|:--| | `{ label: 'y' }` (the card's shape) | accepted | registered | `valid: true`, exit 0 | | `{ label: 'y', expression: null }` | accepted | registered | `valid: true`, exit 0 | | `{ label: 'y', condition: 'true' }` (the edge's spelling) | accepted | registered | `valid: true`, exit 0 | | `{ label: 'y', expression: ' ' }` (control, objectstack-ai#19960) | refused, `custom` at `nodes.1.config.conditions.0.expression` | refused, same issue | `valid: false`, exit 1, same path | | `{ label: 'y', expression: 'true' }` (control) | accepted | registered | `valid: true`, exit 0 | What the run did with it: `evaluateCondition({ dialect: 'cel', source: undefined })` and `source: null` both throw `condition evaluation error: A structural condition …`. On the real decision executor, a run that reaches such a branch ends `success: false` at the branch (pinned below). ## The fix: one walk, one judge - `packages/spec/src/automation/flow-node-expression-paths.ts` - `FlowNodeExpressionPath` gains `required?: true`. It is set on `decision` `conditions[].expression` and on nothing else. - For a `required` predicate slot, `resolveFlowNodeExpressions` now emits the absent or `null` value on a branch that exists. It still skips a decision with no `conditions`, an empty list, and an absent screen `visibleWhen`. - `predicateSlotRefusal(undefined | null)` now has its own detail sentence and prescription, under the unchanged `PREDICATE_SLOT_STRING_REFUSAL` lead. - `packages/spec/src/automation/flow.zod.ts`: the `FlowSchema` predicate-slot refinement now admits the absent or `null` value of a `required` slot, next to strings. Every other non-string keeps its objectstack-ai#15572 scope, so it is still not refused at this door. - `packages/lint/src/validate-expressions.ts`: `checkDeclaredPredicate` dropped its `raw == null` early return. Whether an absent value is a finding is the resolver's call. The early return answered "valid" for the exact value `FlowSchema.parse` refuses, for any caller of `validateStackExpressions` that does not parse first. This site is outside the claim's file surface. The measurement put the third door's refusal there (ablation B below). - `engine.ts`: no change. Its ledger pass already calls `predicateSlotRefusal` on everything the resolver emits, and `registerFlow` parses first, so the parse answers first. That is the same two-layer shape the blank has. **The route choice (Zone 2 item 3).** I chose (a), the predicate-slot walk treating an absent `expression` as a refused slot. I did not choose (b), parsing each branch against `DecisionConditionSchema`. Reasons, per axis: - Business need, measured: the only named producer is objectui's `rowsToList`, which writes `{ label }`. The absent key is the whole defect. - Long-term design: (a) keeps one judge (`predicateSlotRefusal`) and one walk for the three doors. (b) would be a second judge with Zod's own messages, a different prescription at each door, and a key-set closure on branches that nobody ruled. - Guarding AI authors: both refuse loudly. (a) also names the `condition` alias mistake in the same prescription. - No scope growth: (a) touches one ledger entry. (b) would narrow a much wider accept set, including unknown branch keys and the whole branch shape. `DecisionConditionSchema` and the fenced `DecisionConfigSchema` / `mode` region are untouched. objectstack-ai#20168's PR objectstack-ai#20279 landed while this was in flight, and this branch is merged over it (`7534fd7e`). Its refusal lives in `DecisionConfigSchema`, which no door parses a node's config against, so it and this walk do not meet. Its suite is green here (in the `src/automation` run below). **Prescription wording.** Triage (`5811370954`) says PR objectstack-ai#19960's decision-branch prescription is "删掉这个分支" (delete the branch). The landed objectstack-ai#19960 text says something else: write the predicate, or `expression: 'false'` to keep what the blank ran, and⚠️ **not** by dropping a decision's only branch. That clause is pinned by `predicate-slot-blank.test.ts`. This PR follows the landed wording. It drops the "keep what ran" half, because an absent predicate never ran: it failed the run at the branch. So `'false'` is offered as "keep the branch and its label, never take it", and nothing is claimed to be preserved. ### The refusal text, quoted (`predicateSlotRefusal(undefined)`, byte for byte what all three doors print) ```text A predicate slot holds BARE CEL TEXT that states a rule — it is declared `z.string()` — so an expression envelope, any other non-string, or a string that is blank after trimming is not authorable there. Found nothing — the key is absent where the slot is required: a decision branch is `{ label, expression }` and its `expression` is not optional, so a branch without one states no rule. Write the predicate the branch was meant to test (e.g. `record.rating >= 4`); a predicate written under another key — `condition` is the edge's spelling — belongs in `expression`. There is no run to keep: the executor evaluates every branch it reaches, and a branch with no `expression` failed the run there. To keep the branch and its label but never take it, write `expression: 'false'`. Not by dropping a decision's only branch: the node then routes by its out-edges alone, and the out-edge that branch labelled is no longer held back. ``` For `null`, `Found nothing — the key is absent` reads `Found` followed by the code-spelled `null`. The lead sentence (`PREDICATE_SLOT_STRING_REFUSAL`) is unchanged, byte for byte. **After, measured on `e702ebd4` (the real CLI door, spec rebuilt; no file of this diff changed after that).** `{ label: 'y' }`, `expression: null` and `condition: 'true'` all give `objectstack validate --json` `valid: false`, exit 1, one `custom` error at `flows.0.nodes.1.config.conditions.0.expression`. The absent and alias messages are byte-identical. `registerFlow` refuses the same three with a `custom` issue at `nodes.1.config.conditions.0.expression`. `expression: 'true'` still validates and registers. The blank keeps its own message. ## Pins: one table per door, the same five rows Every refused row asserts the issue `code`, the `path`, and the full message equal to the spec's own `predicateSlotRefusal(value).message`. - `packages/spec/src/automation/flow-decision-branch-expression-absent.test.ts`: `FlowSchema.parse`. - The five rows: absent, `null`, `condition` alias, blank control, real accept control. - Branch index 1 is anchored. The ADR-0031 region body is anchored. - Controls: a decision with no `conditions` or `[]` still parses; an absent screen `visibleWhen` still parses. - `packages/services/service-automation/src/decision-branch-expression-absent.test.ts`: `registerFlow`. - The same table. `getFlow` is `null` after each refusal. - Region body. - On the real decision executor: the absent branch failed the run (`success: false`, `condition evaluation error`, ran `['start']`); `expression: 'false'` routes to the fallback. - `packages/lint/src/validate-expressions.test.ts` `describe('a decision branch with no expression (objectstack-ai#19961)')`: `validateStackExpressions`, with the same table, the exact `where` string, branch index 1, and controls. - `packages/spec/src/automation/flow-node-expression-paths.test.ts`: - The resolver emits `undefined` or `null` for the decision slot and skips everything else. - `predicateSlotRefusal(undefined | null)` prescription clauses are pinned by name. - The `required` set is pinned to exactly `decision.conditions[].expression (predicate)`, because the absent arm's wording is decision-specific. - `packages/services/service-automation/src/builtin/config-expression-ledger.test.ts`: the reconciliation ratchet now reads each channel's JSON-Schema `required` list. It asserts that the ledger's `required` flags equal the channel's, in both directions, over the `predicate` role. It derives, not assumes, that `visibleWhen` is optional. It asserts that `required` is never set on another role. The channels do require `loop.collection` / `map.collection`, but no door refuses their absence (reported to the seat as an out-of-scope finding). **Pin sweep.** One published pin flipped: `decision-predicate-envelope.test.ts` asserted `decisionFlow('str_absent', undefined)` registers. It was re-judged in place, and the reason is written beside it. It now asserts the throw carries `PREDICATE_SLOT_STRING_REFUSAL` and `Found nothing — the key is absent where the slot is required`. Repo sweep for other branches without an `expression`: a bracket-balanced scan of every `.ts` / `.json` / `.yaml` file that mentions both `decision` and `conditions` found only this PR's own fixtures. A grep of helper-built branches (`{ label: …, expression }` shorthand) found 4 sites, all in suites run below. No other package's test builds a `decision` with `conditions`. ## Ablation: the pins can fail Both ablations were run on committed state through `scripts/ablation-replace.mjs`, which wraps the change, verifies it on disk and restores it with a trap. Both proved restore by blob hash equal to HEAD and an empty `git diff HEAD`. - **A: the `required` flag neutralised.** `required: true,` was replaced by a spread that is `{}` unless a `globalThis` flag named `ABLATION_19961` is set. - `ablation-dist-preflight.mjs @objectstack/spec ABLATION_19961` found the marker present in 20 built files. - Red, in the expected direction: - spec: 7 failed (the absent, `null` and alias rows, index 1, region, the `required`-set pin, the resolver pin); - service-automation: 6 failed (the three rows, region, the re-judged envelope pin, the ratchet); - lint: 4 failed. - The blank and real controls stayed green at every door. - Restore leg: rebuild, `--absent` marker gone from all 222 built files, whole-tree `git status` clean. spec 52/52, service-automation 34/34 and lint 344/344 green. - The first attempt was a no-op and its reading was discarded: my replacement was not valid TypeScript, so the transform failed and the build never ran. - **B: the lint early return put back** (`if (raw == null) return { refused: false };`): lint showed 4 failed (absent, `null`, alias, index 1), and the blank and real rows stayed green. Restored by blob hash. The first attempt was refused by the tool before running anything, because the anchor matched its own replacement. ## Producer census (Zone 2 item 4): authored count 0 - `examples/**` at `e702ebd4`: 3 flows carry `decision` nodes (app-crm `convert-lead`, app-showcase `needs_exec` / `triage`, app-todo `check_recurring`). All of them branch on out-edges and declare no `conditions`, so 0 branches lack an `expression`. - `packages/**` non-test: no default flow carries a `decision` node. The `content/docs/automation/flows.mdx` examples: 3 `conditions` lists, all with `expression`. - cloud `origin/main` `96eb092f`: 0 `decision` nodes. `service-ai-studio`'s authoring whitelist names `decision` as an authorable node type, so AI-authored flows now meet this refusal. - objectui at the pin `f8a9d0fb` (`.objectui-sha`): `FlowObjectListField` `rowsToList` still drops a blank cell, so a branch row with an empty expression cell is written as `{ label }`. That is the known writer. Triage accepted that its save now fails loudly, so it is not fixed here. ## ADR-0087 and changeset - New semantic entry `flow-decision-branch-expression-absent-refused` (major 18) and a regenerated `registry.ts`. - There is no D2 conversion: the platform cannot know the rule the author left out, and `'false'` would change behaviour rather than keep it. - The changeset `.changeset/19961-decision-branch-expression-absent-refused.md`: `@objectstack/spec` and `@objectstack/lint` `minor`, BREAKING, with the FROM → TO table. - `service-automation` gets no changeset: its diff is test files only, and those are not in `files[]`. ## Verification (final head `7534fd7e`, which is `origin/main` `6a6a17b6` merged, objectstack-ai#20279 included) - Tests (`os-verify-lock`, spec rebuilt on this head): - spec `src/automation` + `src/migrations`: 1005/1005, including objectstack-ai#20279's `schemaless-node-config.test.ts`. - service-automation: the 4 predicate-slot / ledger files, 50/50. - lint `validate-expressions.test.ts`: 344/344. - Full suites, run on the first merge head `266cd043`: spec 16855 passed (583 files), service-automation 1767/1767, lint 4269/4269. - Typecheck, including the test layers, on `266cd043`: spec, lint and service-automation all exit 0. No file of this diff changed after that. - Gates: `dispatch-gates.mjs --commands` re-derived on `7534fd7e` gives 90 families. 89 ran with exit 0. `dispatch-gates --ran` answers "90 derived famil(ies) accounted for — 89 run, 1 NOT-MEASURED". - NOT MEASURED: `check:type-check-debt`. Its `--re-measure` runs a whole-tree `turbo run build --filter=./packages/*` outside `os-verify-lock`. This diff touches no DEBT-ledger package. - On earlier heads, two gates needed their prerequisites built first, and both then exited 0. `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET because 12 unrelated packages were unbuilt. `check:dts-closure` went red on local state: 6 packages lost their `.d.ts` to my own interrupted `--re-measure` build. That is not this diff. - `eslint --no-inline-config --format json` over the 12 changed `.ts` files on `7534fd7e`: 12 files, 0 errors, 0 warnings. - Population: `eslint.config.mjs` `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`, and no file here is ignored. - Invariance: the config never enables type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. - Declared narrowing: after the second and third `origin/main` merges, I re-ran the targeted suites above and every gate, not the full package suites. The incoming commits touch other surfaces (rls, date comparands, report charts, cli generate, pm scripts, and objectstack-ai#20279's `DecisionConfigSchema` `mode`), not the flow predicate walk. ## Acceptance notes (observed, not filed) - `service-automation` `engine.ts` `evaluateCondition` still has an inline comment saying the empty-source arm is where "a `decision` node whose `conditions[]` entry has no `expression`" lands and answers `false`. Since objectstack-ai#16038 the shape gate throws first, and since this PR the shape cannot register. The comment is stale; no behaviour follows from it. Carrier: whoever next edits `evaluateCondition`, else none. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20168
Clause-②: no
This PR carries ruling
5856786357intopackages/spec: batch #227 item 4, letter A, put in force by5856865124. Adecisionnode that declares a NON-EMPTYconditionslist together withmodeis now refused at authoring. The refusal names the ruled way out: dropmode(a conditions list is first-match on its own), or move the branches onto the edges and keepmode.modebelongs to the edge-branched decision alone.Dispatched by the
domain:specseat 4 PM, sessionsession_01CiCTczDo7tGhafXjf61dUJ. The claim is comment5857419814. Branch basee46218674; every reading below is at headae8185032unless it says otherwise.What changes
DecisionConfigSchema(packages/spec/src/automation/schemaless-node-config.zod.ts) gains a.superRefine. Whenmodeis authored beside a non-emptyconditionsarray, it adds onecustomissue at['mode']. The message comes from a new helper,decisionModeWithConditionsRefusal(), which sits beside the existingdecisionModePrescription()and echoes the value the same way. Both members are refused alike, because it is the key that has no reader here, not the value.modebeside an empty list,modewithconditionsabsent, and a list withoutmode. All three parse exactly as before. Amodeoutside the closed pair still gets its own value refusal first. A base-type issue aborts the object's refinement, so the author sees one issue, never two.mode.describe()and docblocks now state the refusal.content/docs/references/automation/schemaless-node-config.mdxis regenerated withgen:docs, becausecheck:generatedproved onlycheck:docsstale.dropped-refinements.baseline.jsongains exactly the row the build's ratchet printed:automation/DecisionConfig, with one root site (the empty path). No arm of the projection list inshared/refinement-projection.tsfits "this key forbids that one when the list is non-empty". That rule is value-conditional, anddependent-required/banned-keysare presence-only. Adding an arm would be a public-contract decision, so the one route the ruling allows was taken. The header totals were recounted from the body: 211 → 212 schemas and 604 → 605 sites. The builtjson-schema/automation/DecisionConfig.jsoncarriesx-dropped-refinements: [{ at: '', type: 'object', count: 1 }]..changeset/20168-decision-mode-beside-conditions-refused.mdbumps@objectstack/specaspatch, withClause-②: no.Timing and grade: measured at landing, not assumed (ruling item 2)
latest@objectstack/spec17.4.0npm view @objectstack/spec dist-tags, 2026-09-27T16:12Z, and again at 18:03Zmodein the published contractjson-schema/automation/DecisionConfig.jsonin the 17.4.0 tarball declaresconditionsonly, withadditionalProperties: false. Indist/automation/index.js, the control stringfirst true expression winshas 2 hits and the test stringedge-branched decisionhas 0npm pack @objectstack/spec@17.4.0open,merged: falsemode.changeset/19867-decision-config-mode.mdis still in.changeset/, so it is unconsumedae8185032⇒ Unreleased. Per ruling item 2 this is
patch,Clause-②: no, and no ADR-0087 entry.modereaches its first release together with this refusal, so no published accept set narrows. Against the published 17.4.0 contract, the release still only widens. The same reading and its source are written into the changeset.Which doors parse
DecisionConfigSchematoday (PM mechanism assumption 3, measured)SCHEMALESS_NODE_CONFIG_SCHEMAS.decisionhandle (one object). Both are pinned.validateNodeConfigKeys(engine.ts) skips a node whose descriptor publishes noconfigSchema(if (!schema) continue;), anddecisionpublishes none by design.FlowSchema/defineFlow: no.FlowNodeSchema.configis az.record(z.string(), z.unknown()), and the node-level config pass parses only anendnode (parseEndNodeConfig).os validate: no.lint-flow-patterns.tsreadsconfig.conditionsad hoc (labels, emptiness) and never parses the schema.git greponDecisionConfigSchema|SCHEMALESS_NODE_CONFIG_SCHEMAS|getSchemalessNodeConfigJsonSchemasoutsidepackages/specfinds three places.metadata-protocol/src/reference-sites.tsis a JSON-projection walk, where a refinement projects byte-identically.service-automation'sconfig-expression-ledger.test.tsreads the projection.config-expression-ledger.test.ts:325mentions the schema in a comment only.⇒ No door that answers in the ADR-0112 envelope (a
codeand astatus) parses this schema yet. The envelope arrives with the registration-time reader that #15429 adds, which is the ruling's item 3. This PR pins the parse door, the by-node-type registry handle that reader will look up, and the per-parseobjectStackErrorMapa validator may pass. Mechanism assumption 1 held (:471/:486/:206one46218674). So did assumption 2: the projection drops the refinement, and the ledger row is registered.For #15429's acceptance list (the
domain:servicesseat)The refusal that the registration reader must surface:
code: 'custom',path: ['mode']; exactly one issue for a config with a legalmodebeside a non-emptyconditions.`mode: 'inclusive'` is not valid on a decision that declares a `conditions` list — `mode` belongs to the edge-branched decision alone.Either delete `mode` and keep the list…move the branches onto the out-edges (a `condition` on each branch edge, `isDefault: true` on the fallback), delete `conditions`, and keep `mode`.{ conditions: [], mode },{ mode }, and{ conditions: [...] }withoutmode.Pins, and the ablation
packages/spec/src/automation/schemaless-node-config.test.ts:{ conditions: [one], mode: 'inclusive' | 'exclusive' }and the same with a two-entry list: refused, onecustomissue at['mode'], ruled first sentence and both remedies.SCHEMALESS_NODE_CONFIG_SCHEMAS.decision, and underobjectStackErrorMap.safeParsesuccess that round-trips:{ conditions: [], mode }for both members,{ mode }withconditionsabsent for both members, and a list withoutmode. Also a test that following either remedy parses.Ablation (one-shot, run from the committed state; no permanent test file). The test imports the schema by relative path, so the source is what is resolved and no
dist/is in the path.node scripts/ablation-replace.mjsreplaced the refinement'sif (...)guard withif (false):70f2ae5d5b01→1de6a6511010;70f2ae5d5b01== HEAD blob,git diff HEADempty.Flipped-semantics sweep (card clause)
No fixture, example, doc or skill authors
conditions+modetogether. The sweep grepped formode: 'inclusive'|'exclusive'and the double-quoted forms:examples/**,skills/**andcontent/docs/**: 0 hits. The six files carryingtype: 'decision'were checked for anymode:, and the only two hits are a screen node'smode: 'create'and a comment.packages/services,packages/lint,packages/cli,packages/metadata,packages/metadata-protocol: 0 hits./home/user/hotcrmat2f7b2326(read-only): 0 hits across its 14 decision-bearing files. The controlisDefaulthits.objectui was not checked out in this container, so its designer form is NOT MEASURED here. objectui#10750 stays the coordination card (ruling item 4).
Verification at
ae8185032pnpm --filter @objectstack/spec build+ automation/DecisionConfig (1 site(s))and exited 1pnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typechecktsc,check:scripts-typecheck,check:test-typecheck)pnpm --filter @objectstack/spec check:generatedcheck:docsstale. Aftergen:docs,check:docsgives exit 0, "226 generated files in sync"turbo run build --only(service-automation / lint / metadata-protocol closures, plus client and client-react, excluding spec)@objectstack/service-automationvitest@objectstack/lintvitest@objectstack/metadata-protocolvitestdispatch-gates --commands→ each run →--ran--no-inline-config --format json) on the 2 touched.tsfilescheck:nul-bytesplus a control-byte self-scan of the 4 hand-edited filescheck:dual-build-cjs-loadsandcheck:type-check-debtboth exited 3 (PREREQUISITE NOT MET): they need every workspace package built, which is 86 packages withoutdisthere, and lint.yml's own prerequisite is a fullturbo buildof all packages. CI runs both on the PR.git grep(upstream of nothing; downstream of spec), plus@objectstack/lintas the dispatch named it. I did not run all of...@objectstack/spec: the public types are byte-unchanged (check:api-surfaceexit 0 with no regeneration;z.input/z.inferare unaffected by a refinement), so only the parse accept set of this one schema narrows..tsfiles. The.json,.mdand.mdxfiles match no eslint config object. The count comes from the JSON output. It is invariant for untouched files, becauseeslint.config.mjsnever enables type-aware linting (noparserOptions.project).Acceptance notes
mode: 'inclusive'toDecisionConfigSchema— the contract half of #15429's ruling (edge-branched decisions become exclusive / first-match; "take every true edge" must be declared) #19867 changeset still says "Aconditionslist is unaffected". It is left as written, because it is another PR's input. This PR's changeset states the refusal, and both reach the same release's CHANGELOG. If the release compiler wants one sentence, the edit is to append "— andmodebeside a non-empty list is refused" to that bullet.dropped-refinements.baseline.json.origin/main17bd31877has not moved the ledger sincee46218674. Whichever PR lands second re-merges withbash scripts/pm/os-regen-merge.shand recounts both header totals from the body.Generated by Claude Code