Repository navigation
feat(spec)!: flow value slots refuse the {…} template dialect, naming the CEL spelling of each token (#19939, C half) - #22259
Conversation
Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…mplate dialect (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…plate retirement (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…ssion (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…ow-template-dialect-retire
…s after merging main (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…ation now (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…ow-template-dialect-retire
…e v18 pre line is open on main (#19939) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 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 — 140 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 1158e495b9daa86f87f4bfdf87a9ee3ccc28cdff && git checkout 1158e495b9daa86f87f4bfdf87a9ee3ccc28cdff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 335c4737f8269ca7a80c7674e997e75139dec43f b073d92ded5132d710a1c952b858e9308854dfda && git checkout -B drift-repro 335c4737f8269ca7a80c7674e997e75139dec43f && git merge --no-ff b073d92ded5132d710a1c952b858e9308854dfda
node scripts/docs-audit/affected-docs.mjs --json 335c4737f8269ca7a80c7674e997e75139dec43f
|
Contract reviewServed-tier: Inputs read, and nothing else: card #19939 (body and all 7 comments, the newest ① Derived judgments
② Semver level
③ Boundary flagsDev report
Implemented-by: VERDICT: PASS |
…n fields / assignment values and / 100.0 for money (objectstack-ai#22284) Fixes objectstack-ai#22260 Clause-②: no `skills/objectstack-automation/SKILL.md` taught `{token}` values in `create_record` / `update_record` `fields` and the money sample `{round(x * 100) / 100}`. Since objectstack-ai#19939 pass 1 landed (PR objectstack-ai#22259 is merged; its changeset `.changeset/19939-flow-value-slot-template-dialect-refused.md` is the FROM → TO authority for every spelling below) the tree refuses those values at `objectstack validate`, `registerFlow` and the executors, and `/ 100` is integer division in CEL (objectstack-ai#11182 ruling D item 2 prescribed `/ 100.0`). objectstack-ai#19939's second half (the two kept spellings) remains open. Tier H (`skills/**`): this PR stays draft and lands on an authorized APPROVED review or by the maintainer's hand — no seat readies, queues or arms it. ## What changed — two files, `skills/objectstack-automation/` only **`SKILL.md` (+33 / −32 lines on `origin/main` `c8bb3c8d9c`):** - **Item 1 of the authoring-traps list (`:224-241`)** is rewritten around the value slot: in `create_record` / `update_record` `fields.*` and an `assignment` value (its two legacy shapes too) a string is the literal text it spells, a `{…}` token is refused at the three doors naming its CEL spelling, and a computed value is `{ dialect: 'cel', source: '…' }` — one worked `has()` form (`'{record.owner}'` → `source: 'has(record.owner) ? record.owner : null'`, with "nothing / `null` / a default is now YOUR call"), the array-index form (`'{record.tags.0}'` → `'record.tags[0]'`), the money form (`source: 'round(x * 100) / 100.0'` — CEL divides two integers as integers, so `/ 100` turns `123.46` into `123`), and the two spellings a value slot still accepts (`{NOW()}` / `{TODAY() ± N}`, `{$User.PATH}`). A second paragraph names the slots pass 1 does not touch — text slots (notify `title` / `message`, `script` `inputs`, `http` `url` / `body`, …) and `filter` keep the single-brace template — and keeps the text-slot grammar and its two traps (`{{x}}` is the template-field dialect; an unknown call-position name fails the node at run time). - **Item 2 (`:245-246`)**: `fields: { ref: '{newRec}' }` / `'{newRec.id}'` → `fields: { ref: { dialect: 'cel', source: 'newRec' } }` (writes the whole record object) / `source: 'newRec.id'`. - **Item 3's example (`:273`)**: `fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' }` → `fields: { ai_category: { dialect: 'cel', source: 'ai.ai_category' } }` (one field; the second was the same spelling twice). - **Filter-tokens section (`:207-208`, `:213-214`)**: `fields` dropped from "in `title`, `message`, `fields` and `url` a bare `{current_year_start}` is a nonsense reference" (a `fields` string is now literal text, not a reference at all) and "write payload" dropped from "(message body, `http` url, write payload) only renders an empty string — a warning" (a `fields` `{record.x}` is now an error at validate, not a warning). **`evals/flows-triggers-approvals.json`** (eval 5): "persists `{recalc.discount}` via `fields`" → "persists it with `fields: { discount: { dialect: 'cel', source: 'recalc.discount' } }`"; `must_contain` gains `dialect: 'cel'`, `must_not_contain` gains `{recalc`, so the eval now pins the new spelling and refuses the old. ### Every `{…}` value spelling in the package, judged against the changeset's refused list | File `:line` on `c8bb3c8d9c` | Spelling | Slot | Verdict | Now | |:--|:--|:--|:--|:--| | `SKILL.md:224-226` | "Value fields on a node's `config` (`fields`, `inputs`, notify `message`/`title`, …) interpolate `{token}`" | puts `fields` in the template set | false for `fields` | item 1 rewritten; `inputs` and notify text keys stay in the template list | | `SKILL.md:232` | `{round(x * 100) / 100}` | value-slot teaching | refused (expression), and integer division | `source: 'round(x * 100) / 100.0'` | | `SKILL.md:235` / `:237` | `body: '{{ai_reply}}'` / `body: '{ai_reply}'` | slot unnamed | in a value slot `{ai_reply}` is refused (path); in a text slot still the template | folded into the text-slot paragraph (`{{x}}` is the template-field dialect) | | `SKILL.md:236-237` | `ticket: '$source.id'` / `ticket: '{source.id}'` | `fields`-shaped key | `{source.id}` refused (path) | retired; the rule survives as "a string is the literal text it spells" | | `SKILL.md:238-241` | `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` | slot unnamed | in a value slot the `{…}` is refused at validate first; in a text slot still fails the node at run time | kept for text slots, one spelling | | `SKILL.md:245` | `fields: { ref: '{newRec}' }` | `update_record` field value | refused (path) | `fields: { ref: { dialect: 'cel', source: 'newRec' } }` | | `SKILL.md:246` | `fields: { ref: '{newRec.id}' }` | `update_record` field value | refused (path) | `source: 'newRec.id'` | | `SKILL.md:267` | `inputs: { ticketId: '{record.id}' }` | `script.inputs` | not a value slot — `screen-nodes.ts:343` still interpolates | unchanged | | `SKILL.md:272` | `filter: { id: '{record.id}' }` | `filter` | keeps the template | unchanged | | `SKILL.md:273` | `fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' }` | `update_record` field value | refused (2 paths) | one CEL envelope | | `SKILL.md:107-114` | notify `recipients` / `title` / `message` / `sourceId` | text slots | keep the template | unchanged | | `SKILL.md:154` | "The handler reads `{NODEID.error}` (or run-wide `{$error}`)" | no slot named | true in a text slot; in a value slot the refusal names `vars["$error"].message` | unchanged (see Acceptance notes) | | `SKILL.md:208` / `:213` | "`fields`" / "write payload" in the filter-tokens prose | — | false since pass 1 | dropped | | `references/examples-flows.md:41`, `:119-121` | `{TODAY()}`, `{TODAY() + N}` | `filter` | keeps the template | unchanged | | `references/examples-flows.md:44` | `fields: { status: 'escalated' }` | field value | literal | unchanged | | `references/state-machines-and-approvals.md:120` | `filter: { id: '{record.id}' }, fields: { stage: 'closed_won' }` | `filter` / literal field | keep | unchanged | | `evals/flows-triggers-approvals.json:7` | `renewal_date: { $lt: '{TODAY()}' }`, `fields: { status: 'lapsed' }` | `filter` / literal | keep | unchanged | | `evals/flows-triggers-approvals.json:17` | notify `recipients: '{record.owner_id}'`, "`title` with single-brace interpolation" | text slots | keep | unchanged | | `evals/flows-triggers-approvals.json:47` | "persists `{recalc.discount}` via `fields`" | `update_record` field value | refused (path) | CEL envelope, pinned | `references/_index.md` is generator-owned and unchanged. No `os:check`-marked block changed (the two in this package are in `references/*.md`, untouched). ## `skills/**` readings — the token ratchet, paid inside each file | File | Before (`c8bb3c8d9c`) | After (`5ac59ff0da`) | Ceiling | |:--|:--|:--|:--| | `SKILL.md` | 438 lines · 23125 bytes · **5782 tokens** | 439 lines · 23052 bytes · **5763 tokens** | 5785 (headroom 3 → 22) | | `evals/flows-triggers-approvals.json` | 55 lines · 5019 bytes · **1255 tokens** | 55 lines · 5018 bytes · **1255 tokens** | 1255 (headroom 0 → 2 bytes) | | whole package, all 10 `SKILL.md` | 4408 lines · **52572 tokens** | 4409 lines · **52553 tokens** | −19 tokens | Token = `ceil(utf8 bytes / 4)`, the gate's own convention. No ceiling moved; nothing was re-wrapped to buy a line. Every deleted line and where its content survives: 1. Item 1's grammar bullets (`{var}` / `{record.title}`, `{record.tags.0}` array index, `{$User.Id}` / `{NOW()}` / `{TODAY() + 30}`, the six functions "mirror the CEL stdlib 1:1", "anything without `{…}` is a literal") → compressed into the text-slot paragraph (same facts, now scoped to the slots that still read the template); "(e.g. a `multiple: true` lookup, stored as an array)" → "(array index)". 2. "`round` is integer-only (no `round(x, 2)`); for N decimals write `{round(x * 100) / 100}` (scale 2)" → the money bullet (`/ 100.0`); `round(x)` → int is in objectstack-formula's stdlib table, which item 4 and the opening blockquote already point at. 3. ❌ `body: '{{ai_reply}}'` → "`{{x}}` is the template-field dialect" in the text-slot paragraph. 4. ❌ `ticket: '$source.id'` and ✅ `body: '{ai_reply}'`, `ticket: '{source.id}'` → retired: in a value slot the ✅ spelling is now refused outright and the refusal names its CEL form; the literal rule survives as "a string is the literal text it spells" (value slots) and "no `{…}` ⇒ literal" (text slots). 5. ❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` "… with a named error naming the supported set. The build does not catch these (conditions are checked, call-position names are not)" → one spelling, the same rule ("fails the node at run time, unchecked at build, not `fault`-routable"), scoped to text slots — in a value slot the build refuses the `{…}` first. 6. `:277` the one-line `defineStack({ functions: { 'helpdesk.aiTriageStub': … } })` registration — spelled a third time (item 3's head shows `defineStack({ functions: { my_fn: … } })` and the second fence registers `'helpdesk.aiTriageStub'`) → deleted. 7. `:263` `// ❌ DON'T: expect the function to update the record itself (it has no data API)` → restated the prose two lines above the fence ("it does NOT read/write the database") → deleted; the ✅ line stays. Eval file, paid by: "(FlowSchema has no top-level `schedule`; no cron tagged template)" → "(no top-level `schedule`; no cron tag)"; "nodes wired with `edges` (never `next`), ending in an `end` node" → "`edges` (never `next`), an `end` node"; "Failure routing is an edge `{ source, target, type: 'fault' }`" → "a `type: 'fault'` edge" (the shape stays pinned by `must_contain`). ## Measured at the tree, not recalled **Refusal control** — built `packages/spec/dist` at `5ac59ff0da`, `flowNodeValueTemplateRefusals` and `UpdateRecordConfigSchema` imported from `@objectstack/spec/automation`: - old `:273` → 2 refusals, `path=fields.ai_category` / `fields.ai_sentiment`, `label=update_record field value`; each message leads with the `VALUE_SLOT_TEMPLATE_REFUSAL` sentence ("A value slot no longer reads the `{…}` template dialect: a string here is the literal text it spells …") then "Write `{ai.ai_category}` as { dialect: 'cel', source: 'ai.ai_category' }. CEL refuses an absent variable or key where the template wrote nothing, so guard one that may be absent with `has()`: `has(ai.ai_category) ? ai.ai_category : null` (the guarded form writes `null`)." - old `:246` → 1 refusal at `fields.ref`: "Write `{newRec.id}` as { dialect: 'cel', source: 'newRec.id' } …" - old `:232` → 1 refusal at `fields.total`: "Write `{round(x * 100) / 100}` as { dialect: 'cel', source: 'round(x * 100) / 100.0' }. Every division keeps a decimal operand: CEL divides two integers as integers, so `round(x * 100) / 100` drops the decimals where `round(x * 100) / 100.0` keeps them." - the new envelopes (`ai.ai_category`, `newRec.id`, `round(x * 100) / 100.0`, `has(record.owner) ? record.owner : null`) → 0 refusals; `UpdateRecordConfigSchema.safeParse` on the old sample → `success: false` at path `["fields","ai_category"]`, on the new sample → `success: true`. - the two survivors in `fields` (`'{NOW()}'`, `'{TODAY() + 30}'`, `'{$User.Id}'`) → 0 refusals; notify `title` / `message` and `script` `inputs` carrying `{record.id}` → 0 (not value slots); legacy `assignments: [{ variable, value: '{record.amount}' }]` → 1 refusal at `assignments[0].value`. **CEL evaluation** — built `@objectstack/formula`, `ExpressionEngine.evaluate({ dialect: 'cel', source }, scope)` with the engine's own `celScope` shape (`{ extra: { ...vars, vars }, record: vars }`, `engine.ts:11791-11806`), `x = 123.456`: - `round(x * 100) / 100` ⇒ `123` · `round(x * 100) / 100.0` ⇒ `123.46` · `round(x * 100)` ⇒ `12346` - `has(record.missing) ? record.missing : null` ⇒ `null` · bare `record.missing` ⇒ error "No such key: missing" · `record.tags[0]` ⇒ `"x"` · `has(vars.x) ? vars.x : null` ⇒ `123.456` - `list[0]` for a variable literally named `list` ⇒ error "Cannot index type 'type' with type 'int'" (`list` is a CEL type name) — see Acceptance notes. ## Gates — local, at `5ac59ff0da` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this tree derives 24 families from the two changed paths. All 24 ran, each exit code captured before any pipe; `--ran` reconciliation: "24 derived, 24 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 24 recorded an exit code and none of them is 3)". | Command | Exit | |:--|:--| | `node scripts/check-skills-token-ratchet.mjs` — "skills/objectstack-automation/SKILL.md is 5763 tokens (ceiling 5785; headroom 22)" · "54 authored bundle file(s) within their ceilings" | 0 | | `node scripts/check-skills-token-ratchet.mjs --self-test` | 0 | | `pnpm check:skill-identifier-liveness` — "Leg 1: 457 citation(s) over 53 published file(s) … Leg 2: 8 registered exhaustive section(s), 0 ledgered gap(s)" | 0 | | `pnpm check:corpus-claim-drift` — "Scanned: 254 .md/.mdx file(s) … skills 53" | 0 | | `pnpm check:doc-authoring` | 0 | | `pnpm check:nul-bytes` — "scanned 10237 text file(s) … no raw ASCII control bytes" | 0 | | `pnpm check:skill-compatibility` — "11 pinned major(s) all match the workspace (@objectstack/spec is 17.x)" | 0 | | `pnpm check:skill-frame-sync` | 0 | | `pnpm check:role-word` | 0 | | `pnpm --filter @objectstack/spec run check:skill-docs` — "Skill docs in sync" | 0 | | `pnpm check:watch-hint-literal` | 0 | | `pnpm --filter @objectstack/lint run check:doc-formula-expressions` — first answered exit 3, "PREREQUISITE NOT MET — `@objectstack/lint` is not built" (not a measurement); after `pnpm exec turbo run build --filter=@objectstack/lint --concurrency=2` under the verify lock (VERDICT command-exit 0) | 0 | | `node scripts/check-ci-filter-parity.mjs` · `check-closing-keyword-parity.mjs` (+ `--self-test`) · `check-comment-mask-corpus.mjs` · `check-doc-route-spelling.mjs --advisory` (+ `--self-test`) | 0 each | | `pnpm check:agent-test-spelling` · `check:cross-package-test-inputs` · `check:driver-memory-census` · `check:gitlink-declared` · `check:pm-governed-merges` · `check:refd-timer-probe` | 0 each | `check:skill-examples` not run as a control: no marked block changed. Not run locally (CI-owned): `pnpm lint`, the type-check lanes, Test Core — no package source changed. No package `test` / `typecheck` is owed: the diff touches no `packages/**`. Changeset: none — `skills/**` publishes nothing from a released package; `skip-changeset` is applied with the PR assignee in one `label-write` call (recorded in the report comment on objectstack-ai#22260). ## Acceptance notes - **Finding, for the seat to file (class c)**: the changeset's FROM → TO row `'{list.0}'` → `{ dialect: 'cel', source: 'list[0]' }`, and `celPath` in `packages/spec/src/automation/flow-value-slot-template.ts` which every refusal message is built from, emit `list[0]` for a variable literally named `list`; under CEL `list` is a type name, so the remedy an author copies evaluates to "Cannot index type 'type' with type 'int'" (probe above). Affected: variables named after a CEL type (`list`, `map`, `int`, `string`, `bool`, `double`, `uint`, `bytes`, `type`, `timestamp`, `duration`, `null_type`). Reach: measured through the built formula engine, not through `objectstack validate` (whether validate accepts `list[0]` was not probed). Dedupe words: `list[0] CEL type name`, `celPath value slot remedy`, `Cannot index type 'type'`, `flow-value-slot-template list variable`. The skill now teaches `record.tags[0]` instead. - Noted, not filed: `SKILL.md:154` "The handler reads `{NODEID.error}` (or run-wide `{$error}`)" names no slot; it holds in a text slot, and in a value slot the refusal names `vars["$error"].message`. Left as is (22 tokens of headroom); carrier: objectstack-ai#19939's second half, which will touch this item again. - Noted, not filed: the frontmatter `description` still routes "CEL expressions in flow conditions / edge guards" to objectstack-formula; value envelopes now also are CEL. Untouched because the frontmatter regenerates `skills/README.md` and `content/docs/ai/skills-reference.mdx` (`check:skill-docs`), outside this card's file surface; carrier: none. ## 维护者速读(草稿) ### 改了什么 发布版自动化技能(`skills/objectstack-automation/SKILL.md`)里教 AI 怎么给流程节点的 `fields` 赋值的那一条,改成了现在运行时真正接受的写法:`create_record` / `update_record` 的 `fields` 和 `assignment` 的值,要么是字面量,要么是 CEL 信封 `{ dialect: 'cel', source: '…' }`;旧的 `{token}` 单花括号写法在这些位置会被 `objectstack validate`、`registerFlow` 和执行器拒绝。同时把金额取两位小数的样例从 `/ 100` 改成 `/ 100.0`,补了「键可能不存在时用 `has()` 守一下」的写法,并明确写出仍旧接受的两种旧写法(`{NOW()}` / `{TODAY() ± N}`、`{$User.Id}`)和没有变化的位置(通知文案、`inputs`、`http`、`filter` 仍用单花括号模板)。配套的一条 eval 也改成期望新写法。两个文件各自在自己的 token 上限内付清,没有抬上限。 ### 为什么改 这份技能随 `npx skills add` 发到每个客户项目,是 AI 写流程之前读的第一份材料。PR objectstack-ai#22259 合并后,它教的 `fields` 写法会在校验时被拒;更糟的是金额样例 `{round(x * 100) / 100}` 没有任何门拦着——在 CEL 里是整数除法,123.46 会悄悄变成 123。实测:旧样例在构建后的 spec 上全部被拒并给出 CEL 写法;新样例全部通过;CEL 引擎里 `/ 100` 得 123、`/ 100.0` 得 123.46。 ### 风险与代价(含回滚) 只改了技能文本和一条 eval,不碰 `packages/**`,不发包、不需要 changeset。风险在措辞:每句话都对照了合并后的 changeset 和源码,24 个派生门禁本地全绿(含 token 棘轮、标识符存活、兼容版本)。回滚就是 revert 这一个 commit,没有数据或运行时影响。一个顺带发现(变量恰好叫 `list` 时,拒绝文案给出的 `list[0]` 在 CEL 里跑不通)不在本 PR 范围,留给席位立单。 ### 席位意见 (留空) ### 你要做的 审阅后在本 PR 上给一个 APPROVED(Tier H,`skills/**`),或自行合并;不需要其他操作。 --- _Generated by [Claude Code](https://claude.ai/code/session_01CXydFDyiQwNbGFkmwrcRQq)_ Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
… cited decision in words instead of a tracker number (stage 29) (objectstack-ai#22376) Part of objectstack-ai#20749 Clause-②: no Stage 29 of this card: the next area of class (e), the test strings shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513. This stage takes the second name-ordered group of the test files directly in `packages/spec/src/`: 15 files, `root-entry-migrations-split.pin.test.ts` through `stack.test.ts`. 12 of them carried 57 tracker ids in test titles (59 ids), one of which sits in a declared describe label rather than a literal title. All 57 now either state what their record decided, in words (form D), or drop the number where the title already says it. Text only: no assertion, identifier, test count, code comment, file name or non-test file changes. No file directly in `src/` carries an id in a string any more (`type-alias-convention.pin.test.ts`, which sorts after the group, reads 0); the card stays open for the subdirectory files named under Acceptance notes. ## Census (re-taken first) The instrument is stage 28's `census28.cjs`, byte-identical (md5 `31d8488b5194b8d3048e3fcaec0efaed`, the value stage 28 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 stage 27's landing `f7b8a5932b`, 191 / 200 in 53 files (titles 162 / 168, other 29 / 32) at `aa09db58c9`, and 73 / 76 in 24 files at stage 28's landing `b7e01fbbd`. | reading | messages / ids | files | |:--|--:|--:| | the group, base `43fc50051` | 57 / 59 (titles 56 / 58, other 1 / 1) | 12 of 15 | | class (e) whole, base `43fc50051` | 73 / 76 (titles 61 / 64, other 12 / 12) | 24 | | class (e) whole, this head | 16 / 17 (titles 5 / 6, other 11 / 11) | 12 | | the group, this head | 0 / 0 | 0 of 15 | The group reads 57 / 59, stage 28's landing figure: no re-cut. The census at `3f80f1716` (the dispatch base) and at stage 28's landing read the same per file as at `43fc50051`. Per file, messages at base: root-entry-migrations-split.pin 1, stack-artifact-crossref 11 (12 ids), stack-artifact-packages 3, stack-cross-reference-envelope 2 (one title, one declared label), stack-dev-logins 4, stack-email-template-locale-floor 6, stack-inline-action-crossref 13, stack-json-stage-package-body 4, stack-refusal-envelopes 2, stack-requires 4 (5 ids), stack-top-level-strict 5, stack 2. `stack-conversions-record`, `stack-duplicate-action-key` and `stack-provenance` carry no id in any literal. Controls: - Pathspec: the 15 named paths hit the control word `describe(` in 15 of 15 files, and a nonsense word in none; the census scanned 15 of 15. - Lit outside the group, at base and head: `ai/build-progress.test.ts` 2 / 2 and `ui/dashboard.test.ts` 2 / 2. - Lit at base and dark at head, inside the group: `stack-artifact-crossref` 11 / 12, `stack-inline-action-crossref` 13 / 13, `stack-requires` 4 / 5 and `stack-cross-reference-envelope` 2 / 2 each read 0. - Dark at both ends: `stack-conversions-record` has no id anywhere; `stack-duplicate-action-key` and `stack-provenance` carry an id in one comment line each and read 0. The 15 group files read 0 at head while 55 of their comment lines still carry ids (112 id-bearing lines at base, comments included, 14 files). - Planted, in a scratch tree: an id in a describe title, a plus-chain title, an expect message, an it.each row name, a template title and a declared string each read once (6 / 6); a comment, a six-digit number, an HTML entity and a markdown heading read 0. ## What changed 57 literals, one line each, in 12 files: +57 / -57. Every file keeps its line count. - 6 state the decision in words: "a modal target names a page, only" for the inline and the embedded modal-target refusals (stack-inline-action-crossref 133 and 330); "it was never built by a stack producer" for the hand-built stack object composeStacks refuses before either artifact-scoped rule runs (stack-artifact-crossref 380); "compatibility with the multi-package artifact" (stack-artifact-crossref 270); "unchanged from before the dangling-`objectName` refusal" (stack-inline-action-crossref 399); and the declared label "hooks[].object (the hook-ownership rule)", the rule ADR-0130 states as "the split must follow hook ownership" (stack-cross-reference-envelope 91). - 4 get their subject back where the number was the only subject: "the no-floor report: warn-once bookkeeping" and "the floor guard's declared scope boundary" (stack-email-template-locale-floor 159 and 179), "the strict top-level door: the accept side does not move" and "defineStack surfaces the unknown-top-level-key refusal" (stack-top-level-strict 173 and 225). - 47 drop a number the title already explains, with its connector: a `#N — ` or `#N ` prefix, a `[#N] ` prefix, or a trailing `(#N)`. That includes the twin title `stack-artifact-packages.test.ts:355`, which read `does not warn about an undeclared composition rule (objectstack-ai#5005 rule 3)` and now reads `does not warn about an undeclared composition rule`, the same text stage 28 gave its copy at `compose-stacks-manifest-preserve.test.ts:308`. ## Cited records 26 distinct ids. 25 answer 200 and were read: the body and the comment thread as the API serves it, with the rulings and landings that the rewritten titles state read in full. objectstack-ai#10485 answers 404; its decision was read from its landing commit `35ad101bc` (the `themes` carrier retired under ADR-0049 enforce-or-remove; `app.branding` is the one colour surface) and from the unknown-key strictness ledger, which records the maintainer's 2026-08-21 disposition as retiring the authorable surface, the phrase the title keeps verbatim. The comments counter of four records differs from what the comments endpoint serves (objectstack-ai#14122 serves 21 of 22, objectstack-ai#17614 5 of 6, objectstack-ai#16449 0 of 5, objectstack-ai#18056 5 against a counter of 4); everything served was read, and objectstack-ai#16449's decision is in its body. What each record decided, as the titles now say it: - objectstack-ai#6739, ruling A (2026-08-09): a `type: 'modal'` target names a page, only; the object fallback is consumer leniency and enters retirement. - objectstack-ai#20367, ruling B: one authoring shape; `defineStack` and `composeStacks` stamp a provenance mark and `composeStacks` refuses an unmarked input with `STACK_PROVENANCE_MISSING` / 422. - objectstack-ai#7456, Option A: the embedded `objectName` gets the registered rule's existence check, and only a dangling name newly refuses. - objectstack-ai#19926, ruling A: `packages: null` is malformed, not absent. - objectstack-ai#19784, ruling C: a non-array concat key is refused with the envelope. - objectstack-ai#5005 (2026-08-04): same value passes, a conflict throws a prescriptive error, an undeclared top-level key warns. - objectstack-ai#8687, Shape B: the top-level stack door is strict, and the lint yields to it. - objectstack-ai#14122 / objectstack-ai#18202: one artifact carries N packages; the artifact-scoped permission and seed references resolve against the artifact, a stack that does not opt in is untouched. - objectstack-ai#17518, ruling A prime: the artifact-stage and record-stage package bodies are declared beside the assembled one. - objectstack-ai#6889 and objectstack-ai#7397: inline page-element actions and object-embedded actions get the registered action rule's target cross-references. - objectstack-ai#17063: the `type: 'page'` list-view mount is retired. objectstack-ai#17556: an app declares its own `devHint` / `devLogins`. objectstack-ai#17614 and objectstack-ai#18056: the no-`en-US`-floor report and its declared scope. objectstack-ai#14552 and objectstack-ai#15963: every `defineStack` refusal carries an ADR-0112 envelope; objectstack-ai#16449 registers each shipped code in the ledger. objectstack-ai#3265 and objectstack-ai#3308: `requires` is validated at the producer and the camelCase aliases are removed. objectstack-ai#14153 and objectstack-ai#20332: trigger capability validation and its `triggers` without `automation` pair. objectstack-ai#20646: the root entry stops carrying the migration registries. objectstack-ai#2611: named import mappings are wired into the import path. ## Readers 173 needles (each old literal, a 24-character window around each id, and the text left and right of each id) were searched with `git grep -F` over the tracked tree at the base outside the 15 files, with a lit control (`composeStacks` in `stack.zod.ts`, hit) and a dark control (a nonsense string, no hit). One needle hit, one line: a `//` comment in `packages/qa/dogfood/test/fixtures/override-composite-fixture.ts` that quotes the tail of one title with its id. It is a comment, not a reader. The only reader of the declared label is the file's own `describe(row.label, ...)`. No group file reads a test name (`currentTestName`, `expect.getState`, snapshots: 0). The 15 file names appear elsewhere only in prose and comment lines, in `packages/spec/test-typecheck-debt.json` (keyed by file and error text, not by title) and in two `dispatch-gates.mjs` / `check-changeset-no-major.mjs` fixtures that name a path or a changeset file. Every old literal occurs exactly once across the 15 files (its own site). ## Text-only proof - `textonly28.cjs` (stage 28's, md5 `957eff6b3837d762b8e03d070155930a`), per file, base text against head text: both texts with every string token masked are byte-equal (code and comments), the string-token counts are equal, and every changed token sits in a describe / it / test title position or on a declared line, carried an id at base and carries no `#` plus digits at head. 15 / 15 files SAME; 57 changed tokens, 56 titles and 1 declared label (run with `--declared 91`); per-file counts exactly as predicted in writing at 2026-10-08T22:57Z, before the edit. The 3 untouched files read SAME with 0 changed. - 17 / 17 controls on scratch copies of the head files behave as predicted: an identifier rename, a numeric literal, a comment edit, a non-title string given an id, a rewritten title given a new id, a title that was id-free at base edited, one rewritten title reverted to base (SAME, 0 changed), an it.each title given an id, an expect message changed, a title re-split into a plus chain, a template title given an id, a test added, an id appended to a rewritten title in a many-title file, an untouched file (SAME, 0 changed), the declared label run without `--declared` (refused), the declared line plus another changed string (refused) and the declared line alone (SAME, 2 changed). - `git diff -U0`: 57 plus and 57 minus lines, each the planned line; 0 added lines carry `#` plus digits; control-byte scan of the 12 files and of this body: 0 hits. ## Tests - The 15 files with `--project local --project repo` and the JSON reporter, at the base (same worktree, before the edit) and at the head `65b42b7d1`: 405 / 405 passed in 15 files, 108 suites each side; per-file test count and status sequence identical in 15 / 15; 229 full names changed, 0 mismatches against the plan (each head name equals the base name with the planned replacement applied); 0 duplicate full names either side; names with `#` plus digits 229 at base, 0 at head. - spec `vitest run --project local`: 626 files passed, 18740 tests passed | 1 todo, exit 0. - spec `typecheck`: `VERDICT command-exit 0`; `check:test-typecheck` 52 files / 246 errors / 135 pinned signatures held, unchanged; `tsc --listFilesOnly -p tsconfig.test.json` holds 15 of 15 group files. - turbo build of all packages before the gates: `Tasks: 71 successful, 71 total`. Heavy runs went through `scripts/pm/os-verify-lock.sh` (slot `dev-20749-s29`). - No reverse verification or ablation applies: no assertion, gate or reader moved. ## Gates All at `65b42b7d1`. The dispatch list (77 commands, derived at `3f80f1716`) equals the list re-derived from the real change set at this head, 12 paths against merge base `43fc50051` (114 changed lines): nothing added, nothing dropped. All 77 exit 0 and `dispatch-gates --ran` reads "77 derived, 77 run, 0 NOT-MEASURED, 0 UNRUN". Also run, exit 0: the five roster families that share a directory with the diff (`check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) and `check:generated` ("All 15 generated artifacts are up to date", measured against the dist the full build wrote at this head). ESLint, narrowed and proven: 15 files linted with `--no-inline-config`, 0 errors, 0 warnings; the population read from ESLint's own config is 15 configured, 0 ignored; no file has `parserOptions.project` or `projectService`, so type-aware linting is not enabled and this diff cannot move the verdict of a file it did not touch. ## Packaging and landing - No changeset: `skip-changeset`. `npm pack --dry-run --json --ignore-scripts` in `packages/spec` lists 2069 files, none of the 12 changed files and no `*.test.ts`; controls `src/stack.zod.ts`, `dist/index.mjs` and `package.json` are present; an old and a new title phrase read 0 files in `dist/`, the control `Unrecognized key` reads 42. - Not governed: `check-governed-merges --test` on the 12 paths reads 0 of 12; 114 changed lines. - `git merge-tree` onto `origin/main` `6a53564b9`: clean. None of the 17 open PRs touches any of the 15 files (file lists read; 6 of them touch `packages/spec/src`, so the scan sees spec files). ## Acceptance notes - Class (e) is not finished: 16 messages / 17 ids remain in 12 files, all in subdirectories. By directory: `ui/` 9 / 9 in 7 files, `automation/` 2 / 3 in 1, `ai/` 2 / 2 in 1, and `api/`, `contracts/` and `kernel/` 1 / 1 in 1 each. Their plan is the cutting seat's: stage 27's landing sets the needles stage apart (one stage, with an at-tier review) and keeps `ui/`'s four colour literals as decided, and this stage measured neither. - Regrowth for stage 30's plan: five subdirectory test files carry 6 messages / 7 ids that were added after stage 27's landing. The files are older; the ids entered their strings in three commits. `api/meta-item-response-shapes.test.ts` 1 / 1 (a title, from objectstack-ai#22126); `automation/builtin-node-config.test.ts` 2 / 3 (two titles, from objectstack-ai#22259, which re-added ids to a title stage 10 had rewritten); `kernel/manifest.test.ts` 1 / 1, `ui/action-description.test.ts` 1 / 1 (titles) and `ui/view-submit-redirect-url.test.ts` 1 / 1 (an `other` string, to be needle-checked there), all three from objectstack-ai#22125. Nothing regrew since stage 28's landing: the per-file census at `b7e01fbbd` equals the one at `43fc50051`. Test files are outside `check:doc-authoring`'s ledgered leg, so nothing refused these on arrival. Not edited here. - Code comments in the 15 files still carry live ids (55 lines). Comments are not this card's share. Carrier: none. - `origin/main` moved from `43fc50051` to `6a53564b9` while this ran; one commit touches `packages/spec` (`api/error-code-ledger.zod.ts`, none of the 15 files), so nothing was merged into the branch. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ 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>
Part of #19939 — this lands the C half for every spelling CEL can write today, and measures the B half empty. What stays open on the card: refusing the two spellings this PR deliberately keeps (
{NOW()}/{TODAY() ± N}and{$User.*}), each once CEL can spell it — see "Kept, and why" and the report's open question.Clause-②: yes (narrowing)
The rulings this executes (quoted, not paraphrased)
5805777944, maintainer 「11182 D 其他同意」), item 3: "v18 carrier. C (refuse the template dialect at registration, per-spelling remedies:/ 100.0,has()guards, a string form forNOW()/TODAY()) and whatever of B is lossless ride the v18 train"; governing text: "ADR-0087 D2 (only lossless mappings ride an automatic conversion — the 12 DIFF spellings the round measured are not lossless)".6051196407: the card isdomain:specwhole; theservice-automationandlintfiles are declared cross-lane on the claim6052247536. The text-slot interpolation inbuiltin/template.tsis [v18] flow text slots: read ADR-0032 §3's{{ }}delimiter instead of single-brace{token}(notify title/message and the other flow string slots), converting only what renders the same #22110's and is untouched (one docblock paragraph names the value-slot retirement; no code there changed).origin/mainf4bed5834(merged into this branch):.changeset/pre.jsonis present ("mode": "pre","tag": "next"— chore(release): enter Changesets pre mode (next) with onemajormarker, so v18 opens at 18.0.0-next.0 #22084 entered pre mode at 07:03Z today), so per triage6038868940and the dispatch this is gradedmajor, with a BREAKING section and an ADR-0087registereddisposition. It was absent at the claim (959c209d5) and at this branch's first merge ofmain(7d7943dd0); the changeset moved fromminortomajorin the commit after the second merge.What changes
A flow VALUE slot no longer reads the single-brace
{…}template dialect. In every value slot —create_record.fields.*,update_record.fields.*, theassignmentnode'sassignmentsmap, and the two legacyassignmentshapes the executor still reads (theassignments: [{ variable, value }]array and the bare config) — a string, or a string at any depth of an array or object value, that carries a{…}token the interpolator would resolve is refused, with the CEL spelling of each token. A string with no token is the literal text it spells; a computed value is a CEL value envelope.One judge, every door —
packages/spec/src/automation/flow-value-slot-template.ts:valueSlotTemplateRefusals(value)judges one value;flowNodeValueTemplateRefusals(nodeType, config)locates every refusal in a node's config (the ledger'svalueslots throughresolveFlowNodeValueSlots, plus the two legacyassignmentshapes normalised exactly as the executor normalises them);VALUE_SLOT_TEMPLATE_REFUSALis the sentence every refusal leads with.celValueSlotSchemacomposes the judge, soFlowValueSlotSchema,AssignmentValueSchema,CreateRecordConfigSchemaandUpdateRecordConfigSchemarefuse it at the value's path, andAssignmentConfigSchema's catchall (the bare legacy shape) does too — one new dropped-refinement site, ledgered (automation/AssignmentConfigout.catchall, totals 678 → 679).AutomationEngine.registerFlow(validateFlowExpressions) pushes one located failure per refused string:node 'w' (create_record) create_record field value at config.fields.total: ….@objectstack/lintvalidateStackExpressionsreports the same refusal asexpression-invalidaterror(the existing rule family; it gatesos validate,os compileand the metadata save door). This replaces thewarninghint ruling D point 1 put there for 17.x.create_record/update_recordexecutors refuse it at their ownparseNodeConfig(throughFlowValueSlotSchema), and theassignmentexecutor callsflowNodeValueTemplateRefusalsbefore it assigns anything; both return a guard refusal, so a fault edge cannot route it.The remedies, per spelling (the refusal names them for the authored token):
'{record.owner}','{x}'{ dialect: 'cel', source: 'record.owner' }has()guard:has(record.owner) ? record.owner : null,has(vars.x) ? vars.x : null(writesnull)'{list.0}'source: 'list[0]''{$error.message}'source: 'vars["$error"].message'$-named variable is read throughvars'{round(x * 100) / 100}'source: 'round(x * 100) / 100.0'/ 100.0: CEL divides two integers as integers (123.46becomes123) — every integer divisor is rewritten in the prescription'Renewal — {contract.number}'source: "'Renewal — ' + contract.number"string(…), one that may be null incoalesce(…, '')'{"a": 1}'(braces meant literally)source: "'{\"a\": 1}'"B — measured empty: no spelling is lossless (ADR-0087 D2)
Re-measured on this branch, not copied: every spelling was evaluated through the shipped interpolator (
interpolateString) and through the shipped CEL value path (AutomationEngine.evaluateValueEnvelope, the realcelScope) over the same variables. The 25-row grid reproduces the #11182 round exactly — 13 SAME / 12 DIFF:{name},{amount},{oppRecord.name},{oppRecord.amount}— key present{userList.0}→userList[0], list non-empty"u1""u1"{$error.message}→vars["$error"].message, present"boom""boom"Hello {o.name},Total: {amount}→ concatenation, holes present{o.owner}, key present withnullnullnullconverted→'converted'{round(… / 100 * 100) / 100}, integer-valued amount5400054000{rows}(a list),{flag}(a boolean)amount1234.56123.46,1111.1123,1111{10 / 4}2.52{NOW()}→now(),{TODAY()}→today()Date(Timestamp)string(now())string(Timestamp)overload{missing}— variable absentundefined{oppRecord.owner}— key absentundefined{userList.0}— empty listundefinedHello {o.owner}— holenull"Hello "{$User.Id}→current_user.idcurrent_userconvertedread as CEL sourceThe "13 of 25" in the card counted probes, not spellings: S1–S13 are the present-key / integer-valued scenarios of the same spellings whose absent-key, null-hole, decimal and Timestamp scenarios are D1–D12. Read per spelling — the unit a conversion rewrites — every authored spelling has a DIFF input: a path faults where the template wrote nothing (D7–D9), text with holes faults on a null hole (D10), arithmetic truncates (D1–D3), the date macros change type (D4–D6),
$Userhas no binding (D11). Only a token with no variable in it ({1.5},{100}) maps losslessly, and none is authored in either repository. Controls beyond the 25: ahas()guard writesnullwhere the template wrote nothing (X2–X4), so it is a semantic rewrite, not a conversion. So, by D2's letter and the card's own "a semantic rewrite is not a conversion", no D2 conversion is registered; the retirement is the D3 semantic entryflow-value-slot-template-dialect-refused(step 18, rationale fragment order 88). A pin replays the whole conversion chain, retired entries included, over every measured spelling and asserts each value comes out as authored.Kept, and why — two spellings CEL cannot write yet
A refusal must name what to write instead. For two spellings there is nothing to name, measured:
{NOW()},{TODAY()},± Ndays. CEL'snow()/today()/daysFromNow()/addDays()yield a Timestamp, which reaches the data engine as aDateobject (measured through ObjectQL with a recording driver:today()into adatefield arrives asDate(2026-10-08T00:00:00.000Z)where the macro wrote"2026-10-08"), andstring(today())is refused for want of an overload. This is the card's own contingency ("apackages/formulasub-card if v18 needs it") — it does.{$User.*}. The flow CEL scope binds no user (current_user.idfaults, D11). Binding ADR-0068's canonicalcurrent_userthere is a contract decision this PR does not take (open question in the report).A string whose tokens include one of these keeps its 17.x meaning; everything else in it would be refused if moved alone, so the whole string is kept. Their refusal is the remaining half of #19939, after the two prerequisites.
This repository's sites (census at
959c209d5)A TypeScript-AST walk over
git ls-files(everycreate_record/update_recordfieldsvalue andassignmentvalue, all three shapes, same-file spreads): 21 authored sites, 20 migrated here, 1 kept.examples/app-crm/src/flows/convert-lead.flow.ts:148'{account_id}'source: 'account_id'examples/app-crm/src/flows/convert-lead.flow.ts:149'{opportunity_id}'source: 'opportunity_id'examples/app-showcase/src/automation/flows/index.ts:115'{new_assignee}'source: 'new_assignee'examples/app-showcase/src/automation/flows/index.ts:1235'{$error.message}'source: 'vars["$error"].message'examples/app-showcase/src/automation/flows/index.ts:1602'{record.title}'source: 'record.title'examples/app-showcase/src/automation/flows/index.ts:1603'{record.assignee}'source: 'has(record.assignee) ? record.assignee : null'examples/app-showcase/src/automation/flows/index.ts:1604'{record.project}'source: 'has(record.project) ? record.project : null'examples/app-todo/src/flows/task.flow.ts:377'{completedTask.subject}'source: 'completedTask.subject'examples/app-todo/src/flows/task.flow.ts:377'{completedTask.description}'has(completedTask.description) ? … : nullexamples/app-todo/src/flows/task.flow.ts:378'{completedTask.priority}'examples/app-todo/src/flows/task.flow.ts:378'{completedTask.category}'examples/app-todo/src/flows/task.flow.ts:379'{completedTask.owner}'examples/app-todo/src/flows/task.flow.ts:380'{completedTask.recurrence_type}'examples/app-todo/src/flows/task.flow.ts:381'{completedTask.recurrence_interval}'examples/app-todo/src/flows/task.flow.ts:384'{nextDueDate}'source: 'nextDueDate'examples/app-todo/src/flows/task.flow.ts:457'{subject}'source: 'subject'examples/app-todo/src/flows/task.flow.ts:457'{priority}'source: 'has(vars.priority) ? vars.priority : null'examples/app-todo/src/flows/task.flow.ts:457'{dueDate}'source: 'has(vars.dueDate) ? vars.dueDate : null'examples/app-todo/src/flows/task.flow.ts:457'{category}'source: 'has(vars.category) ? vars.category : null'examples/app-todo/src/flows/task.flow.ts:457'{$User.Id}'packages/verify/src/handle.fixture.ts:191'{resolution}'source: 'resolution'Each guard is a judgment the template made silently: a field a screen may leave empty, or a key a row may not carry, writes
null(on insert a field default still applies). Docs code samples migrated too:content/docs/automation/flows.mdx(the assignment and create-record examples, an inline comment, the hot-lead example),content/docs/kernel/runtime-services/examples.mdx(two fields),packages/services/service-automation/README.md(one field), and the test fixturepackages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts:81.hotcrm (public, read-only clone at
c529de2, not touched): 91 authored sites (2 of them through the same-fileMEMBERSHIP_FIELDSspread) — 71 refused (31 bare references, 36 dotted paths, 4 text with holes) and 20 kept (15 date macros — 8{NOW()}, 3{TODAY()}, 3{TODAY() + N}, 1{TODAY() + var}— and 5{$User.Id}, allowner_idoncreate_record); plus 5 in tests (4 refused, 1 kept). Its two money sites already use the CEL envelope with/ 100.0.H4 — where
{var}is still read after this changecrud-nodes.tsresolveFieldValues,logic-nodes.ts), reached only by the two kept spellings; on every other literal it is the identity. It is removed when the kept spellings are refused. Pinned: the kept spellings resolve through the executors (logic-nodes.test.ts,crud-fields-value-envelope.test.ts), andvalue-slot-template-grammar.test.tsdrives the interpolator over every kept and refused spelling so the spec's copy of the token grammar cannot drift fromresolveToken.{{ }}delimiter instead of single-brace{token}(notify title/message and the other flow string slots), converting only what renders the same #22110's:notifytitle / message, screen text,endmessage),filtervalues (interpolateFilter, the filter-placeholder hand-off),loop.collection/map.collection(the ledger'sflow-templaterole), and the value-like positionssubflow.input,map.input,script.inputs,screen.defaults, a screen field'sdefaultValue, andhttp(interpolated whole before its parse).Wrong guidance (ruling D item 2)
builtin/template.tsandcontent/docs/automation/flows.mdxalready carried/ 100.0onmain(#20205). This PR removes the lastround(x * 100) / 100claim in its surface: the comment inflow-field-expression-scale.integration.test.tsthat called it "the CEL-identical authoring pattern" (the oracle now runs as a CEL envelope with/ 100.0, and the docblock states integer division).skills/objectstack-automation/SKILL.md:232still reads{round(x * 100) / 100}and teaches{token}infields; it is Tier H and not in this PR.BREAKING
An accept-set narrowing on a published authoring surface (
Clause-②: yes (narrowing), copied from the claim), gradedmajoron the v18nextpre line; the changeset carries the BREAKING banner, the FROM → TO table above and the ADR-0087 dispositionregistered flow-value-slot-template-dialect-refused. A stored flow carrying a refused value is refused at registration (skipped at boot with a warn naming it);os validatenames each one with its CEL spelling.Tests and gates
This PR opens at
b073d92de. The package suites, typechecks and consumer runs below were read at31c52e59c(or the commit named); the second merge ofmainafter it changed nothing underpackages/spec/src/automation,packages/lint,packages/services/service-automation,packages/triggers,packages/qa,packages/verifyorexamples(inpackages/cli, only its secret-rewrap files), and the spec build, generated-artifact check, spec migration / conversion / automation suites and every gate were re-run atb073d92de. The box is shared, so durations are not quoted. Builds, tests and typechecks ran throughscripts/pm/os-verify-lock.sh, each read off itsVERDICT command-exitline.--maxWorkers=2):@objectstack/spec679 files, 19605 passed + 1 todo;@objectstack/lint125 files, 5730 passed;@objectstack/service-automation176 files, 2157 passed.pnpm --filter … run typecheck, exit 0 each): spec, lint, service-automation, trigger-record-change, dogfood, cli, example-todo, example-showcase, example-crm, verify.turbo run build --concurrency=2, exit 0):trigger-record-change11 files / 114;trigger-schedule8 / 174;plugin-approvals61 / 899;verify18 / 133;example-todo7 / 238 (at0d7eb274c);example-showcase33 / 408;example-crm5 / 45;mcp7 / 74,metadata-protocol6 / 127 andruntime20 / 548 — each the files of that package that author these node types or load an example (a narrowing, declared: the rest of those suites is CI's);cliunit 2 / 14 and integration 2 / 14 (the integration project run because this diff editspackage-install-local-boot-steps.integration.test.ts; four more cli files I named are integration-tier and declared to CI);dogfood3 / 25 (flow-trigger-record-credential-mask,flow-durable-suspend,expression-conformance).packages/spec/src/automation/flow-value-slot-template.test.ts(every refused class with its remedy, the kept spellings, the controls, every value-slot contract, every value position of a node, and the no-conversion replay);packages/lint/src/validate-expressions.fields-value-slot.test.ts(the build door:expression-invalidaterror, located, in all three value slots and both legacy shapes; kept spellings andfilterclean);packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts,logic-nodes.test.tsandassignment-value-envelope.test.ts(registration and the run-time twin, nothing written, kept spellings resolve);value-slot-template-grammar.test.ts(the spec judge against the interpolator, kept and refused spellings and the dispatch-order edges).pnpm --filter @objectstack/spec check:generated— 15 of 15 current after--fixregeneratedapi-surface/,export-origins/andcontent/docs/references/**on the merged tree, re-read exit 0 atb073d92deafterpnpm --filter @objectstack/spec build(exit 0);dropped-refinements.baseline.jsonhand-edited (one site, totals 678 → 679). Atb073d92dethe spec'ssrc/migrations,src/conversions,src/automationandscriptssuites: 115 files, 3251 passed.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatb073d92de(47 paths vs merge basef4bed5834) derived 120 commands; all 120 ran with their exit codes captured before any pipe. 119 answered exit 0 on the first pass;check:skill-examplesanswered exit 3 PREREQUISITE NOT MET (packages/client/distolder thansrcafter the merge — nothing measured), and afterpnpm --filter @objectstack/client build(exit 0) it answered exit 0, "262 prose examples type-check across 3 surface(s)".--ranreconciliation: "120 derived famil(ies) accounted for — 120 run, 0 NOT-MEASURED". The printed artifact-roster block (35 roster, 14 checker-health self-tests) and the 11 declared wide-population families ran too: 57 exit 0;check-closing-target-claim,check-partof-closing-keywordandcheck-single-claim-pathsanswered exit 2 "NOT WIRED" (they need a PR number / body) — NOT MEASURED there;check-partof-closing-keywordwith this body asPR_BODYis in the report.maintwice throughscripts/pm/os-regen-merge.sh(each merge committed first, regeneration as its own commit): at7d7943dd0(pnpm install --frozen-lockfileafter it), and atf4bed5834, which brought.changeset/pre.jsonand feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less sys_platform_setting (ADR-0131 D7) #22166's step-18 entry (sys-setting-global-rung-moved, rationale order 87 — this PR's fragment keeps 88, the next free one;gen:migration-registryre-run on the merged tree changed nothing). Seven latermaincommits (to73a0a6bf1) are not merged:git merge-treeanswers clean, their three overlapping files (packages/lint/src/validate-expressions.ts/.test.ts,packages/spec/src/migrations/registry.ts) change other regions, and none adds a{…}value-slot string.Acceptance notes
current_userin a flow's CEL — the build doors and the run disagree today, independently of this PR:validateExpression('predicate', "current_user.id == 'u1'", { scope: 'flattened', … })(the checkregisterFlowandos validaterun on a flow condition) answersok, andExpressionEngine.evaluateover the flow's scope shape answers "Unknown variable: current_user" (measured at those two primitives; a door-level run is not part of this PR). Binding ADR-0068'scurrent_userin the flow CEL scope would close that and give{$User.*}its remedy — the open question in the report.{var}positions outside this card's three slots still read the dialect:subflow.input,map.input,script.inputs,screen.defaults, a screen field'sdefaultValue, andhttp. Text slots are [v18] flow text slots: read ADR-0032 §3's{{ }}delimiter instead of single-brace{token}(notify title/message and the other flow string slots), converting only what renders the same #22110's; these have no carrier.validate-flow-template-pathsstill checks a{record.…}path inside a value-slot string that is now refused anyway, so such a value draws both its path finding and the refusal. Harmless; no carrier.validateStackExpressionsfinding, the rule the runtime publish gate runs on a flow write, so a Studio / REST / MCP save of such a flow answers422 INVALID_METADATAby construction — not separately measured in this PR (spec: declarecreate_record/update_recordfields.*as a value-role expression slot in the expression ledger, so the CEL envelope is accepted there (the contract half of #11182 ruling D) #19938 measured that door for the envelope arm of the same rule).FlowSchema.parseor a non-expression rule (flow.test.ts,validate-field-consumers.test.ts,validate-flow-template-paths.test.ts,validate-readonly-flow-writes.test.ts) still spell a value-slot template; they pass, becauseFlowSchemajudges no value slot and those rules filter by their own id.Generated by Claude Code