Commit 733822c
Fixes #20168
Clause-②: no
This PR carries ruling `5856786357` into `packages/spec`: batch #227
item 4, letter **A**, put in force by `5856865124`. A `decision` node
that declares a NON-EMPTY `conditions` list together with `mode` is now
refused at authoring. The refusal names the ruled way out: drop `mode`
(a conditions list is first-match on its own), or move the branches onto
the edges and keep `mode`. `mode` belongs to the edge-branched decision
alone.
Dispatched by the `domain:spec` seat 4 PM, session
`session_01CiCTczDo7tGhafXjf61dUJ`. The claim is comment `5857419814`.
Branch base `e46218674`; every reading below is at head `ae8185032`
unless it says otherwise.
## What changes
1. **`DecisionConfigSchema`**
(`packages/spec/src/automation/schemaless-node-config.zod.ts`) gains a
`.superRefine`. When `mode` is authored beside a non-empty `conditions`
array, it adds one `custom` issue at `['mode']`. The message comes from
a new helper, `decisionModeWithConditionsRefusal()`, which sits beside
the existing `decisionModePrescription()` 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.
2. Out of scope, by the ruling: `mode` beside an empty list, `mode` with
`conditions` absent, and a list without `mode`. All three parse exactly
as before. A `mode` outside 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.
3. The `mode` `.describe()` and docblocks now state the refusal.
`content/docs/references/automation/schemaless-node-config.mdx` is
regenerated with `gen:docs`, because `check:generated` proved only
`check:docs` stale.
4. **`dropped-refinements.baseline.json`** gains exactly the row the
build's ratchet printed: `automation/DecisionConfig`, with one root site
(the empty path). No arm of the projection list in
`shared/refinement-projection.ts` fits "this key forbids that one when
the list is non-empty". That rule is value-conditional, and
`dependent-required` / `banned-keys` are 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 built
`json-schema/automation/DecisionConfig.json` carries
`x-dropped-refinements: [{ at: '', type: 'object', count: 1 }]`.
5. The changeset
`.changeset/20168-decision-mode-beside-conditions-refused.md` bumps
`@objectstack/spec` as `patch`, with `Clause-②: no`.
## Timing and grade: measured at landing, not assumed (ruling item 2)
| reading | value | source |
|:--|:--|:--|
| npm `latest` `@objectstack/spec` | `17.4.0` | `npm view
@objectstack/spec dist-tags`, 2026-09-27T16:12Z, and again at 18:03Z |
| `mode` in the published contract | **absent**:
`json-schema/automation/DecisionConfig.json` in the 17.4.0 tarball
declares `conditions` only, with `additionalProperties: false`. In
`dist/automation/index.js`, the control string `first true expression
wins` has 2 hits and the test string `edge-branched decision` has 0 |
`npm pack @objectstack/spec@17.4.0` |
| Version Packages PR #17076 | `open`, `merged: false` | REST, 16:12Z
and 18:03Z |
| pending changeset for `mode` |
`.changeset/19867-decision-config-mode.md` is still in `.changeset/`, so
it is unconsumed | tree at `ae8185032` |
⇒ **Unreleased.** Per ruling item 2 this is `patch`, `Clause-②: no`, and
no ADR-0087 entry. `mode` reaches 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 `DecisionConfigSchema` today (PM mechanism
assumption 3, measured)
- **Direct parse**: yes, through the export and through the
`SCHEMALESS_NODE_CONFIG_SCHEMAS.decision` handle (one object). Both are
pinned.
- **Flow registration**: no. `validateNodeConfigKeys` (`engine.ts`)
skips a node whose descriptor publishes no `configSchema` (`if (!schema)
continue;`), and `decision` publishes none by design.
- **`FlowSchema` / `defineFlow`**: no. `FlowNodeSchema.config` is a
`z.record(z.string(), z.unknown())`, and the node-level config pass
parses only an `end` node (`parseEndNodeConfig`).
- **`os validate`**: no. `lint-flow-patterns.ts` reads
`config.conditions` ad hoc (labels, emptiness) and never parses the
schema. `git grep` on
`DecisionConfigSchema|SCHEMALESS_NODE_CONFIG_SCHEMAS|getSchemalessNodeConfigJsonSchemas`
outside `packages/spec` finds three places.
`metadata-protocol/src/reference-sites.ts` is a JSON-projection walk,
where a refinement projects byte-identically. `service-automation`'s
`config-expression-ledger.test.ts` reads the projection.
`config-expression-ledger.test.ts:325` mentions the schema in a comment
only.
- **Published JSON Schema**: it cannot state the rule. The rule is
declared dropped instead, in the ledger and on the artifact, as item 4
above describes.
⇒ No door that answers in the ADR-0112 envelope (a `code` and a
`status`) 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-parse `objectStackErrorMap` a validator
may pass. Mechanism assumption 1 held (`:471` / `:486` / `:206` on
`e46218674`). So did assumption 2: the projection drops the refinement,
and the ledger row is registered.
## For #15429's acceptance list (the `domain:services` seat)
The refusal that the registration reader must surface:
- issue `code: 'custom'`, `path: ['mode']`; exactly one issue for a
config with a legal `mode` beside a non-empty `conditions`.
- message first sentence, verbatim (the value is echoed): ``` `mode:
'inclusive'` is not valid on a decision that declares a `conditions`
list — `mode` belongs to the edge-branched decision alone. ```
- the two remedies in the same message: ``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`.``
- the message carries no tracker number.
- to leave alone: `{ conditions: [], mode }`, `{ mode }`, and `{
conditions: [...] }` without `mode`.
## Pins, and the ablation
`packages/spec/src/automation/schemaless-node-config.test.ts`:
- The old pin *"…and alongside a branch list, which the key does not
forbid"* asserted the accept-both shape. It is replaced by the ruled
semantics.
- A new describe block covers:
- `{ conditions: [one], mode: 'inclusive' | 'exclusive' }` and the same
with a two-entry list: refused, one `custom` issue at `['mode']`, ruled
first sentence and both remedies.
- The same refusal through `SCHEMALESS_NODE_CONFIG_SCHEMAS.decision`,
and under `objectStackErrorMap`.
- An illegal value beside a list: the value refusal only.
- Controls, each a full `safeParse` success that round-trips: `{
conditions: [], mode }` for both members, `{ mode }` with `conditions`
absent for both members, and a list without `mode`. 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.mjs` replaced the refinement's `if (...)` guard
with `if (false)`:
- mutation: anchor 1 → 0, blob `70f2ae5d5b01` → `1de6a6511010`;
- run: **7 failed / 43 passed (50)**. All six refusal pins went red,
plus *following either remedy parses*, whose first assertion is the
refusal. The controls and the value-refusal-first test stayed green.
That is the expected direction, and it was observed;
- restore: blob back to `70f2ae5d5b01` == HEAD blob, `git diff HEAD`
empty.
## Flipped-semantics sweep (card clause)
No fixture, example, doc or skill authors `conditions` + `mode`
together. The sweep grepped for `mode: 'inclusive'|'exclusive'` and the
double-quoted forms:
- `examples/**`, `skills/**` and `content/docs/**`: 0 hits. The six
files carrying `type: 'decision'` were checked for any `mode:`, and the
only two hits are a screen node's `mode: 'create'` and a comment.
- `packages/services`, `packages/lint`, `packages/cli`,
`packages/metadata`, `packages/metadata-protocol`: 0 hits.
- `/home/user/hotcrm` at `2f7b2326` (read-only): 0 hits across its 14
decision-bearing files. The control `isDefault` hits.
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 `ae8185032`
| command | result |
|:--|:--|
| `pnpm --filter @objectstack/spec build` | exit 0; the
dropped-refinement ratchet passes with the new row. Without the row it
printed `+ automation/DecisionConfig (1 site(s))` and exited 1 |
| `pnpm --filter @objectstack/spec test` | exit 0: 549 files, 16159
passed, 2 todo |
| `pnpm --filter @objectstack/spec typecheck` | exit 0 (`tsc`,
`check:scripts-typecheck`, `check:test-typecheck`) |
| `pnpm --filter @objectstack/spec check:generated` | 14/15 current plus
`check:docs` stale. After `gen:docs`, `check:docs` gives exit 0, "226
generated files in sync" |
| consumer closure `turbo run build --only` (service-automation / lint /
metadata-protocol closures, plus client and client-react, excluding
spec) | exit 0 |
| `@objectstack/service-automation` vitest | exit 0: 146 files, 1757
passed |
| `@objectstack/lint` vitest | exit 0: 110 files, 4258 passed |
| `@objectstack/metadata-protocol` vitest | exit 0: 189 passed, 3
skipped files; 2715 passed, 19 skipped |
| `dispatch-gates --commands` → each run → `--ran` | 107 derived, **105
exit 0**, 2 NOT MEASURED, 0 unrun |
| eslint (`--no-inline-config --format json`) on the 2 touched `.ts`
files | exit 0, 2 files, 0 errors, 0 warnings |
| `check:nul-bytes` plus a control-byte self-scan of the 4 hand-edited
files | exit 0; 0 matches |
- **NOT MEASURED, declared.** `check:dual-build-cjs-loads` and
`check:type-check-debt` both exited 3 (PREREQUISITE NOT MET): they need
every workspace package built, which is 86 packages without `dist` here,
and lint.yml's own prerequisite is a full `turbo build` of all packages.
CI runs both on the PR.
- **Consumer sweep direction.** I ran the direct importers of the schema
family found by `git grep` (upstream of nothing; downstream of spec),
plus `@objectstack/lint` as the dispatch named it. I did not run all of
`...@objectstack/spec`: the public types are byte-unchanged
(`check:api-surface` exit 0 with no regeneration; `z.input`/`z.infer`
are unaffected by a refinement), so only the parse accept set of this
one schema narrows.
- **eslint narrowing.** The population is the two `.ts` files. The
`.json`, `.md` and `.mdx` files match no eslint config object. The count
comes from the JSON output. It is invariant for untouched files, because
`eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`).
## Acceptance notes
- The pending #19867 changeset still says "A `conditions` list 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 "— and `mode` beside a non-empty list is refused" to
that bullet.
- Ledger contention: PR #20251 also edits
`dropped-refinements.baseline.json`. `origin/main` `17bd31877` has not
moved the ledger since `e46218674`. Whichever PR lands second re-merges
with `bash scripts/pm/os-regen-merge.sh` and recounts both header totals
from the body.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2aa25ef commit 733822c
5 files changed
Lines changed: 223 additions & 12 deletions
File tree
- .changeset
- content/docs/references/automation
- packages/spec
- src/automation
Lines changed: 15 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
137 | 137 | | |
138 | 138 | | |
139 | 139 | | |
140 | | - | |
| 140 | + | |
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
6 | | - | |
| 5 | + | |
| 6 | + | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| |||
413 | 413 | | |
414 | 414 | | |
415 | 415 | | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
416 | 421 | | |
417 | 422 | | |
418 | 423 | | |
| |||
Lines changed: 132 additions & 6 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
12 | | - | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
13 | 15 | | |
14 | 16 | | |
15 | 17 | | |
| |||
25 | 27 | | |
26 | 28 | | |
27 | 29 | | |
| 30 | + | |
28 | 31 | | |
29 | 32 | | |
30 | 33 | | |
| |||
244 | 247 | | |
245 | 248 | | |
246 | 249 | | |
247 | | - | |
248 | | - | |
249 | | - | |
250 | | - | |
251 | | - | |
| 250 | + | |
| 251 | + | |
252 | 252 | | |
253 | 253 | | |
254 | 254 | | |
| |||
303 | 303 | | |
304 | 304 | | |
305 | 305 | | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
306 | 432 | | |
307 | 433 | | |
308 | 434 | | |
| |||
0 commit comments