Skip to content

feat(spec)!: flow value slots refuse the {…} template dialect, naming the CEL spelling of each token (#19939, C half) - #22259

Merged
objectstack-fleet[bot] merged 16 commits into
mainfrom
claude/issue-19939-flow-template-dialect-retire
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 16 commits into
mainfrom
claude/issue-19939-flow-template-dialect-retire

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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)

What changes

A flow VALUE slot no longer reads the single-brace {…} template dialect. In every value slot — create_record.fields.*, update_record.fields.*, the assignment node's assignments map, and the two legacy assignment shapes the executor still reads (the assignments: [{ 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's value slots through resolveFlowNodeValueSlots, plus the two legacy assignment shapes normalised exactly as the executor normalises them); VALUE_SLOT_TEMPLATE_REFUSAL is the sentence every refusal leads with.
  • The contract says it: celValueSlotSchema composes the judge, so FlowValueSlotSchema, AssignmentValueSchema, CreateRecordConfigSchema and UpdateRecordConfigSchema refuse it at the value's path, and AssignmentConfigSchema's catchall (the bare legacy shape) does too — one new dropped-refinement site, ledgered (automation/AssignmentConfig out.catchall, totals 678 → 679).
  • Registration — AutomationEngine.registerFlow (validateFlowExpressions) pushes one located failure per refused string: node 'w' (create_record) create_record field value at config.fields.total: ….
  • Build door — @objectstack/lint validateStackExpressions reports the same refusal as expression-invalid at error (the existing rule family; it gates os validate, os compile and the metadata save door). This replaces the warning hint ruling D point 1 put there for 17.x.
  • Run time — the create_record / update_record executors refuse it at their own parseNodeConfig (through FlowValueSlotSchema), and the assignment executor calls flowNodeValueTemplateRefusals before 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):

you wrote the refusal prescribes what changes
'{record.owner}', '{x}' { dialect: 'cel', source: 'record.owner' } CEL refuses an absent variable or key where the template wrote nothing — the has() guard: has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null (writes null)
'{list.0}' source: 'list[0]' an empty list fails the run
'{$error.message}' source: 'vars["$error"].message' a $-named variable is read through vars
'{round(x * 100) / 100}' source: 'round(x * 100) / 100.0' / 100.0: CEL divides two integers as integers (123.46 becomes 123) — every integer divisor is rewritten in the prescription
'Renewal — {contract.number}' source: "'Renewal — ' + contract.number" wrap a non-string hole in string(…), one that may be null in coalesce(…, '')
'{"a": 1}' (braces meant literally) source: "'{\"a\": 1}'" a CEL string literal

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 real celScope) over the same variables. The 25-row grid reproduces the #11182 round exactly — 13 SAME / 12 DIFF:

# spelling and input template CEL verdict
S1–S4 {name}, {amount}, {oppRecord.name}, {oppRecord.amount} — key present the value the value SAME
S5 {userList.0} → userList[0], list non-empty "u1" "u1" SAME
S6 {$error.message} → vars["$error"].message, present "boom" "boom" SAME
S7, S8 Hello {o.name}, Total: {amount} → concatenation, holes present the text the text SAME
S9 {o.owner}, key present with null null null SAME
S10 token-free converted → 'converted' the text the text SAME
S11 {round(… / 100 * 100) / 100}, integer-valued amount 54000 54000 SAME
S12, S13 {rows} (a list), {flag} (a boolean) the value the value SAME
D1, D2 the money spelling, amount 1234.56 123.46, 1111.1 123, 1111 DIFF
D3 {10 / 4} 2.5 2 DIFF
D4, D5 {NOW()} → now(), {TODAY()} → today() ISO text a Date (Timestamp) DIFF
D6 string(now()) ISO text refused: no string(Timestamp) overload DIFF
D7 {missing} — variable absent undefined fault: unknown variable DIFF
D8 {oppRecord.owner} — key absent undefined fault: no such key DIFF
D9 {userList.0} — empty list undefined fault: index out of bounds DIFF
D10 Hello {o.owner} — hole null "Hello " fault: no overload for string plus null DIFF
D11 {$User.Id} → current_user.id the run user id fault: unknown variable current_user DIFF
D12 token-free converted read as CEL source the text fault: unknown variable DIFF

The "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), $User has 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: a has() guard writes null where 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 entry flow-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:

  • The date macros — {NOW()}, {TODAY()}, ± N days. CEL's now() / today() / daysFromNow() / addDays() yield a Timestamp, which reaches the data engine as a Date object (measured through ObjectQL with a recording driver: today() into a date field arrives as Date(2026-10-08T00:00:00.000Z) where the macro wrote "2026-10-08"), and string(today()) is refused for want of an overload. This is the card's own contingency ("a packages/formula sub-card if v18 needs it") — it does.
  • The run user — {$User.*}. The flow CEL scope binds no user (current_user.id faults, D11). Binding ADR-0068's canonical current_user there 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 (every create_record / update_record fields value and assignment value, all three shapes, same-file spreads): 21 authored sites, 20 migrated here, 1 kept.

file:line (base) before after
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}' guarded has(completedTask.description) ? … : null
examples/app-todo/src/flows/task.flow.ts:378 '{completedTask.priority}' guarded
examples/app-todo/src/flows/task.flow.ts:378 '{completedTask.category}' guarded
examples/app-todo/src/flows/task.flow.ts:379 '{completedTask.owner}' guarded
examples/app-todo/src/flows/task.flow.ts:380 '{completedTask.recurrence_type}' guarded
examples/app-todo/src/flows/task.flow.ts:381 '{completedTask.recurrence_interval}' guarded
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}' kept (the run user — see above)
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 fixture packages/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-file MEMBERSHIP_FIELDS spread) — 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}, all owner_id on create_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 change

  • In the three value slots: the interpolator call stays (crud-nodes.ts resolveFieldValues, 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), and value-slot-template-grammar.test.ts drives the interpolator over every kept and refused spelling so the spec's copy of the token grammar cannot drift from resolveToken.
  • Outside them, unchanged by this card: text slots ([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: notify title / message, screen text, end message), filter values (interpolateFilter, the filter-placeholder hand-off), loop.collection / map.collection (the ledger's flow-template role), and the value-like positions subflow.input, map.input, script.inputs, screen.defaults, a screen field's defaultValue, and http (interpolated whole before its parse).

Wrong guidance (ruling D item 2)

builtin/template.ts and content/docs/automation/flows.mdx already carried / 100.0 on main (#20205). This PR removes the last round(x * 100) / 100 claim in its surface: the comment in flow-field-expression-scale.integration.test.ts that 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:232 still reads {round(x * 100) / 100} and teaches {token} in fields; 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), graded major on the v18 next pre line; the changeset carries the BREAKING banner, the FROM → TO table above and the ADR-0087 disposition registered 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 validate names each one with its CEL spelling.

Tests and gates

This PR opens at b073d92de. The package suites, typechecks and consumer runs below were read at 31c52e59c (or the commit named); the second merge of main after it changed nothing under packages/spec/src/automation, packages/lint, packages/services/service-automation, packages/triggers, packages/qa, packages/verify or examples (in packages/cli, only its secret-rewrap files), and the spec build, generated-artifact check, spec migration / conversion / automation suites and every gate were re-run at b073d92de. The box is shared, so durations are not quoted. Builds, tests and typechecks ran through scripts/pm/os-verify-lock.sh, each read off its VERDICT command-exit line.

  • Package suites (vitest, --maxWorkers=2): @objectstack/spec 679 files, 19605 passed + 1 todo; @objectstack/lint 125 files, 5730 passed; @objectstack/service-automation 176 files, 2157 passed.
  • Typecheck (pnpm --filter … run typecheck, exit 0 each): spec, lint, service-automation, trigger-record-change, dogfood, cli, example-todo, example-showcase, example-crm, verify.
  • Consumers (after a full turbo run build --concurrency=2, exit 0): trigger-record-change 11 files / 114; trigger-schedule 8 / 174; plugin-approvals 61 / 899; verify 18 / 133; example-todo 7 / 238 (at 0d7eb274c); example-showcase 33 / 408; example-crm 5 / 45; mcp 7 / 74, metadata-protocol 6 / 127 and runtime 20 / 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); cli unit 2 / 14 and integration 2 / 14 (the integration project run because this diff edits package-install-local-boot-steps.integration.test.ts; four more cli files I named are integration-tier and declared to CI); dogfood 3 / 25 (flow-trigger-record-credential-mask, flow-durable-suspend, expression-conformance).
  • The new pins: 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-invalid at error, located, in all three value slots and both legacy shapes; kept spellings and filter clean); packages/services/service-automation/src/builtin/crud-fields-value-envelope.test.ts, logic-nodes.test.ts and assignment-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).
  • Generated artifacts: pnpm --filter @objectstack/spec check:generated — 15 of 15 current after --fix regenerated api-surface/, export-origins/ and content/docs/references/** on the merged tree, re-read exit 0 at b073d92de after pnpm --filter @objectstack/spec build (exit 0); dropped-refinements.baseline.json hand-edited (one site, totals 678 → 679). At b073d92de the spec's src/migrations, src/conversions, src/automation and scripts suites: 115 files, 3251 passed.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at b073d92de (47 paths vs merge base f4bed5834) derived 120 commands; all 120 ran with their exit codes captured before any pipe. 119 answered exit 0 on the first pass; check:skill-examples answered exit 3 PREREQUISITE NOT MET (packages/client/dist older than src after the merge — nothing measured), and after pnpm --filter @objectstack/client build (exit 0) it answered exit 0, "262 prose examples type-check across 3 surface(s)". --ran reconciliation: "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-keyword and check-single-claim-paths answered exit 2 "NOT WIRED" (they need a PR number / body) — NOT MEASURED there; check-partof-closing-keyword with this body as PR_BODY is in the report.
  • Merged main twice through scripts/pm/os-regen-merge.sh (each merge committed first, regeneration as its own commit): at 7d7943dd0 (pnpm install --frozen-lockfile after it), and at f4bed5834, which brought .changeset/pre.json and 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-registry re-run on the merged tree changed nothing). Seven later main commits (to 73a0a6bf1) are not merged: git merge-tree answers 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_user in 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 check registerFlow and os validate run on a flow condition) answers ok, and ExpressionEngine.evaluate over 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's current_user in the flow CEL scope would close that and give {$User.*} its remedy — the open question in the report.
  • Value-like {var} positions outside this card's three slots still read the dialect: subflow.input, map.input, script.inputs, screen.defaults, a screen field's defaultValue, and http. 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.
  • Two findings on one value: validate-flow-template-paths still 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.
  • The save door: the refusal is a validateStackExpressions finding, the rule the runtime publish gate runs on a flow write, so a Studio / REST / MCP save of such a flow answers 422 INVALID_METADATA by construction — not separately measured in this PR (spec: declare create_record / update_record fields.* 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).
  • Kept-spelling fixtures: spec and lint test fixtures that only go through FlowSchema.parse or 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, because FlowSchema judges no value slot and those rules filter by their own id.

Generated by Claude Code

claude added 16 commits October 8, 2026 05:16
…s after merging main (#19939)

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…e v18 pre line is open on main (#19939)

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/lint, @objectstack/service-automation, @objectstack/spec, @objectstack/verify, touching 45 documentable anchor(s). ⚠️ 6 changed file(s) yielded no anchor (packages/services/service-automation/README.md, packages/services/service-automation/src/builtin/template.ts, packages/spec/api-surface/automation.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via FlowValueSlotSchema (symbol, a top-level const object))
  • content/docs/permissions/system-context.mdx (via registerCrudNodes (symbol, a top-level function))
What this run could not see
  • 6 changed file(s) yielded no anchor (packages/services/service-automation/README.md, packages/services/service-automation/src/builtin/template.ts, packages/spec/api-surface/automation.json, …) — pages documenting those are invisible to this run
  • 11 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 335c4737f8269ca7a80c7674e997e75139dec43f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1158e495b9daa86f87f4bfdf87a9ee3ccc28cdff — the merge of head b073d92ded5132d710a1c952b858e9308854dfda into base 335c4737f8269ca7a80c7674e997e75139dec43f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 335c4737f8269ca7a80c7674e997e75139dec43f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b073d92ded5132d710a1c952b858e9308854dfda
Local-runs: none

Inputs read, and nothing else: card #19939 (body and all 7 comments, the newest os-dev-report 6057667009 and the seat's ACCEPT 6057714005 included); #11182 comment 5805777944 (ruling D) verbatim; PR #22259 body, its 47-file list, its one comment (docs-drift, 6057602791), and the net diff against main (merge base f4bed5834, 16 commits, +1589 / -451); the check-runs on b073d92de, read at 2026-10-08T10:20:22Z and again at 10:24:47Z (tally in ③). PR head read from the API: b073d92ded5132d710a1c952b858e9308854dfda, equal to the brief's head.

① Derived judgments

  1. Accept-set narrowing — a flow value slot refuses a {…} token. Right. Where it is said: celValueSlotSchema (so FlowValueSlotSchema, AssignmentValueSchema, CreateRecordConfigSchema.fields.*, UpdateRecordConfigSchema.fields.*) judges every non-envelope value through valueSlotTemplateRefusals, a custom issue at the string's own path; AssignmentConfigSchema's catchall (the bare legacy shape) takes LEGACY_ASSIGNMENT_VALUE with envelopeIsLiteral: true. A string at any depth of an array or object literal is judged; a top-level envelope is left to the envelope rule, as before. Pinned in flow-value-slot-template.test.ts (refused classes with their remedies, kept spellings, controls, every contract, every node position, the no-conversion replay) and builtin-node-config.test.ts.

  2. The refusal at each door, and whether they agree. Right — one judge, four doors, one verdict.

    • Zod contract: above.
    • AutomationEngine.registerFlow (engine.ts, the expression pass): flowNodeValueTemplateRefusals(node.type, node.config) pushes one located failure per refused string (node 'w' (create_record) create_record field value at config.fields.total: …), inside the existing aggregated ADR-0032 §1a error; a stored flow carrying one is skipped at boot with a warn, the door's existing shape.
    • os validate / os compile / the metadata save door (@objectstack/lint validateStackExpressions): the same function on the same config, reported as expression-invalid at error, located at the node and config.PATH. The 17.x warning hint (templateExpressionToken, templateExpressionEnvelopeHint, the flow-template-grammar import) is removed — ruling D item 1's 17.x shape, replaced by item 3's refusal. Pinned in validate-expressions.fields-value-slot.test.ts across all three value slots and both legacy assignment shapes.
    • Executors: create_record / update_record refuse through their existing parseNodeConfig (FlowValueSlotSchema now composes the judge — crud-nodes.ts carries comment changes only, the refusal rides the existing parse), pinned by the run-time twin (errorClass: 'guard', nothing written); the assignment executor calls the judge before any variable is set and returns refuseNode, pinned likewise.
    • One asymmetry, pre-existing and not widened here: the Zod contract refuses the legacy assignments ARRAY at assignments itself (ASSIGNMENT_ARRAY_FORM_PRESCRIPTION), while registerFlow, lint and the executor judge each element's value. All four refuse; no door accepts what another refuses.
  3. The judge's grammar is the interpolator's. Right. Compared against builtin/template.ts at the head: the token regex /\{([^{}]+)\}/g, dateFnMatch /^(NOW|TODAY)\s*\(\s*\)\s*(?:([+\-])\s*(\S+))?$/, the $User. prefix test, the direct-path regex /^[A-Za-z_$][\w$]*(?:\.(?:[A-Za-z_$][\w$]*|\d+))*$/ and the arithmetic charset (word characters, whitespace, the arithmetic, comparison and logical operator characters, parentheses, ., ,, ?, :, both quote characters and $) are byte-identical, in resolveToken's dispatch order (date macro, $User., path, expression). The drift pin is value-slot-template-grammar.test.ts, which drives interpolateString over the kept and refused spellings and the dispatch-order edges ({$User}, {NOW}, { amount }).

  4. The remedies the refusal names. Right, and each is pinned: a path → the same path (list.0 → list[0]; a $-named variable → vars["$error"].message) with a has() guard that writes null; arithmetic → every integer divisor as a double (celExpression: / 100 → / 100.0, a divisor already 100.0 left alone); text with holes → one concatenation with the string(…) / coalesce(…, '') advice; a token that is neither path nor expression → the CEL string-literal escape; an unknown call name → the same text, which the envelope's own CEL check then refuses with a did-you-mean. The rule sentence VALUE_SLOT_TEMPLATE_REFUSAL names no tracker number (pinned).

  5. Kept spellings: {NOW()} / {TODAY() ± N} and {$User.*}; a string carrying one is kept whole. Within the ruling, as a staged delivery. Ruling D item 3 names the remedy for the macros as "a string form for NOW() / TODAY()", and the card body names the contingency ("a packages/formula sub-card if v18 needs it"); on this head there is none (measured: now() / today() / daysFromNow() / addDays() yield a Timestamp that reaches the data engine as a Date, and string(today()) has no overload), and a refusal with nothing to name is outside Flow field expressions can call no function but NOW()/TODAY() — every other identifier is rewritten to null, so a computed money value can never be rounded to its field's declared scale #11060 A's loud shape the ruling cites. {$User.*} has no binding in the flow CEL scope (current_user.id faults) and the ruling names no remedy for it. The PR is Part of #19939 with no closing keyword, so the card stays open for this half (the Part-of check-run concluded success). The kept set is declared in the contract text, the changeset, the docs and the D3 entry, and pinned at every door. See ③ for the leak this keeps open.

  6. "B measured empty" against the ruling's "whatever of B is lossless". Right, by the letter of both. ADR-0087 D2 lets a conversion ride only a LOSSLESS mapping, and a mapping is lossless over all inputs, not over one probe. The dev re-measured on this branch through the shipped interpolateString and the shipped evaluateValueEnvelope over the same variables and reproduced the [Deferred by ruling] Unify flow field-expression assignment onto the CEL engine — the B half of the #11060 ruling, awaiting maintainer appetite #11182 grid (13 SAME / 12 DIFF); read per spelling — the unit a conversion rewrites — every authored spelling has a DIFF input (absent key → nothing vs fault; null hole → nothing vs fault; integer division; Timestamp; no user binding), and the has() form writes null where the template wrote nothing (X2–X4), so it is a semantic rewrite, which the card itself says "is not a conversion". packages/spec/src/conversions/registry.ts is untouched; the retirement is the D3 semantic entry flow-value-slot-template-dialect-refused at step 18 (entries file plus the generated registry.ts copy, STEP18_RATIONALE order 88 — 87 is feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less sys_platform_setting (ADR-0131 D7) #22166's), and the pin replays applyConversionsToFlow(flow, { includeRetired: true }) over ten measured spellings and asserts each comes out as authored. This corrects the card's "13 of 25" premise, which counted probes — escalated in ③.

  7. The 20 migrated in-repo sites, each read in the diff. All faithful CEL rewrites, guards included:

    • examples/app-crm/src/flows/convert-lead.flow.ts — account_id, opportunity_id as bare paths (both bound by the preceding screens' idVariable; no guard needed).
    • examples/app-showcase/src/automation/flows/index.ts — new_assignee (a required screen field, unguarded); vars["$error"].message (the $-variable through vars); record.title unguarded, record.assignee and record.project guarded with has() (a webhook body may omit them).
    • examples/app-todo/src/flows/task.flow.ts — create_next_task: completedTask.subject unguarded, description / priority / category / owner / recurrence_type / recurrence_interval guarded, nextDueDate unguarded (the script's raw value, type kept); create_task: subject unguarded (required), priority / dueDate / category guarded through vars.*; owner: '{$User.Id}' is the one KEPT site (21 = 20 + 1).
    • packages/verify/src/handle.fixture.ts — resolution (the screen's required field).
      Every guard is a judgment the template made silently (absent → nothing) now made explicit (absent → null); the comments say so at each site. The doc samples (flows.mdx ×4 regions, kernel/runtime-services/examples.mdx, the service-automation README) and the test fixtures (dogfood flow-durable-suspend-fixture.ts, the credential-mask dogfood test, the cli boot-steps integration test, five trigger-record-change tests, and the service-automation / spec / lint suites) are migrated the same way; record-lookup-expand.integration.test.ts's unexpanded probe correctly changes its expectation from undefined (the template's unresolved token) to 'acc1' (string(record.account), the stored scalar).
  8. New public surface on @objectstack/spec (automation namespace, export *). Right and regenerated: VALUE_SLOT_TEMPLATE_REFUSAL, valueSlotTemplateRefusals, flowNodeValueTemplateRefusals, FlowNodeValueTemplateRefusal, ValueSlotTemplateOptions, ValueSlotTemplateRefusal — six names added to api-surface/automation.json and export-origins/automation.json. dropped-refinements.baseline.json carries the one new site (automation/AssignmentConfig out.catchall, totals 678 → 679), hand-edited as the kit allows. Published text that changed meaning is covered by the major: the four slot descriptions, ASSIGNMENT_ARRAY_FORM_PRESCRIPTION, the dialect error on AssignmentExpressionValueSchema, and registerFlow's aggregated-error header.

  9. H4 — where the dialect is still read in the value slots. Right and declared: the interpolate() call stays in resolveFieldValues and the assignment executor, reached only by the two kept spellings and the identity on every other literal; pinned (logic-nodes.test.ts, crud-fields-value-envelope.test.ts). Text slots, filter, loop.collection / map.collection and the value-like positions outside the card are unchanged (③).

  10. Wrong guidance (ruling D item 2). Right: the last round(x * 100) / 100 claim in this surface is removed (the scale integration test's comment; its oracle now runs the / 100.0 envelope and states integer division). skills/objectstack-automation/SKILL.md is Tier H, not in this PR, and the seat filed its card beside the ACCEPT.

  11. Which packages publish. Right: @objectstack/spec, @objectstack/service-automation, @objectstack/lint are the three the changeset grades. @objectstack/verify changes only src/handle.fixture.ts, which src/index.ts (the tsup entry) does not reach — it is imported by handle.test.ts alone — so nothing published moves and no changeset is owed. trigger-record-change and cli change tests only; dogfood and the three examples are private.

② Semver level

  • Release state at main, read now: .changeset/pre.json is present ("mode": "pre", "tag": "next"); @objectstack/spec is 17.7.0. Triage 6038868940: a breaking change landing after release(v18): enter Changesets pre mode on main (changeset pre enter next) with one major marker, so the first v18 prerelease is 18.0.0-next.0 — the opening ruled B on #22050 #22080 is graded major. The changeset .changeset/19939-flow-value-slot-template-dialect-refused.md grades @objectstack/spec, @objectstack/service-automation and @objectstack/lint major, carries the **BREAKING** banner, the FROM → TO table with the one-line fix, the affected-sites census, and exactly one marker, adr-0087: registered flow-value-slot-template-dialect-refused, whose id exists in migrations/registry.ts step 18. The level matches what the diff publishes: an accept-set narrowing on three published surfaces (contract, engine, build door), 18.0.0-next.* on the open pre line. Right. Both Check Changeset runs on the head concluded success.
  • The declaration line. The claim 6052247536, the PR body and the changeset each carry Clause-②: yes (narrowing) at a line start, byte-identical. Under scripts/pm/clause2-line.mjs that is the declared combination "a diff that widens one surface and narrows another; both facts are true and both are read". Under execution-duties.md line 67 (「判据:本卡放宽接受集或扩大公开面吗」; 公开面 = what the built entry declarations reach) this diff enlarges the public surface — the six @objectstack/spec/automation exports in ①-8 — and narrows the accept set, so yes (narrowing) is the truthful reading. The line 5d5a88eff added (line 105 at main: 「收窄已发布接受集的卡是 Clause-②: no,不移车道;入队前欠一次契约复审档复核。」) governs a card that ONLY narrows; its other two facts hold here regardless — the lane is unchanged (domain:spec, triage 6051196407 A) and this record is the owed contract-review-tier review before the queue. The gate outcome is identical under either value: the (narrowing) arm is BREAKING, takes major in pre mode, and requires the ADR-0087 disposition that is present. The dev's open question 3 is thereby answered A (the claim's line stands; nothing to rewrite).

③ Boundary flags

Dev report 6057667009 (the newest os-dev-report), seat ACCEPT 6057714005 (the newest comment), each flag answered or escalated:

  • Surface crossed before reporting (dev deviation 1; the seat accepted it). Judged: the crossing carries no behaviour beyond the card. packages/spec/src/automation/** is the contract door triage 6051196407 itself routed to domain:spec ("the card changes what a metadata slot accepts"); service-automation/src/engine.ts is the registerFlow door the claim mislocated under builtin/; the consumer tests in packages/triggers, packages/qa/dogfood, packages/cli and examples/app-todo/test are fixture migrations the refusal forces; dropped-refinements.baseline.json is the kit's named baseline; the README and kernel/runtime-services/examples.mdx are doc samples of the retired spelling. Nothing in the crossing changes a slot, a door or a package the card does not name. Accepted as recorded.
  • Open question 1 — {$User.*}: bind ADR-0068's current_user in the flow CEL scope? Escalated to the seat / decision box, as the seat deferred it to landing. Note for that decision: the dev measured the build check and the run disagreeing today independently of this PR (validateExpression('predicate', "current_user.id == 'u1'", flattened scope) → ok; ExpressionEngine.evaluate over the flow scope → unknown variable). The dev's A is the only option whose refusal names a remedy.
  • Open question 2 — the date macros' string form. Escalated: the ruling's own wording ("a string form") and the card's named contingency (a packages/formula sub-card) point at the dev's A; B would change what every Timestamp-returning envelope writes today.
  • Open question 3 — the Clause-② conflict. Answered in ②: A.
  • The kept-spelling leak. A string that mixes a kept token with any other ('Due {TODAY()} for {name}', 'Owner: {$User.Id}' beside a path) is kept WHOLE, so {name} still resolves through the interpolator in such a string. Declared (judge docblock, changeset, docs) and pinned, and the alternative (refusing the mixed string) would name no remedy for the macro half — but it means the dialect is not closed in the value slots until both kept spellings are refused. The seat places this on the card's open half explicitly.
  • null on insert where the template wrote nothing. The guarded example sites and their comments assert "a null on insert is 'no value', and a field default still applies"; the migrated flows run end-to-end in task-recurrence.test.ts over the real todo metadata, but the default-applies reading is not itself pinned in this diff. The examples are private; recorded as a reading for the seat, not a contract defect.
  • Card premises corrected by measurement (dev deviation 7): "13 of 25 SAME" counts probes, not spellings (B empty); hotcrm has 91 authored sites, not 92. Escalated: the card body still states both; the seat annotates the card when placing the kept half and filing the hotcrm migration card.
  • Value-like {var} positions outside the three slots (subflow.input, map.input, script.inputs, screen.defaults, a screen field's defaultValue, http) still read the dialect — outside the card's "flow assignment / fields.* value" scope, no carrier named. Escalated as a family close-out the seat decides on; not a defect of this PR.
  • validate-flow-template-paths double finding on a refused {record.…} value: harmless, no carrier. Accepted.
  • The save door not separately measured: by construction the same validateStackExpressions finding the runtime publish gate runs (spec: declare create_record / update_record fields.* 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). Accepted.
  • skills/objectstack-automation/SKILL.md still teaches the refused spellings and {round(x * 100) / 100} (class c, named producer, Tier H): seat filed. Accepted.
  • B empty, conversions/registry.ts untouched (dev deviation 2): judged right in ①-6.
  • One typecheck outside the verify lock; narrowed consumer runs (mcp, metadata-protocol, runtime selected files; four cli integration files declared to CI) (deviations 3–4): the check-runs on the head are the verdict — Type Check · source gates, Type Check · debt ledger, Type Check · workspace, Type Check · consumer gates, Dogfood Verify CLI, Dogfood Regression Gate (1–3/3), Temporal Conformance, Build Core, Build Docs, Spec property liveness, Governed Surface Queue Guard and the claim checks all concluded success by 10:24:47Z; Test Core (1–6/6) and Lint & Repo Gates were still in_progress at that read.
  • Seven later main commits not merged (deviation 5): git merge-tree clean per the report, and CI runs the merge of this head into the current base (the docs-drift comment names it: 1158e495b). Accepted.
  • Attribution trailer (deviation 6): the dev kept commits free of a model identifier, consistent with the dispatch; no action.
  • Check-run tally on b073d92ded5132d710a1c952b858e9308854dfda (2026-10-08). 10:20:22Z: 36 runs — 18 success, 5 skipped, 13 in_progress. 10:24:47Z: 38 runs — 26 success, 5 skipped, 7 in_progress. 10:27:50Z (last read before this post): 38 runs — 29 success, 5 skipped, 4 in_progress (Lint & Repo Gates, Test Core (1/6), (4/6), (6/6)), 0 failed. Their conclusions are the gate verdicts; the seat reads them green before landing, as Prime Directive 14's Tier S condition requires beside this record.

Implemented-by: claude/issue-19939-flow-template-dialect-retire
Reviewed-by: session_01RPo7FUd6bSnAfkWMAKi848

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 10:42
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 10:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 0e9e7b7 Oct 8, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19939-flow-template-dialect-retire branch October 8, 2026 11:13
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants