Repository navigation
feat(formula,spec): stable refusal codes and params beside every expression refusal message - #20352
Conversation
…ry expression refusal message validateExpression (errors and warnings), collectCelRootIdentifiers and predicateSlotRefusal now return `code` (a closed kebab-case set) and `params` (the values the English message interpolates) beside `message`, so a localized author surface can key its own catalogue rows to the code. The English message is unchanged byte for byte. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…eset for the refusal codes Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…ulary gate does not read them as code constants `check:dispatcher-error-vocabulary`'s object-literal constant shape matches `code: K;` in a type literal, so a single-letter type parameter reads as an unresolvable SCREAMING code constant. `Code` / `Codes` say the same thing and stay out of that grammar. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 137 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 391f8a88da1c686209b7ee31ea51915f367c3669 && git checkout 391f8a88da1c686209b7ee31ea51915f367c3669
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d498113b505f7b29b5e2c554f3152145edeb3461 5581ed26ee2e338a563eabf3b23f1ec1137f82be && git checkout -B drift-repro d498113b505f7b29b5e2c554f3152145edeb3461 && git merge --no-ff 5581ed26ee2e338a563eabf3b23f1ec1137f82be
node scripts/docs-audit/affected-docs.mjs --json d498113b505f7b29b5e2c554f3152145edeb3461
|
…usal too
The file's second refusal producer gets the shape predicateSlotRefusal
has: `code` ('structural-condition-shape') and `params` ({ found }) beside
the unchanged English message. The spec's code set is now one closed
table for both producers (FlowSlotRefusalParams / FLOW_SLOT_REFUSAL_CODES),
with per-producer code types, the way the formula set is laid out.
Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV
Co-Authored-By: Claude <noreply@anthropic.com>
…lot refusal set; update the changeset Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 72/72 ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…stem-* migration entries states each lesson in words, not tracker numbers (stage 3) (objectstack-ai#20384) Part of objectstack-ai#20233 Clause-②: no **Stage 3 of a staged card.** The card stays open for later stages; this PR carries no closing keyword. Text only: no entry id, `surface`, `from` / `to`, conversion or matching logic moves, and the chain rewrites exactly what it rewrote before. ## What this does `os migrate meta` prints every ADR-0087 semantic entry it crosses as one block: `⚠ [protocol N] SURFACE → REPLACEMENT`, then `why:` (the entry's `reason`) and `verify:` (its `acceptanceCriteria`). AGENTS.md's runtime-string rule applies to all of it: 「Runtime strings — refusal prose, prescriptions, anything an author is shown — carry no tracker number (`pnpm check:doc-authoring`): the lesson goes into the text.」 Form **D** of ruling C+D on the parent card sets the shape: the lesson in words, and no number, dead or alive. This stage covers the next three families by site count, `driver-`, `kernel-` and `system-`: **132 sites → 0** in the three prose fields. None of the 26 entries carries a tracker id in `surface` (ruling A of the stage-1 ACCEPT, `5858839916`, is checked and has nothing to do here). Each site now says what the cited ruling, measurement or fix decided. ADR ids stay. `registry.ts`, `spec-changes.json` and `docs/protocol-upgrade-guide.md` are regenerated from the entries (`gen:migration-registry`, `gen:spec-changes`, `gen:upgrade-guide`), never hand-edited. The stage-1 pin now holds `engine-`, `ui-`, `plugin-`, `driver-`, `kernel-` and `system-`. ## Census — tracker ids in the author-shown fields **Instrument.** The stage-2 AST instrument, unchanged: a TypeScript-AST walk over every `packages/spec/src/migrations/entries/**/*.ts`. For each `entry` object literal it evaluates the string value of `replacement`, `reason`, `acceptanceCriteria` and (counted separately) `surface`, joining string literals with `+`, then counts `#` followed by 4 or 5 digits at a word boundary. **Validated first** by reproducing the stage-1 readings on the stage-1 tree (`443b2f4fdc`, extracted with `git archive`): `driver-` 7 entries / 44 sites (0 / 44 / 0, 25 distinct), `kernel-` 9 / 44 (1 / 41 / 2, 11 distinct), `system-` 10 / 44 (0 / 42 / 2, 6 distinct), `engine-` 5 / 67, whole tree 266 entries / 1,016 sites / 9 `surface` sites — every figure equal to the stage-1 census. **Tree measured:** `objectstack-ai/objectstack` at `569d4d2dbf` (this branch's base). Unevaluable fields: 0. **Controls, same run.** - **Lit:** `17.aggregation-node-distinct-retired.ts` reads 7 sites (replacement 1, reason 6), the reading stages 1 and 2 took. - **Dark (comment lines):** 794 `//` lines in entry files carry a tracker id, and none is counted. Comment lines belong to the sibling card, and ⛔ this PR touches none (794 before and after). - **Dark (field boundary):** the 7 `surface` sites left in the tree (other families) count 0 in the three-field total and 7 in the `surface` column. **Re-measured on the base, matching the stage-1 census:** `driver-` 7 entries, **44** sites (replacement 0 / reason 44 / acceptanceCriteria 0), 25 distinct ids; `kernel-` 9 entries, **44** (1 / 41 / 2), 11 distinct; `system-` 10 entries, **44** (0 / 42 / 2), 6 distinct. 37 distinct ids across the three (the families share `objectstack-ai#14478`, `objectstack-ai#15939`, `objectstack-ai#17635` and `objectstack-ai#3733`). `surface`: 0 in all three. Whole tree: 300 entries, **843** sites, 7 `surface` sites. **After this PR:** `driver-` 0, `kernel-` 0, `system-` 0; `engine-`, `ui-`, `plugin-` still 0; whole tree **843 → 711** sites; `surface` 7 (unchanged, other families). | entry | sites (replacement / reason / acceptanceCriteria) | |---|---| | `17.driver-aggregate-undeclared-key-aliases-removed` | 6 (0 / 6 / 0) | | `17.driver-capabilities-inert-bits-removed` | 4 (0 / 4 / 0) | | `18.driver-options-timeout-to-timeout-ms` | 1 (0 / 1 / 0) | | `17.driver-sql-distinct-bare-filter-typed` | 9 (0 / 9 / 0) | | `18.driver-sql-unresolvable-where-column-refused` | 14 (0 / 14 / 0) | | `18.driver-sql-upsert-cross-row-identity-merge-refused` | 9 (0 / 9 / 0) | | `18.driver-turso-config-local-path-wasm-retired` | 1 (0 / 1 / 0) | | `18.kernel-compatibility-matrix-estimated-migration-time-unit-in-key` | 5 (0 / 5 / 0) | | `18.kernel-context-preview-mode-retired` | 5 (1 / 4 / 0) | | `18.kernel-event-bus-retention-unit-in-key` | 3 (0 / 3 / 0) | | `18.kernel-health-check-and-hot-reload-durations-unit-in-key` | 7 (0 / 5 / 2) | | `18.kernel-package-lifecycle-durations-unit-in-key` | 3 (0 / 3 / 0) | | `18.kernel-plugin-health-report-durations-unit-in-key` | 3 (0 / 3 / 0) | | `18.kernel-plugin-security-durations-unit-in-key` | 4 (0 / 4 / 0) | | `18.kernel-runtime-config-timeout-unit-in-key` | 11 (0 / 11 / 0) | | `18.kernel-startup-orchestrator-durations-unit-in-key` | 3 (0 / 3 / 0) | | `18.system-cache-durations-unit-in-key` | 3 (0 / 3 / 0) | | `18.system-collaboration-durations-unit-in-key` | 4 (0 / 4 / 0) | | `18.system-failover-health-check-interval-unit-in-key` | 3 (0 / 3 / 0) | | `18.system-metrics-jsdoc-durations-unit-in-key` | 12 (0 / 12 / 0) | | `18.system-metrics-window-durations-unit-in-key` | 5 (0 / 3 / 2) | | `18.system-object-storage-durations-unit-in-key` | 3 (0 / 3 / 0) | | `18.system-registry-config-durations-unit-in-key` | 3 (0 / 3 / 0) | | `18.system-tracing-otel-exporter-durations-unit-in-key` | 5 (0 / 5 / 0) | | `18.system-tracing-span-duration-unit-in-key` | 3 (0 / 3 / 0) | | `18.system-worker-queue-rate-limit-duration-unit-in-key` | 3 (0 / 3 / 0) | | **total, 26 entries** | **132 (1 / 127 / 4)** | ## Every citation read, and what the text now says I read each cited issue or PR myself with single-card REST reads: the body, and the comments where a ruling or a measurement lives. Ids are in code spans so this body posts no cross-references. All 36 bare ids were resolved against this repository, because every sentence that cites one is about this repository's code; the one cross-repo id is `cloud#1651`. | cited | what it decided (read) | how the text now carries it | |---|---|---| | `objectstack-ai#3733` | The pruned `cached` field key: measured, the parse succeeded and the removed key was dropped without a word; the orphan schema was deleted. | "an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word" (health-check, OTel exporter) | | `objectstack-ai#3821` | The sharing-rule page: an unsortable query fell through to an empty page, and the driver fix made an unsortable query lose its ORDER BY, not its rows. | "the unknown-column recovery ladder (an unsortable query loses its ORDER BY, not its rows)"; "the ladder's own premise — rows matter more than their order"; "the ladder's recoveries" | | `objectstack-ai#4484` | `IDataDriver.findStream` removed: no production caller, two of three implementations buffered the whole set, and no tombstone because nothing parses a driver object. | "Retiring `IDataDriver.findStream` (it had no production caller, and two of its three implementations read the whole result set into memory …)"; "(`IDataDriver.findStream`, removed with no tombstone because nothing parses a driver object)"; by entry id in the `distinct` entry | | `objectstack-ai#4583` | The datasource ledger's dead keys removed; `capabilities.*` went as a whole block (11 of 11 unread). | "was retired separately, as a whole block nothing read" | | `objectstack-ai#4634` | Audit of all 34 `DriverCapabilities` bits: 3 live, 31 dead and tombstoned. | the entry already states the audit ("the follow-up audit checked every bit"); the trailing id is dropped | | `objectstack-ai#4914` | Maintainer, 2026-08-04: remove `manifest.loading` and `PluginHotReloadSchema`; keep `HotReloadConfigSchema`, the side with an implementation (`HotReloadManager`), as the start point. | "kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, …" | | `objectstack-ai#4984` | An org-axis red-line gate read only aliases the schema rejects while its own fixtures spelt them: tests green, rule dead. | "the family of the org-axis red-line gate that read only rejected aliases while its own fixtures spelt them, so its tests stayed green and the rule stayed dead" | | `objectstack-ai#5181` | Narrow the query parameter of `IDataDriver`'s methods (`DriverQuery`, no redundant `object`). | "neither the narrowing of `IDataDriver`'s query parameters to `DriverQuery` nor the follow-through …" | | `objectstack-ai#5499` | Maintainer, 2026-08-05: freeze investment in `driver-memory` / `driver-mongodb`; fully lifted 2026-08-11 (comments `5249019855`, `5252526378`). | "the maintainer's 2026-08-05 investment freeze on driver-memory, which was lifted on 2026-08-11" | | `objectstack-ai#5540` | Remove `IStorageService.list(prefix)`: zero consumers, and the two adapters answered differently and both incompletely. | "(the zero-consumer `IStorageService.list`, whose two adapters answered differently and both incompletely)" | | `objectstack-ai#6011` | Maintainer: close the `ctx.user` `roles` alias now. | "(the `ctx.user` `roles` alias, closed at once on the maintainer's word rather than given a window)" | | `objectstack-ai#6075` | **404** — see Acceptance notes. | "the follow-through that brought five drivers' implementations in line" | | `objectstack-ai#6320` | `distinct`'s third argument meant different things on memory and sql; the sql half was dispatched, the memory half held under the freeze. | "(the measurement that found the two drivers reading this argument differently split the fix: the sql half is this entry, and the memory half was held back by that freeze)" | | `objectstack-ai#6321` | `query.aggregate` / `agg.func` are undeclared aliases whose only writers are driver fixtures; order: re-spell the fixtures, delete the aliases, then narrow the signature. | "The removal ran in a fixed order — the fixtures re-spelt first, the two alias branches deleted second, the parameter narrowed to `DriverQuery` last — because the reverse order yields red nobody can explain." | | `objectstack-ai#6404` (PR) | Executed that order and narrowed `aggregate`'s query parameter to `DriverQuery`. | the same sentence | | `objectstack-ai#7929` | Maintainer, 2026-08-12, ruling B: `driver-sql`'s filter refusal stops echoing `$field` operands, for every caller; the full diagnostic goes to the server log. | "the same predicate-text disclosure shape the driver's field-reference filter refusals had already been made to stop echoing (the full diagnostic goes to the server log, never the response)"; "that disclosure shape closed on the last dialect" | | `objectstack-ai#8371` | Ruled option 2: a dotted filter key whose head is a relation, a formula or a scalar is refused at both doors; a structured head stays unjudged. | "the axis owned by the dotted-filter verdict, which refuses a dotted key whose head is a relation, a formula or a plain column at the protocol and engine doors" | | `objectstack-ai#8592` | Measured on live MySQL: knex compiles the named conflict target away. | stated by the entry ("knex drops the named keys before the statement leaves the process"); the trailing id is dropped | | `objectstack-ai#8621` | Option A: a pre-flight refusal when no unique index backs the caller-named conflict target. | "Two earlier pre-flight refusals closed the half where no unique index backed a caller-named target …" | | `objectstack-ai#8622` | `id` becomes insert-only on the merge path: a merge on a non-primary conflict key was measured rewriting the existing row's primary key. | "`id` is insert-only on the merge path (made so once a merge on a non-primary conflict key was measured rewriting the existing row's primary key)" | | `objectstack-ai#8755` | Ruling option A: a pre-flight refusal when a second unique key could absorb a backed, caller-named target. | "… and the half where a rival unique key could absorb a caller-named one" | | `objectstack-ai#8790` | Maintainer, 2026-08-15: refuse both halves with `INVALID_FILTER` / 400, naming the column. | "Ruled by the maintainer on 2026-08-15: refuse BOTH halves …"; "Recover-both was excluded by the ruling's own argument" | | `objectstack-ai#8807` | Maintainer, 2026-08-15: an upsert must never modify a row whose identity the caller did not supply and whose conflict key it did not name; enforcement delegated, blanket refusal excluded. | "Ruled by the maintainer on 2026-08-15, as a contract principle …" (the principle itself was already quoted verbatim) | | `objectstack-ai#8926` | Maintainer, 2026-08-16, option A: MySQL's spelling joins the one shared predicate (envelope and recoveries together). | "Addendum 2026-08-16." — the paragraph already states option A | | `objectstack-ai#9061` (PR) | Implemented that option A. | the same | | `objectstack-ai#11825` | Maintainer, 2026-08-25: retire the declarative `AdvancedPluginLifecycleConfig` container; the classes stay a host-driven library. | "… and as a host-driven library when the declarative lifecycle config container was retired" | | `objectstack-ai#11846` | **404** — see Acceptance notes. | "maintainer ruling 2026-08-27 (Option A: remove)"; "(as the removal ruling recorded)" | | `objectstack-ai#14478` | Ruling B, 2026-09-02: a no-baseline gate plus an ADR-0087 rename of every offender (`DriverOptions.timeout` among the seven named); ruling B again, 2026-09-05: the population is every authored and every runtime-emitted duration, minus exemptions declared on the schema. | "Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself)"; "the duration-unit rule (…)" | | `objectstack-ai#14519` | The two tenant timeouts published a describe naming no unit (the unit sat in the JSDoc only); folded into the rename. | "the unit-nowhere shape (no unit in the name or in the published describe, first measured on two tenant timeouts)" | | `objectstack-ai#15626` (PR) | Landed the gate and the seven founding renames, the tenant `idleTimeout` → `idleTimeoutSeconds` among them. | "The tenant half was already renamed, in the same change that landed the duration gate itself" | | `objectstack-ai#15678` | `kernel/`: the 14 remaining duration keys carry their unit in the key name. | "the kernel-directory duration renames" / "the kernel-directory round"; trailing ids dropped | | `objectstack-ai#15679` | `system/`: the 15 remaining duration keys carry their unit in the key name; `size` got an honest name. | "the system-directory duration round"; trailing ids dropped | | `objectstack-ai#15939` | The gate did not read JSDoc. Ruled 2026-09-07: refuse the JSDoc / describe divergence. Ruled A 2026-09-11: remediate the population per file first, land the widened gate last. | "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding … a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands" | | `objectstack-ai#16024` | Maintainer, 2026-09-06, per key: forward `timeout`, remove `localPath` and `wasm`. | "ruled per key by the maintainer on 2026-09-06, once all three of this package's unread config keys had been measured" | | `objectstack-ai#17635` (PR) | The widened gate: refuse a duration key whose JSDoc names a unit its describe does not; landed last. | "lands that widened gate last, into a tree already clean" | | `objectstack-ai#18669` | Ruling A, 2026-09-17: rename `FileValue.duration` and `estimatedMigrationTime`, each with an ADR-0087 entry; no new closed type, no narrowing of stored data. | "Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type could express …" | | `cloud#1651` | **Not readable from this session** — see Acceptance notes. | "cloud — a census closed 2026-08-26: OS_PREVIEW_MODE there is a routing-only switch …" | No call-shaped token moves: a `name(` census over `registry.ts` is identical before and after (297 distinct tokens), so textual call-spelling ratchets read the same. ## Pin — `packages/cli/test/migrate-meta-engine-guidance.test.ts`, widened `COVERED_PREFIXES` is now `engine-`, `ui-`, `plugin-`, `driver-`, `kernel-`, `system-`. The pin still spawns the real CLI (`os migrate meta --from 16 --to 18`) once, locates each covered block **verbatim** in stdout, and asserts the printed block — `surface` included — carries no `#` plus 4 or 5 digits. Anti-vacuity: - the `REWRITTEN` floor rises from 29 to **55** ids: the 26 entries of this stage (7 `driver-`, 9 `kernel-`, 10 `system-`) are added, and every covered prefix must still select at least one entry; - presence in stdout is asserted before cleanliness (the `driver-sql-unresolvable-where-column-refused` reason carries two blank-line paragraph breaks, and its block is found verbatim); - the detector is exercised on both sides first (lit on 4 and 5 digits, dark on 3, 6 and `ADR-0112`). The file keeps its stage-1 name; the header lists the six covered families. ## Ablation — the widened pin can fail on a `driver-` block From committed state, HEAD `1c0dc7ad54`, with `scripts/ablation-replace.mjs` in wrap mode (it owns the restore trap) and `scripts/ablation-dist-preflight.mjs` gating each leg. The bundle is built from the generated `registry.ts`, so that is the file mutated. - **Mutation.** In `registry.ts`, the reason of `driver-sql-upsert-cross-row-identity-merge-refused`: anchor `pre-flight refusals closed the half` → `pre-flight refusals (objectstack-ai#8621) closed the half`. The tool read anchor 1 → 0 and replacement 0 → 1, blob `b41e1d44` → `8f8d226e`. - **Mutate leg** (one lock turn: build, preflight, pin). Spec build exit 0. Preflight: marker present in 4 built files. Pin: **red**, `1 failed | 2 passed` — `driver-sql-upsert-cross-row-identity-merge-refused: the printed guidance cites a tracker id: expected 'objectstack-ai#8621' to be undefined`. - **Restore.** Tool-proven: blob `b41e1d44` == HEAD, `git diff HEAD` empty. - **Restore leg.** One lock turn, taken on the second try (the first waited out its 540 s budget, exit 99, NOT MEASURED, the tree already restored). Spec build exit 0. The `--absent` preflight found the marker in none of 222 built files, with the working tree clean against HEAD. Pin: **green**, `3 passed`. ## Verification Final head **`1c0dc7ad54`** for every line below; each heavy run went through `scripts/pm/os-verify-lock.sh` (one turn, `VERDICT command-exit 0`, per-step exits recorded separately). - **Build:** `pnpm exec turbo run build --concurrency=2 --filter='@objectstack/cli^...'` gives `Tasks: 55 successful, 55 total`. - **Pin and its neighbour:** `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/migrate-meta-engine-guidance.test.ts test/migrate-meta-default-range.test.ts` gives `Test Files 2 passed`, `Tests 10 passed | 1 skipped` (the skip is the default-range file's own pre-existing `skipIf`). - **Spec tests that read these entries or the registry:** `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/migrations src/kernel/preview-mode-retirement.test.ts scripts/build-schemas-check-mode.test.ts` plus the 19 other spec test files that read `MIGRATIONS_BY_MAJOR`, the registry or an entry file: `Test Files 24 passed`, `Tests 696 passed`. - **CLI unit:** `test/vitest-tiers-partition.test.ts` and `src/utils/spec-release-changes.test.ts`: `Test Files 2 passed`, `Tests 28 passed`. - **The call-spelling census that reads `registry.ts`:** `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2 src/sql-driver-query-signature.test.ts` gives 15 passed. - **Typecheck:** `pnpm --filter @objectstack/spec typecheck` exits 0 (test layer: 53 files / 255 errors held in its ledger); `pnpm --filter @objectstack/cli typecheck` exits 0 (test layer: 3 files / 28 errors held, unchanged). - **Gate families:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives **89** families at `1c0dc7ad54` (after `git fetch origin main`). `--ran` over the recorded exit codes reads **89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN**, all exit 0. They include `check:doc-authoring` ("16466 customer-facing string(s) across 1135 spec sources clean"), `check:issue-citations`, `check:migration-registry` ("registry.ts is current (300 semantic, 219 retired-key, 199 retired-def)"), `check:spec-changes`, `check:upgrade-guide`, `check:generated` ("All 15 generated artifacts are up to date"), `check:duration-unit-keys`, `check:nul-bytes`, `check:adr-0087-registration` and `check:changeset-no-major`. - `check:dual-build-cjs-loads` refused first with `PREREQUISITE NOT MET` (exit 3: twelve packages outside the CLI closure had no `dist/`). Those `dist/` directories were written later in the same pass (04:48–04:49Z, inside the `check:type-check-debt` run, whose re-measure builds them); re-run at the same head it exits 0 (104 entries / 66 packages / 659 CJS files). The reconciled list takes that latest run. - **Lint (a proven narrowing, not the repo-wide run, which is CI's):** `eslint --no-inline-config --format json` over the 28 changed `.ts` files reports 28 files, 0 errors, 0 warnings. - The population is read from `eslint.config.mjs`: `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`, and all 28 are in it (no file-ignored warning). - Invariance: the config enables no type-aware linting (no `parserOptions.project`, no typed rules), so a text edit cannot move the verdict on a file it does not touch. - **Mergeability:** see Acceptance notes (driver-free `merge-tree` against `862b6ce869` exits 0). ## Acceptance notes - **Two dead ids, rewritten from the code on `main`.** `objectstack-ai#6075` and `objectstack-ai#11846` answer 404 on both the issues and the pulls endpoint, re-probed with a 200 control (`objectstack-ai#14478`). - `objectstack-ai#6075` (`distinct`'s "never reached it" sentence): `packages/drivers/driver-sql/CHANGELOG.md` (commit `d367f03`) and `sql-driver-query-signature.test.ts` record what it did — the five drivers' implementations followed `IDataDriver`'s `DriverQuery` narrowing. The sentence now says exactly that. - `objectstack-ai#11846` (preview mode): `packages/spec/CHANGELOG.md` (commit `0c2334f`), `packages/spec/src/kernel/context.zod.ts` and `preview-mode-retirement.test.ts` record the 2026-08-27 ruling (Option A: remove), the three-repo zero-consumer measurement and the re-declare-fresh condition. Dropped because `main` does not state them: "decision-inbox batch 2" and "all four decision facets pointed the same way". "The objectstack-ai#11846 card records the measurement" (objectui leg) now reads "zero consumers, measured when the removal was ruled", which is what the changelog and the test header say. - **One cross-repo id this session cannot read.** `cloud#1651` answers 403 here: `objectstack-ai/cloud` is not attached to this session (`add_repo` refused: no access). It is neither confirmed nor refuted, so its sentence was rewritten from what `main` records about it — `packages/spec/CHANGELOG.md` (`0c2334f`: closed 2026-08-26 with positive controls, `RuntimeMode` zero hits, `ArtifactKernelFactory` 20+ hits and never touching `previewMode`) and `context.zod.ts` (`OS_PREVIEW_MODE` there is routing-only). The cloud-side detail `main` does not state — `previewMode` "only as a local variable" whose effect is adding wildcards to "CSRF" trusted origins — is dropped; the parenthesis that replaces it describes this repository's own `serve.ts` (the one reader of `OS_PREVIEW_MODE` here only widens better-auth's trusted origins to preview-domain wildcards), which is measured on `main`. - **Decision-batch numbers went too (invisible to the regex).** Nineteen sites cited a decision batch as `#` plus two or three digits (`objectstack-ai#43` ×13, `objectstack-ai#115` ×4, `objectstack-ai#151` ×1, `objectstack-ai#158` ×1). They are numbers an author is shown and cannot follow, so each is dropped. One consequence worth naming: 13 entries said `Maintainer ruling B on objectstack-ai#14478 (2026-09-02, decision batch objectstack-ai#43)`, which fused two rulings on the same card — B of 2026-09-02 (the gate and the no-baseline rename) and B of 2026-09-05, decided in that batch (the population: every authored and every runtime-emitted duration, minus schema-declared exemptions). The sentence now names both dates. The `objectstack-ai#158` sentence (the agreement shape ruled an offence on 2026-09-18) is corroborated by `.changeset/18075-agreement-shape-is-an-offence.md` on `main`. - **"issue NNNN" / "PR NNNN" spellings, checked by hand.** No bare-number spelling exists in these 26 entries' author-shown text; the two `PR` citations (`PR objectstack-ai#6404`, `PR objectstack-ai#9061`) were `#`-spelled, so the instrument saw them and they are gone. The only `#` left in these 26 files is on `//` comment lines (sibling card's surface), including a `Prime Directive objectstack-ai#13` reference. - **A citation whose page says something narrower than the text.** `kernel-health-check-and-hot-reload-durations-unit-in-key` called `shutdownTimeout`'s shape "the objectstack-ai#14519 unit-nowhere shape". `objectstack-ai#14519`'s keys carried their unit in the JSDoc; "unit nowhere" is the gate's name for it (`check-duration-unit-keys.ts` header: "no unit ANYWHERE (the objectstack-ai#14519 shape)"), because the gate did not read JSDoc. The sentence now says what the shape is — no unit in the name or in the published describe — and that it was first measured on two tenant timeouts. - **A comment that my text edit makes slightly stale.** `18.system-metrics-window-durations-unit-in-key.ts` carries a `//` comment saying its acceptanceCriteria sentence "is objectstack-ai#15679's, left word for word". That sentence now says "that JSDoc-channel gap is filed as a finding of its own" where it said "is objectstack-ai#15939": same content, no number. The comment is the sibling card's surface (comment lines), so it is untouched here. - **Three "card" references re-anchored.** Removing an id left "the same card" in the Turso entry pointing at nothing; it now says "the same measurement". The kernel entries' "renamed by this same card" carry no number and were not otherwise rewritten, so they are left. - **Cross-PR check: no open PR adds or edits a `driver-`, `kernel-` or `system-` semantic entry.** Read at 2026-09-28T04:0xZ: the 18 open PRs' file lists (`GET /pulls/{n}/files`) carry 0 files matching `migrations/entries/semantic/NN.(driver|kernel|system)-*`. The Version Packages PR (`objectstack-ai#17076`) lists more than 1,000 files; the 1,100 rows read carry no entry file, and it is the bot-generated release PR. Nothing in flight will be held by the widened pin on arrival. - **`main` moved 4 commits past the base** (`862b6ce869`: `objectstack-ai#20364`, `objectstack-ai#20341`, `objectstack-ai#20366`, `objectstack-ai#20352`); none touches `packages/spec/src/migrations/` or the pin. A driver-free bare-clone `merge-tree --write-tree` of this head against `862b6ce869` exits 0 with no conflicted path, so `registry.ts` needs no merge, and `main` was not merged in. - **Generated projections** (`spec-changes.json`, `docs/protocol-upgrade-guide.md`) are regenerated, as in stages 1 and 2; their `--check` legs are green. Only the three `driver-` entries registered at protocol 17 appear in them, which is why those diffs are small. - **No other test pins these entries' text.** A `git grep` of test files for the 26 entry ids finds one (`preview-mode-retirement.test.ts`), which names the entry in a comment and reads no prose; a grep of tests for the 37 cited numbers finds only comment lines. So no test needed re-pinning this stage (stage 2's `migrations.test.ts` case has no counterpart here). ## Line budget Entry files: **352 changed lines** (+228 / −124) across 26 files, against the stage-1 ≈400 budget. The whole diff is **776 lines** (+516 / −260) in 31 files. Of the rest, `registry.ts` is 352, the two projections are 18 (`spec-changes.json` 12, the upgrade guide 6), the widened pin is 33 and the changeset is 21. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…(ADR-0049) (objectstack-ai#20398) Fixes objectstack-ai#20323 Clause-②: yes ## What this does Retires `action.aria` under ADR-0049 enforce-or-remove, following the triage direction on the card (RETIRE, on the chart-config precedent `2bf6ef18d`). No maintainer word reversed it to ENFORCE. - **Schema.** `ActionSchema.aria` becomes a `retiredKey()` tombstone. It is a `tsc` error (the input type is `never`) and a parse error that carries the prescription. The accessible name that IS applied is the action's required `label`, and the placing node's `aria` block (`page.components[].aria` or the list view `aria`) names the region. `AriaPropsSchema` is untouched: this is a key retirement, not a def retirement. - **ADR-0087.** The D2 conversion `action-aria-removed` (protocol 18, `retiredFromLoadPath`) strips the key from `actions[]` and `objects[].actions[]` as a lossless delete. The D3 semantic entry `action-aria-retired` is its own family, per the one-entry-per-family rule. The retired key `ui/Action:aria` is registered under 18, the major of the chart-config sibling. - **Ledger.** `liveness/action.json` regrades `aria` from `live` to `dead`. Its `REMOVED` note records that the uncited 「PARTIAL — honored by a few objectui renderers」 claim had no reader behind it. The undrilled-container row `action/aria` goes, and `state-counts.md` is regenerated. - **Docs.** Two hand-written pages taught `aria` on an action and are corrected: `protocol/objectui/actions.mdx` and `widget-contract.mdx`. The three reference pages that render `ActionSchema` are regenerated. - **Changeset.** `minor`, BREAKING, with `Clause-②: yes`, the FROM → TO table and the ADR-0087 `registered` marker. ## Premise, re-measured on this checkout - **objectui at the `.objectui-sha` pin `f8a9d0fb05`.** A reader grep for an action's `aria` (`action`, `actionDef`, `def`, `spec`, `btn`, `a`, `act`, `item` followed by `.aria`) over `packages/**` and `apps/**` non-test sources hits **0** lines. The control, the same grep for `.variant`, hits **18** files. Every `schema.aria` reader there is a placing node: the `record:*` components, `ListView`, `ObjectView` and `element:button`'s props. - **Icon-only reversal condition.** Triage named "icon-only actions have no accessible name" as the condition that would reverse this to ENFORCE. It does not hold. `action-icon.tsx:243` renders `aria-label={schema.label || schema.name}`, `action-menu.tsx:341` renders `aria-label={schema.label || moreActionsLabel}`, and `action-button.tsx:346` renders `{schema.label}` as the visible text. - **Framework.** A grep for `.aria` over `packages/**` non-test TS outside `packages/spec` hits **0** lines. `action.form.ts` has 0 `aria` rows; its one hit is the `variant` substring. - **Authors in this repo.** Across `examples/**`, the published `skills/**` and `packages/**` fixtures, **0** actions author `aria`. The control is **15** `variant:` lines in `examples/**`. - **Authors in HotCRM `2f7b2326`.** **0** actions author `aria`. Its 6 `aria:` blocks are all page-level `page.aria`, which stays live. The control: 7 files under `src/**/actions/` declare `locations:`, 17 times. - **Served schema.** `metadata-protocol` drops a tombstone's `{ not: {} }` node from the served JSON Schema, via `stripUnauthorableProperties`. So the Studio "More fields" form stops offering `aria` once this ships. - **Pinned sibling.** objectui's `ActionRunner.ts:410` mirrors the key as `aria?: SpecActionInput['aria']`. A type probe against this branch's built `dist` compiles that mirror at exit 0, because it evaluates to `undefined`. An authored block on it is refused with TS2322, and the control leg without `@ts-expect-error` exits 2. So the Console Pin Gate's objectui build is not broken by this change. ## Hand-over review (the stopped run's six commits, read hunk by hunk) | commit | verdict | | --- | --- | | `0c5dbbff` sources, ledger, tests, changeset | **kept, with corrections.** The prescription said "removed in @objectstack/spec 17"; it now says `17.5.0`, the spelling every sibling retirement on this line uses. The refusal pin in `action.test.ts` now also asserts the tombstone's own issue kind (`invalid_type` at `aria`, and no root `unrecognized_keys`, which is what a bare deletion would answer instead). The changeset's BREAKING sentence now names the replacement. The HotCRM control is re-measured: 7 files and 17 lines, not "5 action files". | | `6334c137` regenerated artifacts | kept, and re-derived after both merges | | `bfc86375` the stored-row pin uses a parseable script action | kept | | `0f20c62f` the `action/aria` undrilled-container row goes | kept; `check:liveness` is green | | `8cbcfa46` merge of `main` | kept | | `90b8fcdc` regenerated state counts | kept; superseded by the post-merge regeneration | Added in this round: - `33407119` and `c38c18ae` merge `origin/main` through `scripts/pm/os-regen-merge.sh`. Both merges stopped on the two registries. Each was settled by stacking both sides: main's conversions and rationale paragraphs first, then this branch's. The merges brought in objectstack-ai#20262, objectstack-ai#20352, objectstack-ai#20251 and objectstack-ai#20353. - `2b15085d` and `7faf0e9f` are the deferred regenerations. - `68b038ef` carries the corrections above, plus two more: - a new pin that the stored-row seam reaches an `object` row's nested action (the changeset's second at-rest coordinate); - one sentence on the `action` row of `liveness/README.md`. That row said "makes the dead set three", which this change makes false in a published file (`liveness` is in `@objectstack/spec`'s `files`). - `8dd3a2ce` regenerates the reference pages for the 17.5.0 prescription. ## Verification Head `7faf0e9f`, on `origin/main` `15bf186f`: - `pnpm --filter @objectstack/spec test`: `Test Files 558 passed (558)` · `Tests 16509 passed | 1 todo (16510)`. - `pnpm --filter @objectstack/spec test:repo`: `Test Files 35 passed (35)` · `Tests 634 passed (634)`. - `pnpm --filter @objectstack/spec check:generated`: all 15 generated artifacts up to date, measured over a spec build made on this head. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` derives 114 commands. All 114 exit 0, and `--ran` reconciles them as `114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN`. The run covers every package's build closure, rebuilt on this head, so the gates that read `dist` measured it. Head `8dd3a2ce`, before the second `main` merge, on `origin/main` `862b6ce8`. The second merge touched none of these packages' interaction with this diff; the incoming commits retire other keys and touch no action surface. | package | command | Test Files | Tests | | --- | --- | --- | --- | | `@objectstack/lint` | `vitest run` | 112 passed | 4640 passed | | `@objectstack/cli` | `vitest run --project unit` | 231 passed | 3309 passed | | `@objectstack/runtime` | `vitest run --project local` | 282 passed | 4059 passed, 1 skipped | | `@objectstack/metadata-protocol` | `vitest run` | 189 passed, 3 skipped | 2736 passed, 19 skipped | | `@objectstack/metadata-core` | `vitest run` | 16 passed | 285 passed | | `@objectstack/objectql` | `vitest run --project local` | 322 passed | 5857 passed | `pnpm --filter @objectstack/spec typecheck` was green at the same head. The cli `integration` layer is declared to CI: this diff touches no spawn entry and no integration file. **Ablation**, from the committed state at `8dd3a2ce`. The blobs of `action.zod.ts` and of the three pin files are byte-identical at `7faf0e9f`. - The mutation goes through `scripts/ablation-replace.mjs`: the anchor ` aria: retiredKey(` becomes ` aria: z.any().optional().describe(`, so the key is accepted again. The tool reports the anchor going from 1 to 0, the replacement from 0 to 1, and the blob from `2e0a17b14b06` to `45e6bd9b1156`. The wrapper also arms a `trap` that restores the file. - The control leg runs the three pin files on the committed state: `Tests 166 passed (166)`. - The mutant leg: `Tests 4 failed | 162 passed (166)`. The four red tests: - `action.test.ts` · refuses an action carrying `aria`; - `aria-carrier-tombstones.test.ts` · the action tombstone fires and prescribes; - `aria-carrier-tombstones.test.ts` · the object-nested coordinate; - `action-aria-removed.test.ts` · the stored-row seam. - The restore is proven by content, not by an exit code. The file's blob equals HEAD's blob `2e0a17b14b06`, `git diff HEAD` is empty, and the porcelain status has 0 lines. - The pins import `./action.zod` and `../ui/action.zod.js` relatively. They read `src`, not `dist`, so this ablation has no dist leg. ## Acceptance notes - **objectui, owned by seat 4 after landing; not in this PR.** `ActionDefaultInspector` should list `aria` in its `RETIRED_FIELDS`, per triage note 3. `ActionRunner.ts:410` should drop its `aria?: SpecActionInput['aria']` mirror, which evaluates to `undefined` once this ships and still compiles (the probe above). - **objectstack-ai#19332, owned by seat 4 after landing.** The disposition of `action.aria` goes to objectstack-ai#19332's item that waits on this card. - **Advisory lint.** No `lint-liveness-properties` non-warn pin is added. The `aria` row never carried `authorWarn`, so the advisory lint's behaviour is unchanged: it was silent before and is silent now. - **Aliases.** `ActionSchema` never aliased `accessibility` or `ariaProps` onto `aria`, unlike the chart config. A probe shows both already refused as `unrecognized_keys`, so no alias refusal pin is owed. --- _Generated by [Claude Code](https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…uired key left out, or a decision branch list it cannot read — at all three doors (objectstack-ai#20316) (objectstack-ai#20416) Fixes objectstack-ai#20316 Clause-②: no (narrowing) A flow node config its executor cannot run is now refused where the flow is built, at all three build doors (`FlowSchema.parse`, `AutomationEngine.registerFlow`, `objectstack validate`), by ONE judge: `flowNodeConfigRefusals` in `@objectstack/spec/automation`. It covers the whole family; objectstack-ai#20317 is folded into this card: - **a key a builtin's executor contract requires, left out** — the census below, `loop.collection` (objectstack-ai#20317) and `map.collection` among them; - **a `decision` branch list its executor cannot read** — a branch with no `label` (the card's shape), a branch that is not an object, and `conditions` that is not an array. ## What was wrong, measured on `origin/main` `a88a1bb399` before the change Probe scripts drive door 1 (`FlowSchema.safeParse`, spec `dist`), door 2 (`AutomationEngine.registerFlow` with `installBuiltinNodes`, then `execute`), and door 3 (the BUILT CLI, `node packages/cli/bin/run.js validate --json` in a stack directory holding one `objectstack.config.ts`). The run leg swaps the two marker nodes `y` / `x` for a recording executor, so the route taken is observable. The decision has out-edges `d → y` labelled `yes` and `d → x` with `isDefault: true`. | lit control | door 1 | door 2 | door 3 | run | |:--|:--|:--|:--|:--| | (a) decision branch `{ expression: 'true' }`, no `label` | `success=true` | registered | `valid: true`, exit 0 | `success=true`, visited `["x","y"]`: EVERY out-edge | | (a) control: `{ label: 'yes', expression: 'true' }` | `success=true` | registered | `valid: true`, exit 0 | `success=true`, visited `["y"]` | | (b) `conditions: ['true']` | `success=true` | registered | `valid: true`, exit 0 | `success=false`, `condition evaluation error: A structural condition …` | | (c) `loop` with `iteratorVariable` and a one-node body, no `collection` | `success=true` | registered | `valid: true`, exit 0 | `success=false`, `loop 'l': config does not satisfy the loop contract — config.collection: Invalid input` | | (c) control: the same loop with `collection: [1, 2]` | `success=true` | registered | `valid: true`, exit 0 | `success=true`, the body ran twice | | (d) `map` with `flowName`, no `collection` | `success=true` | registered | `valid: true`, exit 0 | `success=false`, `map 'm': config does not satisfy the map contract — config.collection: Invalid input` | Further shapes measured on the run leg only: `conditions: {}` and `conditions: 5` fail with `conditions is not iterable`; `conditions: 'x'` fails with a condition evaluation error, because a string is iterated character by character; a `null` branch fails with `Cannot read properties of null`; `label: null`, `''`, `0`, `' '` and `42` all run green down both out-edges. The last two also log the engine's unclaimed-label warn. `conditions: null` routes like no `conditions` at all, which is legal and stays admitted. ## After, measured on the merged branch (spec `dist` built from this head; the CLI reads it at run time) | lit control | door 1 | door 2 | door 3 | |:--|:--|:--|:--| | (a) no `label` | `custom` at `nodes.1.config.conditions.0.label` | throws the same issue | `valid: false`, exit 1, `custom` at `flows.0.nodes.1.config.conditions.0.label` | | (a) control | `success=true` | registered, run visits `["y"]` | `valid: true`, exit 0 | | (b) `conditions: ['true']` | `custom` at `nodes.1.config.conditions.0` | throws the same issue | `valid: false`, exit 1, same path | | (c) loop, no `collection` | `custom` at `nodes.1.config.collection` | throws the same issue | `valid: false`, exit 1, same path | | (c) control | `success=true` | registered, body ran twice | `valid: true`, exit 0 | | (d) map, no `collection` | `custom` at `nodes.1.config.collection` | throws the same issue | `valid: false`, exit 1, same path | ## The fix: one judge, three doors - `packages/spec/src/automation/flow-node-config-refusals.ts` (new). It lives beside the walk file, not inside it, because it reads the executor contracts, and two of their modules import `flow-node-expression-paths.ts`. A static import there closed a cycle that read `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` mid-evaluation (measured: `z.toJSONSchema(undefined)` in 4 spec tests). - `getBuiltinNodeConfigContracts()` maps a node type to the very schema its executor hands `parseNodeConfig`, plus `loop`'s parse condition (a legacy flat-graph `loop` with no `body` is not parsed, so it stays exempt). It is built on first use, never at module load. - `flowNodeConfigRefusals(nodeType, config)` has two arms: - **Contract arm.** It parses `config ?? {}` against the contract, on the executor's own condition, and keeps an issue ONLY where the key it names is absent from what was authored. A present value of the wrong type, or an undeclared key, stays where it is judged today. It also skips issues inside an ADR-0031 region (walked as its own graph) and inside a ledger `value` slot (`fields.*`, `assignments.*`), where a malformed envelope is the value-envelope pass's finding. A plain requirement gets `node-config-key-missing` with a prescription naming the key and the node type. A requirement a rule of the contract states gets `node-config-key-required-by-rule`, carrying the contract's own message: a `notify` with no `template` needs a `title`, and a `lookup` screen field needs its `reference`. - **Decision arm.** `decision` is parsed by nothing at run time, so this arm states what its executor reads: `conditions` present and not `null` is an array (`decision-conditions-not-array`); every branch is an object (`decision-branch-not-object`); every branch's `label` is a non-blank string (`decision-branch-label-missing`, with `found`: `absent`, `null`, `blank`, or the kind). - `packages/spec/src/automation/flow-node-expression-paths.ts`: the five new codes join `FLOW_SLOT_REFUSAL_CODES` and `FlowSlotRefusalParams` in objectstack-ai#20352's shape (a stable kebab-case `code` and typed `params` beside the message). `FlowNodeConfigRefusal` adds the `path` inside `config`. One walk fix: an ARRAY element is no longer read as an object missing its slot, so `conditions: [['true']]` gets one refusal (a branch that is not an object), not also a missing `expression`. - `packages/spec/src/automation/flow.zod.ts`: a `FlowSchema.superRefine` block calls the judge for every node, walked with `collectFlowGraphs`, so a node in a region body is anchored where the author wrote it. It is a presence rule, not a key-set closure. - `registerFlow`: no engine change. It parses first (`canonicalizeStoredFlow` → `FlowSchema.parse`), after the ADR-0087 conversions, so the parse's issue is what it throws. - `packages/lint/src/validate-expressions.ts`: `validateStackExpressions` calls the same judge, for a stack handed to it with no parse in front. One exception: a `script`'s absent `function` stays the existing callable check's finding. That check reads the pre-conversion spellings this pass may be handed (the `functionName` alias, the retired dispatch keys) and names each. **Route choices.** 1. **The judge parses the executor's contract and keeps absences. It is not a hand-written table of required keys.** Business need: the census is 18 keys over 13 node types, and the contract already states every one of them, rule-required keys included. Long-term design: a table beside the contracts would be a second statement of them, free to drift; the ratchet below reads the executors' own parse calls instead. Guarding AI authors: both forms refuse loudly, but this one uses the contract's own words where the contract has them. No scope growth: keeping absence only leaves every other contract finding exactly where it was. 2. **The label rule is "a non-blank string", wider than "the key is absent".** Every one of those values sends the matched branch down every out-edge (measured above), and the repo already has one notion of blank (`NON_BLANK_STRING`). Refusing only the absent key would leave `label: ''`, which misroutes identically. 3. **A non-array `conditions` is refused as well as a non-object branch.** It is the container of the same branch walk, and every non-null non-array value fails the run (measured above). ## Acceptance notes ### Family census: every key a contract-parsing builtin's executor requires Enumerated from the code: every `parseNodeConfig(…)` call under `service-automation/src/builtin/`, cross-checked against the expression-path ledger's required-key reconciliation. The channels require `loop.collection` and `map.collection`, which that ratchet had recorded as "no door refuses their absence". It now asserts the judge refuses both. Door status was measured by the probes above on `a88a1bb399` (before) and on this branch (after), each row beside its whole-config accept control, which is admitted at every door both before and after. | node kind | key left out | runtime reader | before: parse / register / validate | after: all three doors | |:--|:--|:--|:--|:--| | `get_record` | `objectName` | `crud-nodes.ts:264` | admit / admit / admit | refused at `config.objectName` | | `create_record` | `objectName` | `crud-nodes.ts:335` | admit / admit / admit | refused | | `update_record` | `objectName` | `crud-nodes.ts:484` | admit / admit / admit | refused | | `delete_record` | `objectName` | `crud-nodes.ts:578` | admit / admit / admit | refused | | `notify` | `recipients` | `notify-node.ts:250` | admit / admit / admit | refused | | `notify` | `title` (no `template`; rule) | `notify-node.ts:250` | admit / admit / admit | refused, in the contract's words | | `http` | `url` | `http-nodes.ts:112` (parsed after interpolation; interpolation never adds a key) | admit / admit / admit | refused | | `screen` | `fields[i].name` | `screen-nodes.ts:158` | admit / admit / admit | refused at `config.fields.i.name` | | `screen` | `fields[i].options[j].value` / `.label` | `screen-nodes.ts:158` | admit / admit / admit | refused | | `screen` | `fields[i].reference` on a `lookup` field (rule) | `screen-nodes.ts:158` | NOT MEASURED before (same arm) | refused, in the contract's words (spec pin) | | `script` | `function` | `screen-nodes.ts:321` | admit / admit / REFUSE (the lint callable check) | refused at all three | | `subflow` | `flowName` | `subflow-node.ts:71` | admit / admit / admit | refused | | `map` | `collection` | `map-node.ts:96` | admit / admit / admit | refused | | `map` | `flowName` | `map-node.ts:96` | admit / admit / admit | refused | | `loop` (with `body`) | `collection` | `loop-node.ts:85` (the no-`body` form returns at `:72` unparsed) | admit / admit / admit | refused | | `parallel` | `branches` | `parallel-node.ts:68` | admit / admit / admit | refused | | `try_catch` | `try` | `try-catch-node.ts:100` | admit / admit / admit | refused | | `decision` | a branch `label` (absent, `null`, blank, non-text) | `logic-nodes.ts:48-74` (`branchLabel: cond.label`) | admit / admit / admit | refused at `config.conditions.i.label` | | `decision` | a non-object branch; a non-array `conditions` | `logic-nodes.ts:48-51` | admit / admit / admit | refused | Controls that stay admitted: a legacy `loop` with no `body` and no `collection` (it still runs); a decision with no `conditions`, `conditions: null` or `[]`; `assignment`, `wait` and plugin node types; a present value of the wrong type (`objectName: 42`); an undeclared key. Outside this census's definition (no `parseNodeConfig`), measured, **not fixed here**: `connector_action` with no `connectorConfig` is admitted at all three doors and refused at run (`connectorConfig.connectorId and .actionId are required`). It belongs to the same family, but its input is a FlowNode sibling block, like `wait`'s `waitEventConfig`, which `FlowSchema` already requires. Reported to the seat. ### Producer census - `examples/**` at this head: app-crm 1 flow, 8 nodes; app-showcase 30 flows, 139 nodes; app-todo 4 flows, 26 nodes. **0 refusals.** app-multi-package declares no flows. - `packages/**`, non-test: no default flow carries a judged node. The Studio create seed for `flow` has `nodes: []`. - objectui at the `.objectui-sha` pin `f8a9d0fb` (not edited): - `FlowObjectListField` `rowsToList` drops a blank cell. So a decision branch row with an empty **Label** cell is written as `{ expression }`, which is now refused (`decision-branch-label-missing`). The same writer serves the screen **Fields** repeater, so a field row with an empty **Name** cell is written without `name`, also refused. - `previews/flow-canvas-parts.tsx` `defaultNodeExtras` seeds a new node with no config, or a partial one: `http` gets `{ method: 'GET' }` (no `url`), `notify` gets `{ channels, recipients: [] }` (no `title`), and CRUD / `script` / `subflow` / `map` / `parallel` / `try_catch` get nothing. So a node added and saved before it is configured is now a loud save error. The designer's live Zod pass (`clientValidation.ts` → `FlowSchema`) will locate it at the node's config path once objectui takes this spec. - cloud: NOT MEASURED (no checkout in this container; the dispatch named examples, packages and objectui). ### Fixture triage (disposition per fixture, never a batch rename) - **Completed** (the fixture was never runnable; it now carries the key its executor requires): skeleton flows in `spec` `flow.test.ts`, `region-normalization.test.ts`, `flow-region-pause-and-end.test.ts`, `api/zod-issues-to-fields.test.ts`; `service-automation` `engine.test.ts` and 7 sibling files where `script` / `notify` / `map` nodes stand in for mock executors; `runtime` `automation-put-post-error-parity`, `automation-register-error-class` and `automation-flow-clone`; `types` `validation-failure.test.ts`; `verify` `automation-trigger-terminal-messages.test.ts`. The two `collectFlowGraphs` scope and path pins now chain through `try` as well as `catch`, because a `try_catch` without `try` is refused. - **Re-judged in place** (the pin was about the executor's refusal of a missing key, which the build door now answers first): `config-parse.test.ts` (7), `guard-refusal-inventory.test.ts` (7 rows), `http-nodes`, `notify-node` (2), `subflow-node`. Each now asserts the door refusal, then registers the node WHOLE and strips the key from the stored flow, so the executor's guard is still pinned. `screen-nodes.test.ts`: a stored `script` carrying only retired dispatch keys no longer registers (`leaves out function`), rather than registering and refusing at run. `conversions.test.ts`: the flow parse is still blind to the tombstones, and now refuses the stripped node for its absent `function`. - The lint's `script` callable tests are unchanged. That check keeps a `script`'s `function`, as described above. ### Observed, not filed - A pre-conversion alias spelling (`object`, `flow`, `functionName`, `to` / `subject`) is refused as the canonical key's absence at a DIRECT `FlowSchema.parse` / `defineFlow()`. `registerFlow`, `objectstack validate` and `defineStack` convert first, so no measured producer meets it. Carrier: whoever retires those D2 aliases at 18 (the object alias's own docblock says it retires then); otherwise none. - A present value of the wrong type (`objectName: 42`, `collection: ''`, `function: ''` outside the lint) is still admitted at the build doors and refused at run. That is the same family's type half, which this card's direction scoped out. Reported to the seat. ## ADR-0087 - One semantic D3 entry for the family, `flow-node-config-required-keys-refused` (major 18). It uses form D: the decision is stated in words, with no tracker numbers in author-shown text. `registry.ts` is regenerated. - **No D2 conversion.** No lossless repair exists: the platform cannot know the object, URL, collection, function or out-edge label that was left out, and no value it could write would keep what the flow did. - Changeset `.changeset/20316-flow-node-config-required-keys-refused.md`: `@objectstack/spec` and `@objectstack/lint` `minor`, BREAKING, with the FROM → TO table. `service-automation` gets none, because its diff is test files only. ## Pins: each refusal at each door, with the lit control - `spec` `flow-node-config-required.test.ts` (`FlowSchema.parse`): - every census row, refused at its key with the judge's own message, beside its whole-config accept control; - the two rule-required keys, in the contract's own words; - region nesting; - the decision arm: six label values, four non-object branches, three non-array `conditions`; - controls: legacy loop, wrong type, undeclared key, value slot, `assignment` and plugin types, no-branch decisions. - `spec` `flow-slot-refusal-codes.test.ts`: one pin per new code (code, params, full message), the closed set split over three producers, a sweep that reaches every node-config code, and type-level pins. - `service-automation` `node-config-required-keys.test.ts` (`registerFlow`): - the same census, and the decision arm; - on the real executors: the refused shapes failed the run, and a matched branch with no label ran GREEN down both out-edges while its labelled twin took one. - `service-automation` `builtin/node-config-contract-ledger.test.ts`: the ratchet. The map equals every executor's `parseNodeConfig` call, schema by identity; `loop` alone parses on a condition; every builtin type is classified. - `service-automation` `config-expression-ledger.test.ts`: the cross-check described above. - `lint` `validate-expressions.test.ts` (`validateStackExpressions`): the same judge through the third door's pass. ## Ablation: the pins can fail The ablation ran once, on the committed state `7f3b9b6742`, in one `os-verify-lock` hold. It went through `scripts/ablation-replace.mjs` in wrap mode, with the restore trapped on EXIT, INT and TERM. - **Mutation.** `flowNodeConfigRefusals` returns `[]` unless a global named `ABLATION_20316_OFF` is set. The anchor went from 1 hit to 0, and the blob from `bd1d620a25b6` to `353f92191f9d`. After `pnpm --filter @objectstack/spec build`, `ablation-dist-preflight.mjs @objectstack/spec ABLATION_20316_OFF` found the marker in 20 built files. - **Red, in the expected direction:** - spec, the two pin files: 41 failed, 35 passed. Every refused row at `FlowSchema.parse` went red, as did the five new-code pins and the sweep. Every accept control and CONTROL block stayed green. - service-automation, three files: 21 failed, 47 passed. Every refused census and decision row at `registerFlow` went red, as did the ledger cross-check. The accept controls, the run-time "what it did" pins and the contract-ledger ratchet stayed green. - lint `validate-expressions.test.ts`: 7 failed, 345 passed. The new rows went red; the callable-check rows stayed green. - **Restore leg.** The blob is back to `bd1d620a25b6`, equal to HEAD, and `git diff HEAD` is empty. After a rebuild, `--absent` found the marker in none of the 222 built files, and the working tree is clean. Then spec 76/76, service-automation 68/68, lint 352/352. ## Verification Branch head `8171d8537e`: `origin/main` `15bf186f50` merged as `725ffa325e`, plus test-only commits after it. Every heavy run went through `os-verify-lock`, on a closure built from the merge head (`turbo run build --filter=@objectstack/cli...`, 59/59). The box was shared. - **spec**, full suite, both projects: 593 files, 17196 passed, 1 todo. Run on `725ffa325e`; no spec source changed after it. - **service-automation**, full: 149 files, 1818 passed. Run on `725ffa325e`; no source changed after it. - **lint**, full: 113 files, 4712 passed, on `7f3b9b6742`. `validate-expressions.test.ts` again on `8171d8537e`: 352/352. - **runtime** `src/domains`: 77 files, 1436 passed, on `7f3b9b6742`. - **Smaller suites.** types: 23 files, 692 passed. verify: 15 files, 116 passed. metadata-protocol, its 10 flow files: 207 passed. plugin-approvals, its 5 flow files: 344 passed. cli `--project unit`, its flow-building files: 8 files, 112 passed. - **Typecheck** exits 0 for spec (test layer included), service-automation, lint, runtime, types and verify. - **Gates.** `dispatch-gates.mjs --commands` on `8171d8537e` derives 102 families; 100 exit 0. `--ran` accounts for all 102: 100 run, 2 NOT MEASURED. - `check:dual-build-cjs-loads`: PREREQUISITE NOT MET. 9 packages outside this diff have no `dist/`. - `check:type-check-debt`: its `--re-measure` runs a whole-tree build outside the lock. This diff adds no package. - **eslint.** `eslint --no-inline-config --format json` over this branch's 36 changed `.ts` files: 36 files, 0 errors, 0 warnings. - Population: `eslint.config.mjs` has `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`, and none of the 36 files is ignored. - Invariance: the config sets no `parserOptions.project`, so no linting is type-aware, and this diff cannot move a verdict on an untouched file. - **Declared narrowing.** Consumer packages beyond these ran on their flow-bearing test files only. The rest of the consumer farm is CI's: cli integration tests, dogfood, and the whole suites of runtime, metadata-protocol and plugin-approvals. - **Behind `main`.** The branch is 7 commits behind `origin/main` `7db1332f19`. A merge-tree probe with no merge driver (a bare shared clone) merges clean. `check:migration-registry` on that probe merge reports `registry.ts` current. ## Governed surface `skills/objectstack-automation/references/_index.md` is regenerated by `gen:skill-refs` (byte-equal to the generator). `flow.zod.ts` now reaches `schemaless-node-config.zod.ts` transitively, so the index gains that one dependency row. Readings: that file 42 → 43 lines; the published catalog's `SKILL.md` total 4402 → 4402, unchanged. ## 维护者速读(草稿) - **改了什么**:流程节点的配置如果缺了执行器必需的字段(比如查询节点没写对象名、循环节点没写要遍历的集合、决策分支没写标签),现在保存或校验时就会直接报错,不再等到流程运行时才失败或走错分支。 - **为什么改**:以前这类流程能正常保存、正常发布,运行到那个节点才失败;决策分支没写标签更糟,流程会"成功"但同时走了所有分支,业务上悄悄出错。 - **风险与代价(含回滚)**:已有流程里若有这类残缺节点,升级后会被拒绝注册(启动日志会点名),需要补上字段;Studio 设计器里新加的节点在配置完成前保存会报错。回滚即撤销本 PR。 - **席位意见**: - **你要做的**:无需操作;如需确认,关注 objectui 设计器对"空标签行、未配置新节点"的保存报错体验(另立卡跟进)。 --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20291
Clause-②: yes
Rework round 1
Order of record: the seat's comment
5861789336on #20291. Its instruction is to fold in the file's second refusal producer,structuralConditionRefusal, which round 0 reported as a same-family finding. Claim5860074154is unchanged, and the file is already in its surface. The round adds two commits on top of2a6dee312c(658a8637f5,5581ed26ee); no rebase, amend or force-push.structuralConditionRefusalnow returnsStructuralConditionRefusal: its previous{ message, source }pluscode: 'structural-condition-shape'andparams: { found }.foundis one ofarray,ast-without-source,object-without-source,number,boolean,bigint,symbolandfunction. The message is byte-identical,STRUCTURAL_CONDITION_SHAPE_REFUSALincluded.FlowSlotRefusalParamsand the frozenFLOW_SLOT_REFUSAL_CODES(4 codes) replace round 0'sPredicateSlotRefusalParamsandPREDICATE_SLOT_REFUSAL_CODES. Per-producer code types arePredicateSlotRefusalCodeandStructuralConditionRefusalCode. The two replaced names never reachedmain.predicateSlotRefusalandstructuralConditionRefusal. Its other exported functions areisExpressionEnvelopeShaped(a boolean guard),resolveFlowNodeExpressionsandresolveFlowNodeValueSlots(resolvers returning{ entry, path, value }). The onlymessage:sites in the file sit inside those two producers.structuralConditionRefusal(6 admitted, 15 refused, everyfoundkind).baseisorigin/mainde091b50e6for the 43; for the 21 it is2a6dee312c, where that function was still untouched. The result is 0 diffs.flow-slot-refusal-codes.test.ts. It has one pin per code (4 codes;structural-condition-shapecarries 10 cases, every kind) and pinsSTRUCTURAL_CONDITION_SHAPE_REFUSALagainst a literal. The exhaustiveness pin covers both producers: a sweep per producer, each reaching all of its codes, and the two producers' code sets partitioning the table exactly.@ts-expect-errorcovers a code-lessStructuralConditionRefusaland a structural refusal carrying a predicate-slot code.Expression refusals now carry a stable
codeand typedparamsbeside their Englishmessage. This coversvalidateExpression(every entry inerrors[]andwarnings[]),collectCelRootIdentifiers(itsok: falsearm),predicateSlotRefusalandstructuralConditionRefusal. A localized author surface, such as objectui's flow designer, can now key its own catalogue rows to the code instead of showing our English sentence. This is direction (a) from triage5859522970. The Englishmessageis unchanged byte for byte. Nothing is removed or renamed, so it is a widening (minorfor both packages).The closed set
Codes are kebab-case and never change once published. Each code names one message template, and an optional clause (a did-you-mean, an object name) is an optional param that is present exactly when the English message carries that clause. Narrowing on
codenarrowsparams. The keys ofExpressionRefusalParamsandFlowSlotRefusalParamsare the closed sets. The frozen value listsEXPRESSION_REFUSAL_CODESandFLOW_SLOT_REFUSAL_CODESare held equal to those keys at compile time: each list is read off a code table thatsatisfiesa record keyed by every code, so a missing code or an extra one does not compile.These are authoring diagnostics returned as values, not ADR-0112 request error codes, which is why they are kebab-case.
check:error-code-casingandcheck:dispatcher-error-vocabularyboth pass.Inventory: every refusal each producer emits (at head
5581ed26ee)validate.ts:820non-string envelopesourceenvelope-source-not-textrole,found(array/object/number/boolean/bigint/symbol/function)validate.ts:837template slot, other dialecttemplate-dialect-mismatchdialectvalidate.ts:847template engine refusedinvalid-templatedetailvalidate.ts:856single brace in a templatetemplate-single-bracerefvalidate.ts:862CEL slot, other dialectcel-dialect-mismatchdialectvalidate.ts:911CEL compile failure (the "bare CEL" trailer)invalid-celrole,detailvalidate.ts:901over a parse budgetcel-too-largerole,detail,limit?,limitValue? (both or neither)validate.ts:904bare function called as a methodcel-method-callrole,detail,name,receiver?validate.ts:907name not callable herecel-unknown-functionrole,detail,name,suggestion?validate.ts:909template brace in CELcel-template-bracerole,detail,refvalidate.ts:636unknownrecord.fieldunknown-fieldfield,objectName?,suggestion?validate.ts:786role outside the catalogunknown-rolename,suggestion?,catalog(list)validate.ts:701typo of a declared rootunbound-rootname,roots(list),suggestionvalidate.ts:934bare identifier, record scopebare-referencenamevalidate.ts:277date arithmetic (error)date-arithmeticoperands,reference?validate.ts:293text/boolean against a number (warning)type-mismatchoperands,operator,held(text/boolean),reference?validate.ts:966near-miss of a field, flattened (warning)field-near-missname,suggestion,objectName?validate.ts:1011traversal conflictbare-and-traversedtraversal-bare-and-traversedroot,fieldvalidate.ts:1011traversal conflictmulti-hoptraversal-multi-hoproot,fieldcel-engine.ts:343collectCelRootIdentifiers, emptyempty-expressioncel-engine.ts:364collectCelRootIdentifiers, parse fault (bounds included)cel-parse-faileddetailflow-node-expression-paths.ts:630absent ornull, required slotpredicate-slot-missingfound(absent/null)flow-node-expression-paths.ts:647blank after trimpredicate-slot-blankflow-node-expression-paths.ts:672not textpredicate-slot-not-textfound(envelope/array/object/number/boolean/bigint/symbol/function)flow-node-expression-paths.ts:858structuralConditionRefusal, neither text nor an envelope with a stringsourcestructural-condition-shapefound(array/ast-without-source/object-without-source/number/boolean/bigint/symbol/function)detailis the CEL or template engine's own diagnostic. It is English and, for CEL, includes a source excerpt. It is passed through as the message carries it.Carriers: the least-breaking option on each producer
validateExpression:ExprValidationErrorgainscodeandparams. It becomes a type alias ({ message, source }intersected with the correlated code/params union), no longer an interface. No repo consumerextendsit or constructs one (git grep ExprValidationErrorhits onlyindex.tsand two test comments). All construction goes through one internalrefusal(code, params, source, message)helper, which is type-checked against its own code's params.collectCelRootIdentifiers: its refusal was a plain string ({ ok: false; error: string }).errorstays, byte for byte, and the failure arm gainscodeandparamsbeside it (CelRootIdentifiersResult). Every consumer reads.ok,.rootsand.error, and all of those are untouched.predicateSlotRefusal: returnsPredicateSlotRefusal, its previous{ message, source }pluscodeandparams.structuralConditionRefusal: returnsStructuralConditionRefusal, its previous{ message, source }pluscodeandparams(round 1).The control: the English message is unchanged
foundkind. It was run throughorigin/mainde091b50e6before the edit and through the branch after it.message,source,errorandokare identical on all 43 cases (0 diffs).expression-refusal-codes.test.ts, 21 codes;flow-slot-refusal-codes.test.ts, 4 codes). Each asserts the code, the params exactly, and the message with an exact-stringtoBe. Where a message embeds an engine diagnostic, the expected string takesdetailfrom the engine itself (celEngine.compile/templateEngine.compile). That way the pin covers our words and the pass-through, never cel-js's wording.Exhaustiveness pin, and the ablation
PINSis typed{ [C in Code]: [Pin, ...Pin[]] }, so a code without a pin is a type error. At runtime,Object.keys(PINS)equals the code list.validateExpressioncalls (3 roles × 117 inputs × 7 schema hints) and thecollectCelRootIdentifierssweep make assertions about every refusal they provoke. Each one carries a code from its producer's set. Each param value (list elements included) appears in the message, so code/params and message cannot drift apart. Each producer's sweep reaches every code that producer owns, a positive control against a vacuous sweep. The spec side sweeps 25 slot values through each of its two producers the same way.code/params, and one with another code's params, carry@ts-expect-error. Both layers are compiled (tsconfig.test.json,check:test-typecheckOK), so an unused directive would be red.scripts/ablation-replace.mjswith its restore armed, plus an outertrap … EXIT INT TERM): Therefusal()helper was mutated to dropcodeforbare-referencealone. It simulates one refusal path shipping without a code.return { source, message, code, params } as ExprValidationError;went from 1 hit to 0, the replacement from 0 to 1, and the blob from2e753a6e8a15tob90d3d475acd. The markergrep -cread 1 inside the run.expression-refusal-codes.test.tsgave 2 failed, 24 passed (26). The two reds are thebare-referencepin (expected undefined to be 'bare-reference') and the sweep (undefined from "amount * 2"). The other 20 per-code pins stayed green, so the red is specific.2e753a6e8a15equals the HEAD blob,git diff HEADis empty, and the marker count is 0../validaterelatively, so the subject resolves fromsrc/. No packageexportshop and no vitest alias are involved, so no build ordist/preflight applies.Every consumer, and why it still compiles and behaves
git grepover the repo for each producer:validateExpression:packages/lint(validate-expressions.ts,validate-null-guards.ts,validate-visibility-predicates.ts,scripts/check-doc-formula-expressions.mjs),packages/mcp(mcp-http-tools.ts),packages/services/service-automation(engine.ts). Each reads.ok,.errors[].message,.errors[].sourceand.warnings[], and none constructs an error object. Thevalidate_expressionMCP tool forwardserrors/warningsas they are, so its answer entries now also carrycodeandparams, an additive widening of that tool's payload that the changeset states.mcp-validate-expression.test.tsassertstoHaveLengthand message regexes only.collectCelRootIdentifiers:packages/lint(4 sites),packages/objectql(rule-validator.ts×2),packages/plugins/plugin-approvals(approval-service.ts). They read.ok,.rootsand.erroronly.predicateSlotRefusal:packages/lint(validate-expressions.ts:1422) andpackages/services/service-automation(engine.ts:9539) read.messageand.source.structuralConditionRefusal:packages/lint(validate-expressions.ts:1582) andpackages/services/service-automation(engine.ts:9458,engine.ts:10766) read.messageand.source. Existing tests asserttoBeUndefined/toBeDefined/.message/.sourceonly. The tests that compare against it do so relationally (toBe(predicateSlotRefusal(v)!.message))..objectui-sha(f8a9d0fb05) and at itsmain:screen-spec.tsandflow-sim-validate.tscall the producers and read.message/.error.flow-sim-validate.ts:162readsstructuralConditionRefusal(condition).message. No objectui test deep-equals a refusal object. A widening cannot break the Console Pin Gate's build.toEqual/toStrictEqualon these results finds onlytoEqual([])).Measured at head
2a6dee312c:@objectstack/lint, whose closure is spec, formula and sdui-parser (all rebuilt):pnpm --filter @objectstack/lint typecheck: OK (tsc and the test layer).flow-variable-scope,validate-expressions,validate-expressions-nonstring-source,validate-expressions.fields-value-slot,validate-null-guards,validate-visibility-predicates,validate-approval-approvers).check:doc-formula-expressions, which runsvalidateExpressionover every documented formula, passed.NOT MEASURED locally, declared to CI: typecheck and tests for
@objectstack/mcp,@objectstack/service-automation,@objectstack/objectqland@objectstack/plugin-approvals. Each needs a closure of about 20 packages rebuilt (every one a turbo cache miss after the spec change), and the shared verify lock was contended for more than an hour. What they see is additive fields on values they read only.message,.sourceor.errorfrom (grep above).For objectui#10835
This is the hand-off for the consumer half. ⛔ Nothing was posted there from this PR.
EXPRESSION_REFUSAL_CODES/ExpressionRefusalCode/ExpressionRefusalParamsfrom@objectstack/formula, andFLOW_SLOT_REFUSAL_CODES/FlowSlotRefusalCode/FlowSlotRefusalParamsfrom@objectstack/spec/automation.PredicateSlotRefusalCodeandStructuralConditionRefusalCodenarrow the spec set to each producer. A catalogue-coverage test can iterate the two frozen lists.catalog,roots) is an array that the row joins. A reference is bare text that the row quotes.foundandheldare tokens that the row phrases.limitValueis a number. Every other param is a string.tFormat'svarstakesstring | number, so only the two list params need a join before the call.detailstays English (engine-owned). The row wraps it and does not translate it.structuralConditionRefusal(round 1): the flow simulator's guard evaluation calls it directly atflow-sim-validate.ts:162(evalGuard, objectuimain) and showsshape.messageas the step error. That is the site for thestructural-condition-shaperow. Its testflow-simulator.guardsInterpolation-10615.test.ts:128asserts the step error contains the EnglishSTRUCTURAL_CONDITION_SHAPE_REFUSAL, so that assertion moves with the row.visibleWhen, the simulator):predicate-slot-missing,predicate-slot-blank,predicate-slot-not-text,structural-condition-shape,invalid-cel,cel-too-large,cel-method-call,cel-unknown-function,cel-template-brace,cel-dialect-mismatch,envelope-source-not-text,empty-expression,cel-parse-failed. The other codes need a schema hint (fields,fieldTypes,roleCatalog,roots,traversalHydration) or thetemplaterole.envelope-source-not-textrole,foundtemplate-dialect-mismatchdialectinvalid-templatedetailtemplate-single-bracerefcel-dialect-mismatchdialectinvalid-celrole,detailcel-too-largerole,detail,limit?,limitValue?cel-method-callrole,detail,name,receiver?cel-unknown-functionrole,detail,name,suggestion?cel-template-bracerole,detail,refunknown-fieldfield,objectName?,suggestion?unknown-rolename,suggestion?,catalogunbound-rootname,roots,suggestionbare-referencenamedate-arithmeticoperands,reference?type-mismatchoperands,operator,held,reference?field-near-missname,suggestion,objectName?traversal-bare-and-traversedroot,fieldtraversal-multi-hoproot,fieldempty-expressioncel-parse-faileddetailpredicate-slot-missingfoundpredicate-slot-blankpredicate-slot-not-textfound(envelope,array,object,number,boolean,bigint,symbol,function)structural-condition-shapefound(array,ast-without-source,object-without-source,number,boolean,bigint,symbol,function)Acceptance notes
structuralConditionRefusal, folded in round 1: round 0 reported it as a same-family finding. The seat's order5861789336folded it in here, and the family is now closed on the producer side: the file has no third refusal producer.predicateSlotRefusal's not-text arm had an unreachablevalue === nullbranch, becausenullreturns from the first arm. The kind token is now derived first and the phrase rendered from it, so that branch is gone. Output is identical on every reachable input (the 43-case control and the pins cover every kind).check:dispatcher-error-vocabulary's object-literal-constant shape (acode:followed by a SCREAMING identifier) matches a single-letter type parameter in a type literal (readonly code: K;) and reports it as an unresolvable code constant. The type parameters are namedCode/Codeshere, and the gate is green. Not filed: it is a precision note on a gate that failed loudly, with no author-facing reach.5859522970, claim5860074154). A later rewording of a prescription updates its pin and keeps its code.Local verification
Heavy runs went through
os-verify-lock.sh, and each verdict is the lock'sVERDICT command-exit 0line.Round 1, at head
5581ed26ee:flow-slot-refusal-codes,flow-node-expression-paths,flow-decision-branch-expression-absent): 3 files, 63 tests passed, run at658a8637f5.5581ed26eeonly adds generated JSON and the changeset.typecheck: OK (tsc, scripts and the test layer, ledger unchanged at 53 files / 255 errors).check:generated: all 15 artifacts up to date. It first reportedapi-surface/andexport-origins/stale; both were regenerated, and against the basede091b50e6they are additive only (0 removed lines).validate-expressions.ts:1582readsstructuralConditionRefusal):typecheck: OK.check:doc-formula-expressions: pass.5581ed26ee(the same 86 families as round 0) and reconciled with--ran: 83 run and green, 3 NOT MEASURED, 0 unrun.check:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt. Each exits 3,PREREQUISITE NOT MET, because it reads a whole-repo build. Declared to CI.Round 0, at head
2a6dee312c:typecheck: OK.pnpm test: 40 files, 1191 passed atb0b9a29b40.typecheck: OK.pnpm test(local project): 554 files, 16333 passed, 1 todo.check:dispatcher-error-vocabularywas red once, on single-letter type parameters (see Acceptance notes), and is green after the rename.check:error-code-casing: pass.@objectstack/mcp,@objectstack/service-automation,@objectstack/objectqland@objectstack/plugin-approvals(about a 20-package closure rebuild under a contended lock). They read only.message,.sourceor.error(grep above).main: the branch is behindorigin/main, and none of the commits it lacks touchespackages/formula,packages/spec/src/automation,api-surface/orexport-origins/. CI's merge ref covers the combination.Generated by Claude Code