Repository navigation
feat(spec): assignment values may be a CEL value envelope — the expression ledger gains the value role - #15113
Conversation
…ontract, pins, docs, changeset Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…Schema; array-form prescription on the record type error Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…sion ledger gains the value role
FLOW_NODE_EXPRESSION_PATHS gains the assignment entry at assignments.* with the
new value role; ledger paths accept a * segment (every key of an object, the
sibling of []); resolveFlowNodeExpressions emits only envelope-shaped objects
for a value slot, so a plain string stays {token} interpolation and every
pre-existing entry resolves byte-identically. AssignmentConfigSchema /
AssignmentValueSchema / AssignmentExpressionValueSchema declare the assignment
node's value contract (ExpressionSchema, safeExtended to the cel dialect,
beside interpolation and literals); a malformed envelope is refused at the
variable's path with a fixed leading sentence; the map value carries
xExpression: value as the ledger's declaration channel. Docs page, generated
artifacts, and a spec minor changeset. The executor half (evaluation,
validateExpression value checks, the reconciliation ratchet's marker table)
is service-automation's and is reported, not edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…R-0090 reserved word in the flows page Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…ledger ratchet; ratchet learns the value marker and the additionalProperties walk Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…-declared node config map Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
…signment-value-role
…rged tree The os-regen merge driver deferred docs/audits/2026-07-unknown-key-strictness-ledger.counts.md (changed on both sides since the merge base); regenerated with pnpm --filter @objectstack/spec gen:strictness-ledger on the merged tree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
📓 Docs Drift CheckThis PR changes 1 package(s): 1 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 — 128 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 3846d99734cadb1d55c8684a0314db977d5cb752 && git checkout 3846d99734cadb1d55c8684a0314db977d5cb752
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f594e70d796cbdd8e751c30105ede566acfec584 d25a6606530ba3051129d8efce9c66a41d4be83c && git checkout -B drift-repro f594e70d796cbdd8e751c30105ede566acfec584 && git merge --no-ff d25a6606530ba3051129d8efce9c66a41d4be83c
node scripts/docs-audit/affected-docs.mjs --json f594e70d796cbdd8e751c30105ede566acfec584
|
Contract review (Clause ②) — PASS · ACCEPTReviewer of record: the Head and window (readings at 2026-09-04T02:09Z)
The widening, verified in the diff
Report checklist
Follow-up cards this seat files at landing
LandingAt all-green on
Generated by Claude Code |
|
Landing provenance — Landing window readings, all on head
Sequence: ready → squash auto-merge → Generated by Claude Code |
…ssignment executor and validate it at registerFlow (#15137) The executor half of the maintainer's 2026-09-02 ruling on #14149, whose spec half landed in PR #15113. An `assignment` value that is an `{ dialect: 'cel', source }` envelope is now evaluated by the expression engine and the result assigned; before this it went to `interpolate()`, which recursed into it as a plain object and wrote it into the variable verbatim, so `notify` rendered `{"dialect":"cel","source":"…"}` as JSON and the declared CEL stdlib was unreachable from metadata. Three parts: - `AutomationEngine.evaluateValueEnvelope` evaluates a declared envelope in the CEL scope `evaluateCondition` already builds — extracted to one shared `celScope`, so a predicate and a value expression cannot disagree about what a variable means. A refusal throws with the source attached (ADR-0032 §1c/§1d): a value that failed to compute has no falsy default to hide behind. - `registerFlow` refuses a malformed envelope (throws) and `objectstack validate` reports it as a located finding — the severity split the `predicate` role already uses. Both compose the SAME two published primitives, in the same order: `AssignmentValueSchema` for shape (it alone catches the source-less `{ dialect: 'cel' }` that `validateExpression` reads as "not authored") and `validateExpression('value', …)` for CEL. One notion of malformed, derived once, so registration and evaluation cannot refuse different sets. - The behaviour is scoped to the slot the ledger declares, `assignments.*`. The legacy `assignments: [{ variable, value }]` array and the bare `{ <variable>: <value> }` config keep every meaning they had, envelope-shaped values included: `AssignmentConfigSchema` is deliberately NOT wired into `parseNodeConfig` for the array form, because refusing it would break flows that register today and that refusal is a ruling, not a lane's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…ssignment executor and validate it at registerFlow (objectstack-ai#15137) (objectstack-ai#15432) * feat(service-automation): evaluate a value-role CEL envelope in the assignment executor and validate it at registerFlow (objectstack-ai#15137) The executor half of the maintainer's 2026-09-02 ruling on objectstack-ai#14149, whose spec half landed in PR objectstack-ai#15113. An `assignment` value that is an `{ dialect: 'cel', source }` envelope is now evaluated by the expression engine and the result assigned; before this it went to `interpolate()`, which recursed into it as a plain object and wrote it into the variable verbatim, so `notify` rendered `{"dialect":"cel","source":"…"}` as JSON and the declared CEL stdlib was unreachable from metadata. Three parts: - `AutomationEngine.evaluateValueEnvelope` evaluates a declared envelope in the CEL scope `evaluateCondition` already builds — extracted to one shared `celScope`, so a predicate and a value expression cannot disagree about what a variable means. A refusal throws with the source attached (ADR-0032 §1c/§1d): a value that failed to compute has no falsy default to hide behind. - `registerFlow` refuses a malformed envelope (throws) and `objectstack validate` reports it as a located finding — the severity split the `predicate` role already uses. Both compose the SAME two published primitives, in the same order: `AssignmentValueSchema` for shape (it alone catches the source-less `{ dialect: 'cel' }` that `validateExpression` reads as "not authored") and `validateExpression('value', …)` for CEL. One notion of malformed, derived once, so registration and evaluation cannot refuse different sets. - The behaviour is scoped to the slot the ledger declares, `assignments.*`. The legacy `assignments: [{ variable, value }]` array and the bare `{ <variable>: <value> }` config keep every meaning they had, envelope-shaped values included: `AssignmentConfigSchema` is deliberately NOT wired into `parseNodeConfig` for the array form, because refusing it would break flows that register today and that refusal is a ruling, not a lane's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * fix(service-automation): clear the ADR-0087 gate, correct the docs this landing falsified, and pin the bound of the reject-set property (objectstack-ai#15137) The four items from the Clause-② contract review on PR objectstack-ai#15432. The mechanism is untouched — no change to engine.ts, logic-nodes.ts or validate-expressions.ts. - **ADR-0087 gate (red, required).** The changeset declares BREAKING and carried no disposition marker, so `check-adr-0087-registration.mjs` failed. It now carries `not-required (no-migration-prescription)`: no authorable key is renamed, retired or re-typed, so `objectstack migrate meta` has nothing to rewrite. The gate was missed locally because `dispatch-gates.mjs` presents `--self-test` as the runnable member and files the real `--base` invocation as not-runnable, although the script's own usage line defaults `--base` to `origin/main`. - **Docs this PR made false.** `content/docs/automation/flows.mdx` still told authors the executor writes the envelope verbatim and prescribed a `script` node "for a digest body today". Replaced with what landed: where a malformed envelope is refused (three doors, one composition) and which two shapes fault at run time instead. The same stale sentence in `packages/spec/src/automation/flow-node-expression-paths.ts` is corrected as a COMMENT — no contract, schema, export or generated artifact moves. - **The property's bound, pinned.** `{ dialect: 'cel', source: ' ' }` registers and then faults: `source` is `z.string().min(1)` so whitespace passes the shape rule, `validateExpression` trims to empty and answers "not authored", and the CEL engine parses it untrimmed. Same seam class as the `ast`-only case. Pinned loud on both sides (engine faults, lint is silent) and added to objectstack-ai#15430 — deliberately NOT closed here with a trim rule of the engine's own, which would be the third locally-invented notion of "malformed" this design exists to prevent. - **Accuracy.** "Exactly four things" was an under-count (`source: 5` and `meta: 'bad'` are refused too); the reject set is stated as the composition. The runtime publish gate is named as the third door — `validateStackExpressions` is registered `CLI_AND_RUNTIME` / `['flow']` (`authoring-rules.ts:431-441`), so Studio / REST / MCP flow writes get the same refusal at `severity: 'error'`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude <noreply@anthropic.com>
…n in words instead of a tracker number (stage 30) (objectstack-ai#22414) Part of objectstack-ai#20749 Clause-②: no Stage 30 of this card: the class (e) remainder, the test strings shipped under the `packages/spec/src` subdirectories, as ruled in `5902360492` on objectstack-ai#20513. The census at the base reads 17 messages / 18 ids in 12 files. This stage rewrites 7 of them (6 titles and 1 expect message, 8 ids) in 5 files: each now states what its record decided, or drops the number where the title already says it. The other 10 messages / 10 ids stay, 4 because earlier stages decided they are not citations and 6 because an assertion matches the string against text this claim does not let the stage edit; both groups are named under "What stays". Text only: no assertion, identifier, test count, code comment, file name or non-test file changes. No file is deferred. ## Census (re-taken first) The instrument is stage 28's `census28.cjs`, byte-identical (md5 `31d8488b5194b8d3048e3fcaec0efaed`, the value stages 28 and 29 published): an AST walk over the `packages/spec/src` test files, one message per folded string (a lone literal, a template, or a plus chain) that matches the gate's id pattern, a title when the folded root is argument 0 of a describe / it / test / suite / bench call, comments never read. It was run against the three published readings before it was trusted, and all three reproduce exactly: 128 messages / 130 ids in 37 files at `f7b8a5932b`, 191 / 200 in 53 files at `aa09db58c9`, 73 / 76 in 24 files at `b7e01fbbd`. | reading | messages / ids | files | |:--|--:|--:| | base `11d119ab1` | 17 / 18 (titles 6 / 7, other 11 / 11) | 12 | | stage 29's landing `0ef9029da` | 17 / 18, per file equal to the base | 12 | | this head | 10 / 10 (titles 0 / 0, other 10 / 10) | 7 | | the 5 edited files, this head | 0 / 0 | 0 of 5 | Stage 29's ACCEPT carried 16 / 17 (ui 9 / 9 in 7 files, automation 2 / 3, ai 2 / 2, api 1 / 1, contracts 1 / 1, kernel 1 / 1). The base reads 1 / 1 more, all in `ui`: the title `carries no ruling date and no tracker id (objectstack-ai#22093)` at `view-submit-redirect-url.test.ts:341`, which PR objectstack-ai#22322 (`ad381fd94`, landed 2026-10-09T00:35Z) added after stage 29's head. So `ui` reads 10 / 10 in 7 files, and nothing else moved. The census at `origin/main` `e75dceddd` (8 commits past the base, none touching the 12 files) reads the same 17 / 18 in the same 12 files, so nothing regrew while this stage ran. The reading is within one message of the claim's, so there was no re-cut. Per file, messages at base: `ai/build-progress` 2, `api/meta-item-response-shapes` 1, `automation/builtin-node-config` 2 (3 ids), `contracts/approval-service` 1, `kernel/manifest` 1, `ui/action-description` 1, `ui/component-props-unknown-members.pin` 1, `ui/dashboard-chart-structure-refusal` 2, `ui/dashboard` 2, `ui/notification` 1, `ui/strictness-batch14` 1, `ui/view-submit-redirect-url` 2. Controls: - Pathspec: the 12 named paths hit the control word `describe(` in 12 of 12 files and a nonsense word in none; the census scanned 12 of 12. - Planted, in a scratch tree: an id in a describe title, a plus-chain title, an expect message, a template literal, a cross-repo spelling and a ledger-style string each read once (6 / 6); a comment, a six-digit colour, an HTML entity, a two-digit number and a hex colour with a letter read 0. - Lit and dark inside the group: the 5 edited files read 1, 2, 1, 1 and 2 messages at base and 0 at the head; the 7 untouched files read the same at both ends. ## Deferral At the census (2026-10-09T03:03Z) 17 PRs were open; at the re-scan before opening this PR (04:13Z), 13. Every file list was read through REST (605 and 593 rows). None touches any of the 12 files: lit control `api/protocol.test.ts` (PR objectstack-ai#22323) found, dark control 0. Of the four PRs the claim named, objectstack-ai#22380 has landed and objectstack-ai#22315, objectstack-ai#22323 and objectstack-ai#22215 are open; none of them touches a file in this group. Deferred files: none. ## What changed 7 literals, one line each, in 5 files: +7 / -7. Every file keeps its line count. - `api/meta-item-response-shapes.test.ts:226`: the `[objectstack-ai#22114] ` prefix goes; the title already says what objectstack-ai#22126 landed, that the read serves the version token and the 409 carries the current one as data. - `automation/builtin-node-config.test.ts:434`: "a CEL envelope beside literals; the `{token}` dialect retired". objectstack-ai#14149's ruling A made an assignment value a CEL envelope beside literals, and objectstack-ai#19939 retires the `{token}` dialect in flow value slots; the title already stated both, so only the two numbers go. - `automation/builtin-node-config.test.ts:485`: the `[objectstack-ai#19939] ` prefix goes from the REFUSES title. - `kernel/manifest.test.ts:681` and `ui/action-description.test.ts:235`: the trailing `(objectstack-ai#22093)` goes. objectstack-ai#22093 decided that author-visible help and refusals carry no service-interface name, ruling date or foreign example id, and both titles already say what their bodies pin. - `ui/view-submit-redirect-url.test.ts:341`: the trailing `(objectstack-ai#22093)` goes from the title. - `ui/view-submit-redirect-url.test.ts:118`: the expect message `states the rule, not its ruling date (objectstack-ai#22093)` drops the number. It is an assertion's failure message, so it was needle-checked first (below) and is the one declared non-title string. All seven are "drop a number the title already explains". None needed a rewrite in new words, because each title already carried the decision. ## What stays, and why 10 messages / 10 ids in 7 files, none edited. Four are CSS hex colours, not citations. `colors: ['objectstack-ai#111', 'objectstack-ai#222']` at `ui/dashboard-chart-structure-refusal.test.ts:94` and `palette: ['objectstack-ai#111', 'objectstack-ai#222']` at `ui/dashboard.test.ts:124` are fixture input the schema under test reads. Stage 21's ACCEPT (`6001279159`, decision A) kept them by file and line, and every later stage carried them forward. Six are strings that an assertion matches against text outside this stage's edit surface. Moving one at the same strength means editing a non-test source docblock (and, for the first two, its generated reference page) or the assertion that matches it. The claim forbids both and says to stop and report, so none is touched; `open_questions` in the report carries the decision. - `ai/build-progress.test.ts:236` `'cloud#2172'` and `:237` `'objectui#7388 block 2'`: `toContain` over the source text of `ai/build-progress.zod.ts` (docblock lines 8, 27 and 85), which `content/docs/references/ai/build-progress.mdx` renders. - `contracts/approval-service.test.ts:274` `'objectstack-ai#16495'`: `toContain` over the docblock above `continueRestoredRun` in `contracts/approval-service.ts` (line 999). - `ui/notification.test.ts:123` `'// [objectstack-ai#4610]'`: the locator of the tombstone note in `ui/notification.zod.ts:94`; the file's own `toMatch(/^\[objectstack-ai#4610\]/)` at `:134` reads the same note. - `ui/strictness-batch14.test.ts:395` `'objectstack-ai#5015'`: `toContain` over `ui/notification.zod.ts` and `ui/sharing.zod.ts`. - `ui/component-props-unknown-members.pin.test.ts:322` `ruling: 'decision card objectstack-ai#21704, fork 4, letter B (record 5979239990)'`: the file's own assertion at `:417` matches the value with `/objectstack-ai#21704/`. Stage 20's ACCEPT (`5998488373`) kept it for this reason and sent it to the needles' stage. Readers of the seven rewritten strings: none. `git grep -F` at HEAD over the tracked tree outside the 12 files, with the full literal, a 24-character window around each id, and the text on each side of each id (29 needles over all 17 sites): the only hits are the readers of the kept strings named above, the lit control (`composeStacks` in `stack.zod.ts`) hits and the dark control does not. The same needles searched inside the 12 files, outside each literal's own span: the only hits are two code comments beside `:322`. The five short needles (`cloud#2172`, `objectstack-ai#16495`, `// [objectstack-ai#4610]`, `objectstack-ai#5015`, `objectstack-ai#21704`) fall under the script's 12-character floor, so their readers were confirmed by direct `git grep -F` with a dark control. ## Cited records Read with their comments as the API serves them: objectstack-ai#22114 (8 of 8 comments; landed as PR objectstack-ai#22126), objectstack-ai#14149 (12 of 12; ruling A `5507504961`, landed as PR objectstack-ai#15113), objectstack-ai#19939 (15 of 15; pass 1 landed as PR objectstack-ai#22259, the card stays open), objectstack-ai#22093 (17 of 17; PRs objectstack-ai#22125, objectstack-ai#22309 and objectstack-ai#22322), and the four PRs themselves. objectstack-ai#19939 is still open: its pass 1 refuses the `{token}` dialect in flow value slots and keeps two spellings (the date macros and `{$User.*}`) until CEL can write them. The describe's PRESERVATION test still accepts those two, and the title keeps the record's own verb, "retired", as PR objectstack-ai#22259 wrote it. The title and the test body say the same thing the record says. ## Verification At head `8885dbf1c` (one commit on base `11d119ab1`): - **Text only.** `textonly28.cjs` (stage 28's, md5 `957eff6b3837d762b8e03d070155930a`) on all 12 base copies against their heads: 12 / 12 SAME. 7 changed tokens, as predicted in writing at 2026-10-09T03:08Z before any edit or test run: 6 titles and 1 declared string (`--declared 118`). Every other string token, identifier, number, punctuation mark and comment is byte-equal. 16 controls, expectations written in the script before the first run, 16 / 16 as predicted: an identifier rename, a numeric literal, a comment edit, an undeclared expect message, a rewritten title given a new id, an id-free title edited, one title reverted to base (SAME, 0 changed), a declared label without `--declared`, a declared line plus another changed string, the declared line alone (SAME, 2 changed), a title re-split into a plus chain, a test added, an untouched file (SAME, 0 changed), an id appended to a rewritten title, a kept needle rewritten, a kept hex colour rewritten. The two controls that mutate a string beside the declared line fail at the mutated line, not at 118. - **Tests, 12 files, base and head.** `--project local --project repo` with the JSON reporter, 618 tests in 88 suites each side, all passed. Per-file test count and status sequence identical in 12 / 12. 23 full names changed (4 + 14 + 1 + 3 + 1), 0 mismatches against the plan. Names carrying `#` plus digits: 23 at base, 0 at head. Duplicate full names: 3 and 3, the same three `[object Object]` it.each rows at both ends. - **Full spec unit tier at the head**, under the verify lock: `Test Files 626 passed (626)`, `Tests 18743 passed | 1 todo (18744)`. - **Build and typecheck**, under the verify lock: `turbo run build` over `packages/*` and `packages/*/*`, `Tasks: 71 successful, 71 total`; `@objectstack/spec` `typecheck` exit 0 with `check:test-typecheck` holding 52 files / 246 errors / 135 pinned signatures, the same figures as stage 29; the 12 files are all in the `tsconfig.test.json` program. - **Gates.** `dispatch-gates.mjs --commands` at the head derives 79 (stage 29's 77 plus `check:authorable-surface` and `check:yaml-examples`); all 79 exit 0, and `--ran` reconciles 79 derived, 79 run, 0 NOT-MEASURED. The five artifact-roster families that keep their roster in a directory one of the paths is in (`check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) and `check:generated` (all 15 artifacts up to date) also exit 0. - **ESLint**, `--no-inline-config`, 12 files: 0 errors, 0 warnings. Population from ESLint's own config: 12 configured, 0 ignored, 0 with a type-aware parser option, so this diff cannot move the verdict of a file it does not touch. - **Skip-changeset.** `npm pack --dry-run --json --ignore-scripts` in `packages/spec`: 2069 files, 0 `*.test.ts`, 0 of the 5 edited files; controls `src/stack.zod.ts`, `dist/index.mjs` and `package.json` present. The rewritten expect message occurs in 0 files of `dist/`; the control `Unrecognized key` occurs in 42. Nothing published changes. - **Governed.** `check-governed-merges.mjs --test` on the 5 paths: 0 of 5, not governed; 14 changed lines. - **Merge.** `git merge-tree --write-tree` onto `origin/main` `e75dceddd`: clean. - **Bytes.** 0 added lines carry `#` plus digits; 0 control bytes in the changed files. Declared narrowing: the 12-file base and head comparison ran outside `os-verify-lock.sh`, after three queue turns (about 28 minutes) ended without a grant. It is a 12-file run with two workers; the workspace build, the typecheck and the full unit tier all ran under the lock. The gates are `check:*` runs, which do not use the lock. ## Acceptance notes - **Regrowth continues.** Since stage 27's landing, four PRs (objectstack-ai#22125, objectstack-ai#22126, objectstack-ai#22259 and objectstack-ai#22322) added 7 messages / 8 ids to test strings in files that already existed, one of them to a title objectstack-ai#22322 wrote while stripping a ruling date from a describe. Test files sit outside `check:doc-authoring`'s ledgered leg, and the ruling adds no gate, so the per-stage census is the only instrument. An observation about the burn-down's denominator, not a class a / b / c finding. - **Comments are untouched.** Code comments in these files still cite ids (for example `// ─── assignment (objectstack-ai#14149) ───` at `builtin-node-config.test.ts:432`); comments are objectstack-ai#20234's share. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #14149
Clause ②: yes — path limb: every edited source path is under
packages/spec/src/**(automation/flow-node-expression-paths.ts,automation/builtin-node-config.zod.ts,automation/node-executor.zod.tsplus their pins;node scripts/pm/dispatch-gates.mjs --tierat the final head: "no path-derived mandate", each path ⇢ the contract surface); content limb yes: a published node schema widens what it accepts — anassignmentvalue may now be a CEL value envelope beside{token}interpolation — and the exportedFlowNodeExpressionRoleunion widens byvalue.Maintainer ruling 2026-09-02 (card comment 5507504961, verbatim reply 「同意」 to recommendation A): the rendering half only —
FLOW_NODE_EXPRESSION_PATHSgains a value role for theassignmentnode, no new vocabulary, the grouping half deferred,scriptstays the workaround until A lands. This PR is the SPEC half of that ruling, exactly as dispatched by thedomain:specseat (card comment 5532987091 / 5533002333), plus the ONE file the seat widened the surface by in its ruling on the first report's blocker — card comment 5533840057 (2026-09-04T00:18Z, option A with A2, same PR): the ledger's reconciliation ratchetpackages/services/service-automation/src/builtin/config-expression-ledger.test.ts. Nothing else underpackages/services/service-automation/src/**is touched; the executor half is reported below, not implemented. Final head after the last commit:d25a6606—origin/main(f594e70d) merged in by the merge commit7ed4fb54(scripts/pm/os-regen-merge.sh, no rebase, no amend, no force-push) and the one artifact changed on both sides since the merge base, the generateddocs/audits/2026-07-unknown-key-strictness-ledger.counts.md, regenerated from the merged tree withpnpm --filter @objectstack/spec gen:strictness-ledgerind25a6606(the os-regen marker cleared on that commit). The commits afterd902edfdkeep theirwip/choretitles — no history rewrite.What changed
packages/spec/src/automation/flow-node-expression-paths.tsFlowNodeExpressionRolegains'value', with a docblock: a slot whose authored value may be a{ dialect: 'cel', source }expression envelope evaluated by the expression engine to the value the variable takes — not a predicate, not a template; the slot's SHAPE decides the dialect (a plain string stays{token}interpolation, an envelope is CEL).FLOW_NODE_EXPRESSION_PATHSgains one entry:{ nodeType: 'assignment', path: 'assignments.*', role: 'value', label: 'assignment value' }.*segment means "every own key of this object" — the sibling of[]("every element of this array"). The resolver understood only[]before, so this is the smallest extension: one new branch inwalk()guarded byhead === '*'; every existing entry resolves byte-identically because no pre-existing path carries*and no pre-existing entry has thevaluerole (pinned with the ratchet's own fixtures restated inflow-node-expression-paths.test.ts).resolveFlowNodeExpressionsemits, for avalueentry, only envelope-shaped objects; for the two existing roles the rule is unchanged (non-empty strings).isExpressionEnvelopeShaped(a plain object with a stringdialect) is exported as the one recognizer both halves discriminate on — deliberately looser than "a valid envelope", so{ dialect: 'cel' }with nosourcereaches the validator and is refused there instead of being stored as a literal.AssignmentConfigSchemais exposed through the JSON mapgetSchemalessNodeConfigJsonSchemas()— the walk the ratchet already performs — by a NEW sibling mapLEDGER_DECLARED_NODE_CONFIG_SCHEMASinschemaless-node-config.zod.ts, deliberately not by addingassignmenttoSCHEMALESS_NODE_CONFIG_SCHEMAS: that const means "publishes no descriptor" andpackages/metadata-protocol/src/reference-sites.ts:480walks it for exactly that reason (limb 2 of reference-site attribution), so its key set and its readers stay untouched. Exported typesLedgerDeclaredNodeType/ReconciledNodeConfigType; the function's return type widens accordingly. Pinned spec-side:SCHEMALESS_NODE_CONFIG_SCHEMASkeys are still exactlydecision / script / subflow,LEDGER_DECLARED_NODE_CONFIG_SCHEMASkeys are exactlyassignment, and the projectedassignmentJSON carriesproperties.assignments.additionalProperties.xExpression === 'value'.assignments.*and only that: theassignmentnode's canonical config is theassignmentsmap its descriptor (logic-nodes.ts,additionalProperties: true) and the Studio keyValue editor declare, and every bundled example writes. The two legacy shapes the executor also normalizes — the bare{ variable: value }config and theassignments: [{ variable, value }]array — are read-compatibility the descriptor does not declare and the form↔Zod ledger pins as "not offered for new authoring"; their values keep today's meaning (an envelope-shaped object there is the literal it always was). Declaring a top-level*entry would also misreadconfig.assignmentsitself as a value slot whenever an author names a variabledialect.packages/spec/src/automation/builtin-node-config.zod.tsAssignmentExpressionValueSchema=ExpressionSchema.safeExtend({ dialect: z.literal('cel') })— the canonical{ dialect, source, ast?, meta? }envelope fromshared/expression.zod.ts(the spellingvalidateExpressionalready reads:packages/formula/src/validate.ts,toSource()), with its ownsource-or-astrefinement kept and the dialect narrowed at the type level. Not a second spelling..extend()is refused by Zod on a refined object;safeExtendis the sanctioned form — measured.AssignmentValueSchema—z.unknown()refined: anything that is not envelope-shaped passes untouched (strings, scalars, arrays, plain objects — the preservation half); an envelope-shaped value must parse as the CEL envelope, else acustomissue at the value's path whose message starts with the fixed sentenceASSIGNMENT_VALUE_ENVELOPE_REFUSAL. Carries.meta({ xExpression: 'value' })— the ledger's declaration channel for this slot; measured to ridez.toJSONSchemaonto the map'sadditionalProperties(pinned). Built eagerly rather than throughlazySchema:.meta()registers by schema identity and the lazy Proxy is not that identity, so a lazily wrapped marker never reaches the JSON Schema (measured; the sibling markers all sit on eager inner schemas).AssignmentConfigSchema—{ assignments?: Record(name → AssignmentValueSchema) }with an open catchall for the bare legacy keys; the array form is refused as a type error on the record carryingASSIGNMENT_ARRAY_FORM_PRESCRIPTION(an object-level refinement never runs once a property failed its type — Zod aborts the object; measured). Plus the ADR-0122…Parsedaliases.assignmentmoves from "deliberately absent" to "described by its VALUES, not by a key set".packages/spec/src/automation/node-executor.zod.ts— theconfigSchemadocblock names the third role and its declaration channel.flow-node-expression-paths.test.ts(new, 17 cases) and a newdescribeinbuiltin-node-config.test.ts(10 cases): the entry and role; resolution on a two-variable fixture (owner_name: '{manager.name}',digest: envelope) yields exactlyassignments.digestwith the envelope verbatim;{token}strings and literals skipped; a malformed envelope still resolved; legacy shapes resolve nothing; the ratchet'sconditionpin kept; every pre-existing entry byte-identical; envelope accepted (the ruling'sjoinNonEmpty(overdue_tasks.map(t, t.subject), "\n")source, verbatim); interpolation and every other literal still accepted; malformed envelopes refused withcode+path+ the leading sentence (nosource, emptysource, non-stringsource, unknown /template/crondialect); the array form's prescription; thexExpression: 'value'marker in the JSON Schema.content/docs/automation/flows.mdxgains an "Assignment (set variables)" example with both forms and the digest, plus a warn callout stating the runtime status honestly (the executor half has not landed; ascriptnode is the workaround today). Generated:content/docs/references/automation/builtin-node-config.mdx,api-surface/,export-origins/,declaration-map/,authorable-surface/,json-schema.manifest/(automation shards), the strictness ledger counts..changeset/assignment-value-role-cel-envelope.md—@objectstack/specminor.Measured, not edited
validateExpression's roles live inpackages/formula/src/validate.ts:41—FieldRole = 'predicate' | 'value' | 'template'— so avaluerole already exists there (line 553 onward: "predicate | value → CEL"). Nothing inpackages/specholds a role table; the ledger'sFlowNodeExpressionRoleis the declared-and-pinned side. Probed on the built engine at this head:validateExpression('value', { dialect: 'cel', source: DIGEST })→ok: true; atemplateenvelope → "expected a CEL expression but got atemplatedialect.";celEngine.evaluate({ dialect: 'cel', source: DIGEST }, { extra: vars, record: vars })→"Renew contract\nCall ACME"for three rows with one empty subject — themapmacro andjoinNonEmptyboth work, so the ruling's example is a real digest body. One seam for the executor half:validateExpression('value', { dialect: 'cel' })(nosource) answersok: true— a missing source reads as "not authored" there — soAssignmentConfigSchemais the only gate that refuses{ dialect: 'cel' }, and the executor half must parse the config against it (theparseNodeConfigseam) before validating.pnpm --filter @objectstack/spec check:livenessgreen; the gate is registry-rooted over metadata types at one-level granularity andflow.nodes.configcarries a single blanketliveverdict (liveness/flow.json,props.nodes.children.config), so node-config values are not walked and noplannedentry is owed; the executor pointer sits in the role's docblock.@objectstack/linttypecheck→ 0 andsrc/validate-expressions.test.tsgreen (its ledger consumer guards withrole !== 'predicate', not an exhaustive switch).@objectstack/service-automationhas NOtypecheckscript (ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT);tsc --noEmit -p tsconfig.jsonin the package → 3 errors, allsrc/nested-region-parity.test.ts(Property 'flows' is private), byte-identical onorigin/main, none naming the role union — pre-existing test-layer debt, not this change.The widening — seat ruling 5533840057, one test file
packages/services/service-automation/src/builtin/config-expression-ledger.test.ts(the #4027 ratchet) is the only edit outside the declared surface, on the ruled four-condition adjacent-fix path. What it gains, and what it does not:ROLE_BY_MARKER.value = 'value'— a third row besideexpression → predicateandtemplate → flow-template, with the docblock stating that it ISvalidateExpression'svaluerole and what makes it a distinct ledger role (the slot's shape rule).collectExpressionPropsdescends an object-valuedadditionalPropertiesas a*segment — the sibling ofitems→[], the same spelling the spec resolver gained. A bareadditionalProperties: true(theassignmentDESCRIPTOR's own shape) declares nothing, so the descriptor channel and the spec channel cannot double-declare the slot.assignment.assignments.*is in the ledger with rolevalue, reaches the ratchet through the schemaless-config JSON channel specifically and NOT through the descriptor channel, and the marker is mapped (ROLE_BY_MARKER.value === 'value'). The existing pins are untouched, including the line-270 pin (assignmentwith a bareconditionresolves nothing).@objectstack/spec/automationthroughdist/, so each leg rebuilt spec withOS_SKIP_DTS=1and proved the mutation reacheddist/withscripts/ablation-dist-preflight.mjs; source restored bygit checkout HEAD -- ABS_PATHand proven by blob hash + emptygit diff HEAD; a final FULL build restoreddist/):assignmentremoved fromLEDGER_DECLARED_NODE_CONFIG_SCHEMAS(source occurrences 0, dist preflight--absentexit 0) →Tests 2 failed | 10 passed (12):the ledger carries no path a builtin no longer declareswith the verdictstale ledger entries — no descriptor or schemaless schema declares these: expected [ 'assignment.assignments.* (value)' ] to deeply equal [], plus the new positive pin;xExpression: 'value'→'valeu'on the map value (injected count 1, dist preflight present exit 0) →Tests 5 failed | 7 passed (12):every xExpression property a builtin declares is in the ledger, the schemaless-channel derivation, the stale check, the 表达式 ledger 只认 descriptor configSchema,所以「刻意 schemaless」的节点上声明的 CEL 槽位没有任何 build-time 校验器 #4439 decision pin and the new pin, with the verdictassignment.assignments.* declares an unknown xExpression marker 'valeu' — add it to ROLE_BY_MARKER and teach the validators which dialect it takes;Tests 12 passed (12).d902edfd(first report) the file ranTest Files 1 failed (1)/Tests 1 failed | 10 passed (11)— the stale-entries assertion; at11e61c06it runsTest Files 1 passed (1)/Tests 12 passed (12)(the full file, every describe).service-automationchangeset: a test-only edit publishes no bytes. The specminorchangeset stays.Executor half (read-and-report,
packages/services/service-automation)src/engine.ts:6900-6901(invalidateFlowExpressions, the ledger consumer):for (const found of resolveFlowNodeExpressions(node.type, node.config)) { if (found.entry.role !== 'predicate') continue; …— every non-predicateslot is resolved and skipped, so the newvalueentry is walked and ignored atregisterFlow; nothing callsvalidateExpression('value', …).src/builtin/logic-nodes.ts:112-137(theassignmentexecutor): normalizes the three shapes to pairs and runsvariables.set(key, interpolate(value, variables, context))(line 135).src/builtin/template.ts:346-368(interpolate): for an object it recurses into every property and returns a copy — so an envelope{ dialect: 'cel', source: '…' }is written into the variable VERBATIM as a literal object (itssourcestring gets{token}interpolation on the way, so a CEL map literal inside it would even be mangled), never evaluated;notify-node.ts:259-260then renders{digest}throughstringifyForTemplate→ the JSON text of the envelope. The executor half therefore needs: evaluate envelope-shaped values in the canonical map through the expression engine ({ extra: vars, record: vars }is the context shapeengine.ts:7757already uses), parse the config againstAssignmentConfigSchemaat execute time, checkvalueslots withvalidateExpression('value', …)atregisterFlow(and the lint passpackages/lint/src/validate-expressions.ts:1138, another lane), and extend the ratchet as above.Studio form (read-and-report, objectui
origin/maine304a4ef)packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts:475-479:assignment: [cfg('assignments', 'Assignments', 'keyValue', …)]— the descriptor's free-form map rendered byFlowKeyValueField.tsx.FlowKeyValueField.tsx:47-63parseValue: a value cell that starts with{and ends with}isJSON.parsed on commit (arrays likewise), non-JSON falls through to a plain string, andtoRawrenders a stored object back as its JSON text — so the form ALREADY accepts a non-string value and an author CAN type{"dialect":"cel","source":"joinNonEmpty(...)"}and have it stored as the envelope object. What it lacks is an affordance: no per-row "CEL value" toggle or CEL editor, novalidateExpression('value', …)feedback (theFlowExprIssueit imports is for template strings), and the simulator (previews/simulator/flow-simulator.ts:352-360,interpolateValue) returns a non-string value as-is, mirroring the runtime's verbatim write.json-schema-to-fields.ts:243-254readsxExpressiononly on string properties ('expression' | 'template'), so the new marker onadditionalPropertieschanges no editor and breaks nothing. Verdict: usable without an editor change; a consumer-side card is warranted for the affordance (a per-row envelope toggle + value-role validation + simulator evaluation), not required for the contract.Verification record at the merged head
d25a6606Exits captured by redirect; verdict lines the gates' own.
pnpm --filter @objectstack/spec check:generated --fixon the merged tree regenerated nothing (✓ All 15 generated artifacts are up to date.), so the head did not move after the ledger commit.packages/services/service-automation/src/builtin/config-expression-ledger.test.ts→Test Files 1 passed (1)/Tests 12 passed (12).Test Files 3 passed (3)/Tests 87 passed (87).pnpm --filter @objectstack/spec build→ exit 0;pnpm --filter @objectstack/spec typecheck→ exit 0.check:generated→✓ All 15 generated artifacts are up to date.;check:strictness-ledger→✓ docs/audits/2026-07-unknown-key-strictness-ledger.counts.md is current — 440 site(s) measured, 1 authorable strip site(s) left.node scripts/check-system-context-census.mjs→OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.assignments: [{ variable, value }]array-form refusal noted by the director's audit, card comment 5534406255, is left for the executor-half card the seat files at ACCEPT).Verification record at the pre-merge head
11e61c06Every exit captured by redirect before any read; the verdict lines quoted are the gates' own. Order of events at this head: the union chunks and the locked spec runs first on the quiescent committed tree; then the two dist-resolved ratchet legs above (which rebuild spec); then a final full
pnpm --filter @objectstack/spec build(exit 0) and, on that rebuilt dist, the dist-reading gates again.Test Files 3 passed (3)/Tests 87 passed (87).Test Files 1 passed (1)/Tests 12 passed (12).pnpm --filter @objectstack/spec build(the final full build, after the legs) → exit 0;pnpm --filter @objectstack/spec typecheck(after it) → exit 0;pnpm --filter @objectstack/lint typecheck→ exit 0.check:generatedafter the final rebuild →✓ All 15 generated artifacts are up to date.(check:generated --fixbefore the last commit regeneratedapi-surface/andexport-origins/for the three new automation exports and nothing else).Test Files 467 passed (467)/Tests 12463 passed (12463).node scripts/check-system-context-census.mjsafter the last edit →OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.pnpm exec eslint --no-inline-configover the seven edited TypeScript files (six spec, the ratchet) → exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat this head: 92 commands (the 90 derived at the previous head plusnode scripts/check-tenant-audit-census.mjsand its--self-test, both exit 0). Run one by one on the quiescent tree: 89 exit 0 —check:skill-examplesafter building the@objectstack/client-reactclosure it reads (✅ 256 prose examples type-check across 3 surface(s)); 3 answer NOT MEASURED by their own verdict —node scripts/check-test-completeness.mjs(exit 3,PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named),pnpm check:dual-build-cjs-loads(exit 3,PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/),pnpm check:type-check-debt(exit 3,PREREQUISITE NOT MET— the whole-workspace built closure is CI's;check:type-check-coverageinside it reportsOK — 72/79 workspace packages type-checked). After the final rebuild the dist-reading members were re-run and are 0 again:check:generated,check:api-surface,check:export-origins,check:docs,check:authorable-surface,check:liveness,check:exported-any,check:dual-source-exports,check:entry-nameability,check:browser-reachable-entries,check:strictness-ledger,check:role-word,check:spec-parsed-alias.Record at the previous head
d902edfd(the two files the reverse-verification legs below mutate changed only in comment lines since —git diff d902edfd 11e61c06on them shows no code line — so those legs stand):Test Files 3 passed (3)/Tests 86 passed (86)(87 at11e61c06, one new channel pin).pnpm --filter @objectstack/spec build→ exit 0 (DTS emitted;check-dts-emittedandcheck-dev-prereqs --stamppassed inside it).pnpm --filter @objectstack/spec typecheck→ exit 0 (tsc, scripts program, test program — the test files are insidetsconfig.test.json'ssrc/**include, so the new pins are type-checked).pnpm --filter @objectstack/spec check:generated --fixthencheck:generated→✓ All 15 generated artifacts are up to date.vitest run --maxWorkers=2under the shared verify lock:Test Files 467 passed (467)/Tests 12462 passed (12462).check:authorable-surface,check:liveness,check:docs,check:api-surface,check:export-origins,check:strictness-ledger→ exit 0 (inside the union below).pnpm exec eslint --no-inline-configover the five edited TypeScript files → exit 0.node scripts/check-system-context-census.mjsafter the last edit →OK — 106 elevation read sites in 20 packages across 45 files, all anchored.pnpm check:role-word→ 0 andpnpm check:spec-parsed-alias→OKafter the two findings they raised on an earlier head were fixed ind902edfd(ADR-0090 reserved word in the flows page; ADR-0122…Parsedaliases).node scripts/pm/dispatch-gates.mjs --commands(no paths passed; derived from the merge base at this head): 90 commands run one by one; 87 exit 0 (check:skill-examplesafter building the@objectstack/client-reactclosure it reads:✅ 256 prose examples type-check across 3 surface(s)); 3 answer NOT MEASURED by their own verdict —node scripts/check-test-completeness.mjs(exit 3,PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named),pnpm check:dual-build-cjs-loads(exit 3,PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/),pnpm check:type-check-debt(exit 3,PREREQUISITE NOT MET— the whole-workspace built closure is CI's;check:type-check-coverageinside it reportsOK — 72/79 workspace packages type-checked).Reverse verification (from the committed state, restore by blob hash)
Script with
trap restore EXIT INT TERM, absolute paths undergit rev-parse --show-toplevel; the pins import the modules under test by relative source specifier, so nodist/is involved and a mutation is live the moment it is on disk.assignmentledger entry deleted: on-disk proofgrep -c "path: 'assignments.*'"= 0 (expected 0),git diff --stat1 file / 23 deletions → pins6 failed | 53 passed (59)(the entry, the role union, the two-variable resolution, the malformed-envelope resolution, the authoring-order resolution, the five-entry ledger pin). Restore:git checkout HEAD -- FILE;git hash-object=500b8a57…=HEAD:blob;git diff HEADempty.if (result.success) return;arm replaced by an unconditional refusal; injected marker count 1, original anchor count 0,git diff --stat1 file / +1 −1) → pins3 failed | 56 passed (59)(the accept pin with the ruling's example, the two-forms pin, the "wherever it sits" pin whose green key now reds). Restore proven the same way: blob8a5c42d2…=HEAD:;git diff HEADempty.59 passed (59).Notes for review
check-dev-prereqs.mjsat this head still names 32 unbuilt app packages — the dev-server precondition, not a gate in the union.packages/services/service-automation/src/**outside the ratchet test (logic-nodes.tsincluded — the descriptor keepsadditionalProperties: trueand the form↔Zod ledger's pin on it is untouched),packages/formula/src/validate.ts, objectui.🤖 Generated with Claude Code
https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
Generated by Claude Code