Repository navigation
Commit 862b6ce
feat(formula,spec): stable refusal codes and params beside every expression refusal message (#20352)
Fixes #20291
Clause-②: yes
## Rework round 1
Order of record: the seat's comment `5861789336` on #20291. Its
instruction is to fold in the file's second refusal producer,
`structuralConditionRefusal`, which round 0 reported as a same-family
finding. Claim `5860074154` is unchanged, and the file is already in its
surface. The round adds two commits on top of `2a6dee312c`
(`658a8637f5`, `5581ed26ee`); no rebase, amend or force-push.
- `structuralConditionRefusal` now returns `StructuralConditionRefusal`:
its previous `{ message, source }` plus `code:
'structural-condition-shape'` and `params: { found }`. `found` is one of
`array`, `ast-without-source`, `object-without-source`, `number`,
`boolean`, `bigint`, `symbol` and `function`. The message is
byte-identical, `STRUCTURAL_CONDITION_SHAPE_REFUSAL` included.
- The spec code set is now **one** closed table for both of the file's
producers, the way the formula set is laid out. `FlowSlotRefusalParams`
and the frozen `FLOW_SLOT_REFUSAL_CODES` (4 codes) replace round 0's
`PredicateSlotRefusalParams` and `PREDICATE_SLOT_REFUSAL_CODES`.
Per-producer code types are `PredicateSlotRefusalCode` and
`StructuralConditionRefusalCode`. The two replaced names never reached
`main`.
- Census confirmed: the file has exactly **two** exported refusal
producers, `predicateSlotRefusal` and `structuralConditionRefusal`. Its
other exported functions are `isExpressionEnvelopeShaped` (a boolean
guard), `resolveFlowNodeExpressions` and `resolveFlowNodeValueSlots`
(resolvers returning `{ entry, path, value }`). The only `message:`
sites in the file sit inside those two producers.
- The byte-identity corpus now has **64 cases**: round 0's 43 plus 21
for `structuralConditionRefusal` (6 admitted, 15 refused, every `found`
kind). `base` is `origin/main` `de091b50e6` for the 43; for the 21 it is
`2a6dee312c`, where that function was still untouched. The result is **0
diffs**.
- The pin file became `flow-slot-refusal-codes.test.ts`. It has one pin
per code (4 codes; `structural-condition-shape` carries 10 cases, every
kind) and pins `STRUCTURAL_CONDITION_SHAPE_REFUSAL` against 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-error` covers a code-less
`StructuralConditionRefusal` and a structural refusal carrying a
predicate-slot code.
Expression refusals now carry a stable `code` and typed `params` beside
their English `message`. This covers `validateExpression` (every entry
in `errors[]` and `warnings[]`), `collectCelRootIdentifiers` (its `ok:
false` arm), `predicateSlotRefusal` and `structuralConditionRefusal`. 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 triage `5859522970`. **The English
`message` is unchanged byte for byte.** Nothing is removed or renamed,
so it is a widening (`minor` for 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 `code` narrows `params`. The
keys of `ExpressionRefusalParams` and `FlowSlotRefusalParams` are the
closed sets. The frozen value lists `EXPRESSION_REFUSAL_CODES` and
`FLOW_SLOT_REFUSAL_CODES` are held equal to those keys at compile time:
each list is read off a code table that `satisfies` a 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-casing`
and `check:dispatcher-error-vocabulary` both pass.
### Inventory: every refusal each producer emits (at head `5581ed26ee`)
| Producer site | Code | Params |
|---|---|---|
| `validate.ts:820` non-string envelope `source` |
`envelope-source-not-text` | `role`, `found`
(`array`/`object`/`number`/`boolean`/`bigint`/`symbol`/`function`) |
| `validate.ts:837` template slot, other dialect |
`template-dialect-mismatch` | `dialect` |
| `validate.ts:847` template engine refused | `invalid-template` |
`detail` |
| `validate.ts:856` single brace in a template | `template-single-brace`
| `ref` |
| `validate.ts:862` CEL slot, other dialect | `cel-dialect-mismatch` |
`dialect` |
| `validate.ts:911` CEL compile failure (the "bare CEL" trailer) |
`invalid-cel` | `role`, `detail` |
| `validate.ts:901` over a parse budget | `cel-too-large` | `role`,
`detail`, `limit`?, `limitValue`? (both or neither) |
| `validate.ts:904` bare function called as a method | `cel-method-call`
| `role`, `detail`, `name`, `receiver`? |
| `validate.ts:907` name not callable here | `cel-unknown-function` |
`role`, `detail`, `name`, `suggestion`? |
| `validate.ts:909` template brace in CEL | `cel-template-brace` |
`role`, `detail`, `ref` |
| `validate.ts:636` unknown `record.` field | `unknown-field` | `field`,
`objectName`?, `suggestion`? |
| `validate.ts:786` role outside the catalog | `unknown-role` | `name`,
`suggestion`?, `catalog` (list) |
| `validate.ts:701` typo of a declared root | `unbound-root` | `name`,
`roots` (list), `suggestion` |
| `validate.ts:934` bare identifier, record scope | `bare-reference` |
`name` |
| `validate.ts:277` date arithmetic (error) | `date-arithmetic` |
`operands`, `reference`? |
| `validate.ts:293` text/boolean against a number (**warning**) |
`type-mismatch` | `operands`, `operator`, `held` (`text`/`boolean`),
`reference`? |
| `validate.ts:966` near-miss of a field, flattened (**warning**) |
`field-near-miss` | `name`, `suggestion`, `objectName`? |
| `validate.ts:1011` traversal conflict `bare-and-traversed` |
`traversal-bare-and-traversed` | `root`, `field` |
| `validate.ts:1011` traversal conflict `multi-hop` |
`traversal-multi-hop` | `root`, `field` |
| `cel-engine.ts:343` `collectCelRootIdentifiers`, empty |
`empty-expression` | none |
| `cel-engine.ts:364` `collectCelRootIdentifiers`, parse fault (bounds
included) | `cel-parse-failed` | `detail` |
| `flow-node-expression-paths.ts:630` absent or `null`, required slot |
`predicate-slot-missing` | `found` (`absent`/`null`) |
| `flow-node-expression-paths.ts:647` blank after trim |
`predicate-slot-blank` | none |
| `flow-node-expression-paths.ts:672` not text |
`predicate-slot-not-text` | `found`
(`envelope`/`array`/`object`/`number`/`boolean`/`bigint`/`symbol`/`function`)
|
| `flow-node-expression-paths.ts:858` `structuralConditionRefusal`,
neither text nor an envelope with a string `source` |
`structural-condition-shape` | `found`
(`array`/`ast-without-source`/`object-without-source`/`number`/`boolean`/`bigint`/`symbol`/`function`)
|
`detail` is 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`**: `ExprValidationError` gains `code` and
`params`. It becomes a type alias (`{ message, source }` intersected
with the correlated code/params union), no longer an interface. No repo
consumer `extends` it or constructs one (`git grep ExprValidationError`
hits only `index.ts` and two test comments). All construction goes
through one internal `refusal(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 }`). `error` stays, byte for byte, and the
failure arm gains `code` and `params` beside it
(`CelRootIdentifiersResult`). Every consumer reads `.ok`, `.roots` and
`.error`, and all of those are untouched.
- **`predicateSlotRefusal`**: returns `PredicateSlotRefusal`, its
previous `{ message, source }` plus `code` and `params`.
- **`structuralConditionRefusal`**: returns
`StructuralConditionRefusal`, its previous `{ message, source }` plus
`code` and `params` (round 1).
## The control: the English message is unchanged
- **One-time proof.** A 43-case corpus (64 after round 1; see above)
touches every arm of the three producers, including every optional-param
variant and every `found` kind. It was run through `origin/main`
`de091b50e6` **before** the edit and through the branch **after** it.
`message`, `source`, `error` and `ok` are identical on all 43 cases (**0
diffs**).
- **Kept as a pin.** There is one test per code
(`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-string `toBe`. Where a
message embeds an engine diagnostic, the expected string takes `detail`
from 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
- `PINS` is 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.
- A sweep of 2,457 `validateExpression` calls (3 roles × 117 inputs × 7
schema hints) and the `collectCelRootIdentifiers` sweep 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.
- Type level: in both test layers, a refusal literal without
`code`/`params`, and one with another code's params, carry
`@ts-expect-error`. Both layers are compiled (`tsconfig.test.json`,
`check:test-typecheck` OK), so an unused directive would be red.
- **Ablation** (committed first, done through
`scripts/ablation-replace.mjs` with its restore armed, plus an outer
`trap … EXIT INT TERM`): The `refusal()` helper was mutated to drop
`code` for `bare-reference` alone. It simulates one refusal path
shipping without a code.
- **Landed on disk:** anchor `return { source, message, code, params }
as ExprValidationError;` went from 1 hit to 0, the replacement from 0 to
1, and the blob from `2e753a6e8a15` to `b90d3d475acd`. The marker `grep
-c` read 1 inside the run.
- **Run:** `expression-refusal-codes.test.ts` gave **2 failed, 24 passed
(26)**. The two reds are the `bare-reference` pin (`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.
- **Restored:** blob `2e753a6e8a15` equals the HEAD blob, `git diff
HEAD` is empty, and the marker count is 0.
- **Why no build leg:** the test imports `./validate` relatively, so the
subject resolves from `src/`. No package `exports` hop and no vitest
alias are involved, so no build or `dist/` preflight applies.
## Every consumer, and why it still compiles and behaves
`git grep` over 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[].source`
and `.warnings[]`, and none constructs an error object. The
`validate_expression` MCP tool forwards `errors`/`warnings` as they are,
so its answer entries now also carry `code` and `params`, an additive
widening of that tool's payload that the changeset states.
`mcp-validate-expression.test.ts` asserts `toHaveLength` and message
regexes only.
- `collectCelRootIdentifiers`: `packages/lint` (4 sites),
`packages/objectql` (`rule-validator.ts` ×2),
`packages/plugins/plugin-approvals` (`approval-service.ts`). They read
`.ok`, `.roots` and `.error` only.
- `predicateSlotRefusal`: `packages/lint`
(`validate-expressions.ts:1422`) and
`packages/services/service-automation` (`engine.ts:9539`) read
`.message` and `.source`.
- `structuralConditionRefusal`: `packages/lint`
(`validate-expressions.ts:1582`) and
`packages/services/service-automation` (`engine.ts:9458`,
`engine.ts:10766`) read `.message` and `.source`. Existing tests assert
`toBeUndefined` / `toBeDefined` / `.message` / `.source` only. The tests
that compare against it do so relationally
(`toBe(predicateSlotRefusal(v)!.message)`).
- objectui at the pinned `.objectui-sha` (`f8a9d0fb05`) and at its
`main`: `screen-spec.ts` and `flow-sim-validate.ts` call the producers
and read `.message` / `.error`. `flow-sim-validate.ts:162` reads
`structuralConditionRefusal(condition).message`. No objectui test
deep-equals a refusal object. A widening cannot break the Console Pin
Gate's build.
- No test anywhere deep-equals a refusal object (the grep for
`toEqual`/`toStrictEqual` on these results finds only `toEqual([])`).
**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).
- The 7 lint test files that exercise the three producers passed, **7
files, 657 tests** (`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 runs `validateExpression` over
every documented formula, passed.
**NOT MEASURED locally, declared to CI:** typecheck and tests for
`@objectstack/mcp`, `@objectstack/service-automation`,
`@objectstack/objectql` and `@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`, `.source` or `.error` from (grep above).
## For objectui#10835
This is the hand-off for the consumer half. ⛔ Nothing was posted there
from this PR.
- Import `EXPRESSION_REFUSAL_CODES` / `ExpressionRefusalCode` /
`ExpressionRefusalParams` from `@objectstack/formula`, and
`FLOW_SLOT_REFUSAL_CODES` / `FlowSlotRefusalCode` /
`FlowSlotRefusalParams` from `@objectstack/spec/automation`.
`PredicateSlotRefusalCode` and `StructuralConditionRefusalCode` narrow
the spec set to each producer. A catalogue-coverage test can iterate the
two frozen lists.
- Params are values, not renderings. A list (`catalog`, `roots`) is an
array that the row joins. A reference is bare text that the row quotes.
`found` and `held` are tokens that the row phrases. `limitValue` is a
number. Every other param is a string. `tFormat`'s `vars` takes `string
| number`, so only the two list params need a join before the call.
- `detail` stays English (engine-owned). The row wraps it and does not
translate it.
- `structuralConditionRefusal` (round 1): the flow simulator's guard
evaluation calls it directly at `flow-sim-validate.ts:162` (`evalGuard`,
objectui `main`) and shows `shape.message` as the step error. That is
the site for the `structural-condition-shape` row. Its test
`flow-simulator.guardsInterpolation-10615.test.ts:128` asserts the step
error contains the English `STRUCTURAL_CONDITION_SHAPE_REFUSAL`, so that
assertion moves with the row.
- The rows the designer surfaces reach today, with no schema hint
(screen `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 the
`template` role.
- Code → params:
| Code | Params |
|---|---|
| `envelope-source-not-text` | `role`, `found` |
| `template-dialect-mismatch` | `dialect` |
| `invalid-template` | `detail` |
| `template-single-brace` | `ref` |
| `cel-dialect-mismatch` | `dialect` |
| `invalid-cel` | `role`, `detail` |
| `cel-too-large` | `role`, `detail`, `limit`?, `limitValue`? |
| `cel-method-call` | `role`, `detail`, `name`, `receiver`? |
| `cel-unknown-function` | `role`, `detail`, `name`, `suggestion`? |
| `cel-template-brace` | `role`, `detail`, `ref` |
| `unknown-field` | `field`, `objectName`?, `suggestion`? |
| `unknown-role` | `name`, `suggestion`?, `catalog` |
| `unbound-root` | `name`, `roots`, `suggestion` |
| `bare-reference` | `name` |
| `date-arithmetic` | `operands`, `reference`? |
| `type-mismatch` | `operands`, `operator`, `held`, `reference`? |
| `field-near-miss` | `name`, `suggestion`, `objectName`? |
| `traversal-bare-and-traversed` | `root`, `field` |
| `traversal-multi-hop` | `root`, `field` |
| `empty-expression` | none |
| `cel-parse-failed` | `detail` |
| `predicate-slot-missing` | `found` |
| `predicate-slot-blank` | none |
| `predicate-slot-not-text` | `found` (`envelope`, `array`, `object`,
`number`, `boolean`, `bigint`, `symbol`, `function`) |
| `structural-condition-shape` | `found` (`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 order `5861789336` folded 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 unreachable `value ===
null` branch, because `null` returns 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 (a
`code:` 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 named
`Code` / `Codes` here, and the gate is green. Not filed: it is a
precision note on a gate that failed loudly, with no author-facing
reach.
- The per-code message pins assert English copy exactly. That is
deliberate for this card, whose declared control is byte-identical
messages (triage `5859522970`, claim `5860074154`). 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's `VERDICT command-exit 0` line.
**Round 1, at head `5581ed26ee`:**
- **spec:**
- Targeted refusal files (`flow-slot-refusal-codes`,
`flow-node-expression-paths`, `flow-decision-branch-expression-absent`):
**3 files, 63 tests passed**, run at `658a8637f5`. `5581ed26ee` only
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 reported
`api-surface/` and `export-origins/` stale; both were regenerated, and
against the base `de091b50e6` they are additive only (0 removed lines).
- **formula:** the pin file passed, **26 tests** (formula is untouched
in round 1).
- **lint** (consumer; `validate-expressions.ts:1582` reads
`structuralConditionRefusal`):
- `typecheck`: OK.
- The 7 producer-exercising files: **7 files, 657 tests passed**.
- `check:doc-formula-expressions`: pass.
- **Byte-identity control:** **64 cases, 0 diffs**.
- **Derived gates:** re-derived at `5581ed26ee` (the same 86 families as
round 0) and reconciled with `--ran`: **83 run and green**, 3 NOT
MEASURED, 0 unrun.
- The three NOT MEASURED are `check:dual-build-cjs-loads`,
`check:lean-entry-closure` and `check:type-check-debt`. Each exits 3,
`PREREQUISITE NOT MET`, because it reads a whole-repo build. Declared to
CI.
**Round 0, at head `2a6dee312c`:**
- **formula:**
- `typecheck`: OK.
- Pin file with five producer suites: **6 files, 319 passed**.
- Full `pnpm test`: **40 files, 1191 passed** at `b0b9a29b40`.
- **spec:**
- `typecheck`: OK.
- Full `pnpm test` (local project): **554 files, 16333 passed, 1 todo**.
- **Gate notes:** `check:dispatcher-error-vocabulary` was red once, on
single-letter type parameters (see Acceptance notes), and is green after
the rename. `check:error-code-casing`: pass.
- **NOT MEASURED locally, declared to CI:** typecheck and tests for
`@objectstack/mcp`, `@objectstack/service-automation`,
`@objectstack/objectql` and `@objectstack/plugin-approvals` (about a
20-package closure rebuild under a contended lock). They read only
`.message`, `.source` or `.error` (grep above).
- **Not merged with `main`:** the branch is behind `origin/main`, and
none of the commits it lacks touches `packages/formula`,
`packages/spec/src/automation`, `api-surface/` or `export-origins/`.
CI's merge ref covers the combination.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 07bcbf8 commit 862b6ce
10 files changed
Lines changed: 1451 additions & 129 deletions
File tree
- .changeset
- packages
- formula/src
- spec
- api-surface
- export-origins
- src/automation
| 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 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| 23 | + | |
23 | 24 | | |
24 | 25 | | |
25 | 26 | | |
| |||
333 | 334 | | |
334 | 335 | | |
335 | 336 | | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
336 | 340 | | |
337 | | - | |
338 | | - | |
339 | | - | |
| 341 | + | |
340 | 342 | | |
341 | | - | |
| 343 | + | |
342 | 344 | | |
343 | 345 | | |
344 | 346 | | |
| |||
358 | 360 | | |
359 | 361 | | |
360 | 362 | | |
361 | | - | |
| 363 | + | |
| 364 | + | |
362 | 365 | | |
363 | 366 | | |
364 | 367 | | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
365 | 377 | | |
366 | 378 | | |
367 | 379 | | |
| |||
0 commit comments