Skip to content

fix(spec/automation): refuse a text-slot {{ $… }} hole whose root the flow engine does not bind - #22499

Draft
objectstack-fleet[bot] wants to merge 11 commits into
mainfrom
claude/issue-22477-text-slot-dollar-root
Draft

objectstack-fleet[bot] wants to merge 11 commits into
mainfrom
claude/issue-22477-text-slot-dollar-root

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22477
Clause-②: no (narrowing)

What this does

A {{ }} hole in a flow text slot (a notify title / message, a screen title / description, a refusing end message) whose root is a $ name the flow engine does not bind is now refused by the one text-slot judge, textSlotTemplateRefusal in packages/spec/src/automation/flow-text-slot-template.ts. Every door that already calls that judge refuses it with no edit of its own: NotifyConfigSchema, ScreenConfigSchema, EndConfigSchema, AutomationEngine.registerFlow, and objectstack validate (expression-invalid, error).

  • 'By {{ $User.Id }}' is refused with the very remedy 'By {$User.Id}' already gets ("compute it into a variable with an assignment node, whose value slot still reads it (assignments: { v: '{$User.Id}' }), and write {{ v }} here"). The test asserts the hole refusal ends with the single-brace remedy byte for byte, so the two spellings answer alike.
  • Any other $ root ({{ $User }}, {{ $Error.message }}, {{ $org.id }}) gets a remedy that names the root and the variables the engine does bind.
  • A single-brace path token over such a root ('Failed: {$caught.message}') used to be told to write {{ $caught.message }}, which would now be refused in turn. It gets the same remedy instead of a rewrite.
  • Controls hold: {{ $error.message }}, {{ record.name }}, a node output {{ lookup.result }}, and every hole over an engine-bound $ variable are unchanged.
  • ⛔ The template engine binds no new variable (triage ruling).

The $ roots are one enumerated list (H1, measured)

The runtime has no single declaration of its $ variables. They are bound by literal name in three places:

root where service-automation binds it (at dee7692f0b)
$record, $runId, $flowName, $flowLabel engine.ts seedRunVariables (every run attempt; $record when there is a trigger record)
$error engine.ts, the throw arm and the returned-failure arm of node execution (also the try_catch errorVariable default)
$loopItems, $loopIndex builtin/loop-node.ts, the legacy flat-graph loop with no body

The spec has no home for this either. contracts/automation-service.ts states only that $ names are reserved, in its INVALID_SIGNAL prose. So this follows H1's fallback. The list FLOW_ENGINE_VARIABLES sits beside the judge in packages/spec. A parity pin in service-automation's text-slot-template.test.ts scans that package's non-test sources for every .set('$name' literal and asserts the public judge admits a hole over each one. Today the scan finds exactly those seven, listed with their files. The scan also carries a floor that fails if one of the seven stops being bound, so a removal is caught too.

The list is module-private, not exported. A new public export would enlarge the public surface, and that is the question Clause-② answers; it is ruled no. The parity pin reaches the list through textSlotTemplateRefusal, which is already public. check:api-surface and check:export-origins are unchanged and green.

H2 (node outputs): a node output is addressed by its node id ({{ lookup.result }}). The engine writes it as NODEID.KEY. git grep finds no node id starting with $. The map node's .$mapState / .$mapItemDone are .$ segments under a node-id root, not $ roots. So no node output joins the list.

H3 (the validate door): objectstack validate's text-slot check is validate-expressions.ts, and it calls textSlotTemplateRefusal(slot.source) before compiling the slot. The refusal therefore reaches objectstack validate through the same judge, and no lint source edit is needed. validate-flow-template-paths.ts does skip $ roots, but that is the record-field-path warning rule, not the text-slot door. The validate-door pin is in validate-expressions.text-slot.test.ts.

H4: no skills/** edit.

Boundary, stated

The triage ruling says nothing outside the list is admitted. So a variable an author binds under a $ name, such as try_catch errorVariable: '$caught' read as {{ $caught.message }}, is refused in a text slot, and the remedy says to name it without the $. Measured: $-named errorVariables other than $error occur only in tests ($caught in throw-arm-error-refresh.test.ts, $err in two spec tests). None of them is read in a text slot.

Changeset: @objectstack/spec minor, @objectstack/lint patch, Clause-②: no (narrowing), BREAKING

Patch round 1 (REWORK 6083820886 on #22477, after contract review FAIL 6083797903) corrected the grade:

Re-sync after PR #22215 (head 52c005e2b1)

Tests, round 0 (head f0b39b02de)

  • packages/spec flow-text-slot-template.test.ts: 26 passed. It holds the card's pins at the judge and at the three node contracts (NotifyConfigSchema with a bare string and with a template envelope, ScreenConfigSchema, EndConfigSchema; each refused at the key with code: custom), plus the controls.
  • packages/services/service-automation text-slot-template.test.ts: 18 passed. It holds the registerFlow pin, the parity scan, and a run that renders {{ $flowName }} / {{ $flowLabel }} / {{ $record.name }} as roots / roots / Acme Corp.
  • packages/lint validate-expressions.text-slot.test.ts: 6 passed. It holds os validate's own sequence (normalize, parse, runAuthoringRules('validate')): By {{ $User.Id }} in a notify message and a screen title is one error finding carrying the remedy. The control {{ $error.message }} / {{ record.name }} gives [].
  • Package suites and typechecks: every run below is on head f0b39b02de and went through os-verify-lock.sh:
    • @objectstack/spec vitest run --project local: 630 files, 18805 passed, 1 todo;
    • @objectstack/spec vitest run --project repo (the corpus walk over other packages' sources): 54 files, 915 passed;
    • @objectstack/service-automation test: 179 files, 2197 passed;
    • @objectstack/lint vitest run: 130 files, 5944 passed;
    • typecheck (tsc --noEmit plus check:test-typecheck) is green on spec, service-automation and lint.

Ablation (one-shot, nothing left in the tree)

Every leg ran through scripts/ablation-replace.mjs (anchor must hit, blob hash proven, restore trap armed). The fix was committed first. Service-automation and lint resolve @objectstack/spec through dist/, so spec was rebuilt inside each leg, and scripts/ablation-dist-preflight.mjs proved the mutation reached dist/.

  • Leg A, the hole refusal removed. [singleBraceRefusal(text), unboundRootHoleRefusal(text)] became [singleBraceRefusal(text)]: anchor 1 → 0, blob b022c9d7bcda → 1d140ae58225, marker absent from all 98 built files.
    • spec: 7 failed / 19 passed (every hole pin and every schema pin);
    • service-automation: 2 failed / 16 passed (the parity control and registerFlow);
    • lint: 1 failed / 5 passed (the validate door).
  • Leg B, '$loopIndex' deleted from the list: anchor 1 → 0, blob → 8679f7dab66e, "$loopIndex" absent from dist.
    • spec: 1 failed (engine-bound roots admitted);
    • service-automation: 1 failed, with the message $loopIndex, bound in builtin/loop-node.ts. This is the parity pin firing;
    • lint: 6 passed (not its subject).
  • Restore: each leg's blob equals HEAD b022c9d7bcda and git diff HEAD is empty. After a full spec rebuild, the preflight in default mode finds both markers back in dist/, git status --porcelain is empty before and after the build, and the three files show 26 / 18 / 6 passed.

Gates

node scripts/pm/dispatch-gates.mjs --commands derived 88 families from this diff: the dispatch list plus 6 the test edits added (check:engine-double-contract, check:objectql-double-limit, check:query-options-erasure, check:type-check-coverage, check:type-check-debt, check:where-matcher). The derivation was re-run on a throwaway tree at origin/main 35ef501e13 with this diff applied, and it printed the identical 88.

--ran reconciliation: 88 accounted for, 87 run with exit 0, 0 unrun, 1 NOT MEASURED. Among the 87: check:api-surface, check:export-origins, check:authorable-surface, check:docs, check-adr-0087-registration ("1 non-breaking changeset"), check-changeset-no-major, check-empty-changeset, check:published-files, check:nul-bytes, check:doc-authoring and check:cross-package-test-inputs.

  • NOT MEASURED: pnpm check:dual-build-cjs-loads. Reason: PREREQUISITE NOT MET (exit 3). The gate reads every package's built dist/, and 36 packages are unbuilt in this worktree. This diff changes no build config, no exports and no entry point. Left to CI.

Acceptance notes

  • The flow-bare-dollar-reference hint prescribed a refused hole for a bare $User.Id. Fixed in patch round 1 (d9a5ebbb6f).
  • The published skill skills/objectstack-automation on main says "{{ $User.Id }} renders blank: assign them to a variable first". Once this lands, the parenthetical is outdated: the hole is refused at os validate / registerFlow / the node contract. The instruction itself is still right. skills/** is governed and outside this card. Carrier: the skills seat.
  • content/docs/automation/flows.mdx's "you wrote / write instead" table could gain a {{ $User.Id }} row. Nothing on the page is made false by this change: it already says holes read "the engine-set $-named ones". Carrier: none.
  • try_catch's errorVariable / outputVariable still accept a $-named variable that a text slot now refuses to read: filed as spec(automation): try_catch's errorVariable and a node's outputVariable accept a $-named variable that a flow text slot now refuses to read (two doors of one contract disagree after #22477) #22502.

Generated by Claude Code

claude added 3 commits October 9, 2026 13:00
…engine does not bind

A `{{ $User.Id }}` hole in a flow text slot compiled (the hole grammar
admits `$` so the engine's own `$error` has a spelling) and rendered a
blank fragment with the run reporting success, while `{$User.Id}` was
refused at the same door with its remedy. The text-slot judge now reads
one enumerated list of the `$` variables the engine binds and refuses a
hole over any other `$` root: `{{ $User.<path> }}` gets the remedy its
single-brace spelling gets, any other root a remedy naming the engine's
variables. A single-brace path token over such a root is no longer
rewritten to a hole the judge would refuse in turn.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…d validate, and the engine-bound list

The registerFlow and `objectstack validate` doors refuse `By {{ $User.Id }}`
in a text slot through the spec judge, with the remedy `{$User.Id}` gets;
`{{ $error.message }}` and `{{ record.name }}` stay clean. In
service-automation, a scan of the package's sources for every `$`-named
variable it binds by literal name asserts the spec judge admits a hole
over each, so a root the engine starts binding without a line in the
spec list reddens here.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

18 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/spec-changes.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/spec-changes.json) — pages documenting those are invisible to this run
  • 8 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 — 139 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 c76edeb8c623a9996a6706b7f4daf5b5b4acae9b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f7830c31750650db1aed753fa081b5fac2dd7806 — the merge of head c36b06966d2a5cb97faff4a3cf52a38d0a30ba1e into base c76edeb8c623a9996a6706b7f4daf5b5b4acae9b, 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 f7830c31750650db1aed753fa081b5fac2dd7806 && git checkout f7830c31750650db1aed753fa081b5fac2dd7806
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c76edeb8c623a9996a6706b7f4daf5b5b4acae9b c36b06966d2a5cb97faff4a3cf52a38d0a30ba1e && git checkout -B drift-repro c76edeb8c623a9996a6706b7f4daf5b5b4acae9b && git merge --no-ff c36b06966d2a5cb97faff4a3cf52a38d0a30ba1e

node scripts/docs-audit/affected-docs.mjs --json c76edeb8c623a9996a6706b7f4daf5b5b4acae9b

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Inputs: card #22477 (body; comments 6080591754 triage, 6081163129 claim, 6083552417 os-dev-report), PR #22499 (body, file list, the net diff of the head against main: 5 files, +437 / -17), the head's check-runs read at 2026-10-09T15:17Z. For ② only, two reads and no run: the npm registry's dist-tags for @objectstack/spec and this repository's own tag @objectstack/spec@17.7.0. Nothing was built, tested or re-gated.

① Derived judgments

  1. The three node contracts narrow - NotifyConfigSchema (title, message, bare string or template envelope), ScreenConfigSchema (title, description), EndConfigSchema (message): a {{ }} hole whose path compiles and whose root is a $ name outside the seven the engine binds is refused at the key, code: custom. Right direction (the triage ruling's own: refuse at the same door, the roots come from one list, the engine learns nothing). Declared wrong - see ②.
  2. registerFlow and objectstack validate refuse the same text with no edit of their own - right, and measured on main: the judge's five call sites are io-node-config.zod.ts (notify), builtin-node-config.zod.ts (screen, end), service-automation/src/engine.ts (registerFlow) and lint/src/validate-expressions.ts (expression-invalid, error). The lint and service-automation diffs are test-only, as claimed.
  3. The single-brace prescription changes: a {…} path token over an unbound $ root ({$caught.message}) is no longer rewritten to a hole the same judge would refuse; it gets the unbound-root remedy. Right - a prescription that names a refused spelling is the defect class this card exists for. The sentence it edits is [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, unreleased.
  4. textSlotTemplateRefusal now returns a string where it returned undefined for such text - the same narrowing seen through the one public function; no signature change. Right.
  5. Public surface unchanged: FLOW_ENGINE_VARIABLES, ENGINE_VARIABLE_SET and ENGINE_VARIABLE_HOLE_REFUSAL are module-private, the diff adds no export, packages/spec/api-surface/automation.json is untouched. Right; the Clause-②: VALUE no is right (no widening, no new public symbol). The seven names reach authors as prose inside the refusal, which is a message, not a surface.
  6. @objectstack/formula is untouched - {{ $User.Id }} still compiles there and renders nothing; the refusal lives at the flow doors only. Right per the ruling (no new $ root in the engine).
  7. The seven-name list equals the runtime, measured on main: the only literal $-bindings in service-automation/src outside tests are loop-node.ts ($loopItems, $loopIndex) and engine.ts ($error at the throw arm and the returned-failure arm, $record, $runId, $flowName, $flowLabel). The one non-literal bind is try-catch-node.ts variables.set(errorVariable, …), default $error, which the list carries. Node outputs are written variables.set( + node id + . + key ), so H2 holds. Right.
  8. Judge mechanics: HOLE and HOLE_PATH are byte-equal to the formula engine's HOLE_RE and PATH_ONLY_RE on main; a hole that fails HOLE_PATH is left to the compile step every door already runs; a $User. path is routed through templateTokenKind(...) === 'user' to the very unspellableRemedy the single brace gets, so the two spellings answer byte-identically (pinned); a bare {{ $User }} is kind path and gets the generic remedy naming the root. Right.
  9. Tests: the parity scan reads its own package's src from an import.meta.url seed in a spelling check:cross-package-test-inputs recognises, and reaches the list only through the public judge. Right.

② Semver level

Measured against npm latest. npm view @objectstack/spec dist-tags answers latest: 17.7.0, rc: 17.0.0-rc.6, no next. At this repository's tag @objectstack/spec@17.7.0 there is no flow-text-slot-template.ts at all, and the three text-slot contracts are plain strings: NotifyConfigSchema.title / .message are z.string().optional(), ScreenConfigSchema.title / .description are z.string().optional(), EndConfigSchema.message is z.string().min(1).optional(). So the last published spec ACCEPTS title: 'By {{ $User.Id }}' at every one of the doors this diff edits; objectstack validate at that tag only warned (flow-double-brace-interpolation carries no severity), and the 17.x interpolateString regex \{([^{}]+)\} rendered the inner token. At this head the same string is refused at the key.

Verdict on the dev's measurement: wrong. "The acceptance this tightens arrived with the {{ }} delimiter and has not been released" conflates two things. What #22110 added, unreleased, is the hole SEMANTICS. The ACCEPTANCE of the string at the published schema face predates it: 17.7.0 admits it. This is a narrowing of a published accept set, measured at the face contract-review.md names as the contract (packages/spec/src/**, non-test), and that is the shape AGENTS.md step 3 spells (narrowing) = BREAKING.

No ADR-0087 category carries "the line is unreleased." unpublished requires every bumped package to be private: true - @objectstack/spec publishes, which this line's own opening changeset 22080-v18-line-opens.md states in its marker while saying "each breaking stage of the line states its own disposition in its own changeset". no-migration-prescription is refused by a body that carries a FROM-TO table, which this one does. already-registered flow-text-slot-single-brace-refused is not honest as written: that D3 entry's surface is "a string carrying a single-brace template token", and under this very judge's singleBraceTokens a {{ $User.Id }} carries none, so a 17.7.0 author grepping the upgrade guide for it finds nothing. Precedent on the same line, already pending on main: 22343-retry-policy-try-catch-undeclared-keys-refused.md - minor, Clause-②: no (narrowing: …), a **BREAKING** banner, adr-0087: registered ….

Pre mode does not change this. The fixed group (69 packages) already carries a pending major, so the number is 18.0.0-next.N whatever this changeset says. That is exactly why the level is not the carrier of breaking-ness and the arm, the banner and the disposition are (ADR-0087 addendum on #18003). patch with a bare Clause-②: no reproduces the #16296 shape the gate's signal (4) was built against: an accept-set narrowing shipped to consumers under "Patch Changes" with every gate green and no ledger entry. check-adr-0087-registration reporting "1 non-breaking changeset" is the gate taking the author's word, not a finding in the author's favour.

What the changeset must be (.changeset/22477-flow-text-slot-dollar-root-refused.md):

Service-automation and lint ship no source change and need no entry - right.

③ Boundary flags

  • Dev flag: patch, Clause-②: no with no arm, no ADR-0087 disposition, resting on "never released." Answered: wrong, on the 17.7.0 measurement in ②. This is the FAIL carrier; the prescription is in ②.
  • An author-bound $ variable (try_catch errorVariable: '$caught') read in a text-slot hole is now refused. Answered: right under the ruling ("nothing outside the list is admitted") and under the contract's own reserved namespace (IAutomationService.resume INVALID_SIGNAL: $ names are the engine's). The remedy is executable: try-catch-node.ts binds whatever name it is given, so errorVariable: 'caught' with {{ caught.message }} renders, and the changeset's FROM-TO row says so. Corpus on main: $caught and $err occur only in three test files, none read in a text slot. Escalated, not failing: a seam remains inside packages/spec - TryCatchConfigSchema.errorVariable is z.string().default('$error') and every outputVariable is z.string(), so the contract still admits AUTHORING a $-named variable that a text slot now refuses to read while a value slot still reads it. Two doors of one contract disagree. The bind site is outside this card's file surface; it wants a follow-up card in the spec lane (refuse a $-named author binding where it is declared), not a rider here.
  • The engine's $ roots live in a package-internal list with a parity pin in service-automation. Answered: right. The spec cannot import the runtime, the list is measured equal to the runtime (① item 7), the pin reaches it through the public judge, the floor catches a removal, and leg B of the ablation shows the pin firing with the file name. Residual, acceptable: the scan sees only a .set('$name' literal - a binding through a helper or a Map literal would be invisible to it - which the pin's docblock already states ("by its literal name").
  • open_questions: none filed; none found.
  • Out-of-scope findings: the lint-flow-patterns.ts flow-bare-dollar-reference hint still prescribes {{ $User.Id }} for a bare $User.Id, a spelling this judge now refuses - the same class as ① item 3, which this PR fixes in the single-brace remedy. "Noted only" is too weak for it; file it as a card (lint). The skills/objectstack-automation parenthetical ("renders blank") goes stale and is the skills seat's; the flows.mdx table row is optional. Agreed.
  • Check-runs on the head, two readings, still in progress - not judged green. First reading: 32 runs, 13 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke, all opt-in or path-skipped), 16 in progress. Second reading: 33 runs, 22 success, 3 skipped, 8 in progress (Test Core 1-6, Lint and Repo Gates, Type Check workspace). No failure read. The ADR-0087 and changeset gates are inside Lint and Repo Gates, and they will pass this head exactly because the declaration they read is the one ② finds wrong.

Implemented-by: claude/issue-22477-text-slot-dollar-root
Reviewed-by: session_01VZqqwTj2wsihZEbfT6yyYN

VERDICT: FAIL

claude added 4 commits October 9, 2026 15:21
…edy for a $ root the engine does not bind

In a flow text slot, the hint for a bare `$X.y` written outside the holes
prescribed the hole `{{ $X.y }}` for every root. For a root the flow
engine does not bind (`$User.Id`) that hole is now refused by the spec's
text-slot judge, so the hint asked the author for a refused spelling. The
hint now asks the judge per reference: an admitted hole is prescribed as
before, a refused one gets the judge's own refusal and remedy. The roots
stay listed in one place, the judge.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…-root-refused

The 17.x node contracts typed the flow text slots as plain strings, so a
`{{ $User.Id }}` hole in a notify, screen or end text slot was accepted by
the last published spec; the text-slot judge now refuses it. The D3
semantic entry records the narrowing with its remedy (compute the value
with an assignment node, then write the variable as a hole; a variable
the flow binds itself is named without the `$`), and step 18's rationale
gains its fragment. The registry region is regenerated by
gen:migration-registry; spec-changes.json and the upgrade guide do not
move on this base, where step 18 is not projected yet.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…g of the published spec

`@objectstack/spec@17.7.0` (npm latest) types the flow text slots as plain
strings and accepts `{{ $User.Id }}` there, so the refusal narrows a
published accept set: `minor`, a BREAKING banner, `Clause-②: no
(narrowing)` and the ADR-0087 disposition `registered
flow-text-slot-unbound-dollar-root-refused`. `@objectstack/lint` takes a
patch for the flow-bare-dollar-reference hint.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
claude added 2 commits October 9, 2026 17:16
…low-text-slot-unbound-dollar-root-refused

Pure regeneration after the merge of origin/main 4e9fe9f, which projects
protocol step 18: gen:spec-changes and gen:upgrade-guide add the D3 entry
flow-text-slot-unbound-dollar-root-refused (step 17 -> 18 semantic count
329 -> 330 in both) and its rationale fragment. No hand edit.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 52c005e2b173d01b128e03112a1bdf6efcae114b
Local-runs: none

Inputs: card #22477 (body; comments 6080591754 triage, 6081163129 claim, 6083552417 / 6085693344 / 6086613543 the three os-dev reports, 6083820886 the seat's REWORK), PR #22499 (body, its 11-file list, comments 6083515708 and the first record FAIL 6083797903, the net diff of 52c005e2b1 against main at merge-base 4e9fe9ff6a: 11 files, +632 / -23), the head's check-runs. For ② only, reads and no run: this repository's tag @objectstack/spec@17.7.0 (commit 4e4e881427). Second record on this PR; the first, FAIL 6083797903 at f0b39b02de, is re-verified below, not inherited. Nothing was built, tested or re-gated.

① Derived judgments

  1. The judge is unchanged since f0b39b02de (flow-text-slot-template.ts is blob b022c9d7bc at both heads), so the first record's ① holds on re-read: the three node contracts (NotifyConfigSchema title / message, ScreenConfigSchema title / description, EndConfigSchema message) refuse a {{ }} hole whose path compiles and whose root is a $ name outside the seven the engine binds, code: custom at the key; registerFlow and objectstack validate refuse the same text through the same judge with no source edit of their own; a single-brace path over such a root gets the remedy, never a rewrite the judge would refuse; HOLE / HOLE_PATH are byte-equal to @objectstack/formula's HOLE_RE / PATH_ONLY_RE on main; {{ $User.PATH }} routes through templateTokenKind(...) === 'user' to the very sentence {$User.PATH} gets, pinned byte for byte. Right.
  2. The seven-name list still equals the runtime, re-measured on main today: the only literal $ bindings outside tests in service-automation/src are loop-node.ts ($loopItems, $loopIndex) and engine.ts ($error at two arms, $record, $runId, $flowName, $flowLabel); every other variables.set( writes an author-named or node-id-named key. The parity scan reads .set('$name' literals from its own package's src, with a floor for removals. Right.
  3. Public surface unchanged: FLOW_ENGINE_VARIABLES, ENGINE_VARIABLE_SET, ENGINE_VARIABLE_HOLE_REFUSAL and the helpers stay module-private; the file list carries no api-surface/ change; the one symbol lint newly imports in lint-flow-patterns.ts, textSlotTemplateRefusal, was already public (lint's validate-expressions.ts imports it on main). The Clause-② value no is right.
  4. The lint hint (d9a5ebbb6f): flow-bare-dollar-reference's own accept set does not move — the trigger is the same BARE_DOLLAR_REF.test(outsideHoles); only the hint moves, from a static {{ $ref.field }} to textSlotBareDollarHint, which asks textSlotTemplateRefusal once per distinct bare $X.y and re-lists no root. For a bare $User.Id it withholds the hole and gives the judge's refusal with the assignment remedy (assignments: { v: '{$User.Id}' }, then {{ v }}) — right, since that hole is now refused at every door. For a bare $error.message it prescribes {{ $error.message }} with no remedy — right, the engine binds it. A slot carrying both gets one finding with both answers. BARE_DOLLAR_REFS requires at least one .segment, as BARE_DOLLAR_REF does, so the hint never comes back with neither a hole nor a refusal. Residual, acceptable: for a bare root that is neither engine-bound nor $User. ($org.id), the judge's sentence names the hole spelling it was asked about, right after "has no hole either".
  5. The D3 entry 18.flow-text-slot-unbound-dollar-root-refused.ts (504b61ad21): surface names the five slots, a bare string or a template envelope's source, and the hole class with {{ $User.Id }} as its example — no backtick and no pipe, so the code span and the table cell survive; replacement is the assignment-then-hole remedy, the drop-the-$ remedy for an author-bound name, and the seven engine roots that stay holes; reason states the 17.x plain-string acceptance and the blank render under the template engine; acceptanceCriteria is objectstack validate's expression-invalid plus the re-run. Semantic-only with no D2 conversion is right: what the hole meant to read is not in the flow. Its registry.ts row sits in step18.semantic after flow-text-slot-single-brace-refused, the generator's id order. Right.
  6. The STEP18_RATIONALE fragment: id flow-text-slot-unbound-dollar-root-refused, inserted where it sorts (between flow-text-slot-single-brace-refused and flow-value-slot-template-dialect-refused), order: 91, one more than the base's highest (90) and unique; the list's docblock admits a shared number for retirements in flight, so the other duplicated numbers in that list are not this PR's. Right.
  7. The two merges and the regeneration: b3482cb096 (main e148ca9842) and 6e65fb1747 (main 4e9fe9ff6a, PR feat(spec)!: PROTOCOL_VERSION 17 → 18 in an ordinary PR — regenerated spec-changes.json and upgrade guide, ^18 handshakes, pre-mode lockstep exception (#22085 Q1 → B) #22215) add no content of their own — the net diff against the merge-base is exactly the nine hand-written paths plus the two regenerated ones. 52c005e2b1 is a pure regeneration: spec-changes.json perMajor 17→18 migrated 329 → 330 and aggregate migrated 406 → 407, with added / converted / removed unchanged (0 / 64 / 0 per major, 0 / 121 / 0 aggregate), the one migrationId added in both regions being this PR's and none removed; protocol-upgrade-guide.md's "Protocol 17 → 18" Semantic list 329 → 330 bullets, the bullet carrying the entry's "Why not automatic" and "Done when", and the step-18 rationale paragraph gaining the fragment. Right.
  8. Tests follow the behaviour: the spec pins (the byte-identical remedy, each engine-bound root with a formatter and an index, the generic remedy per root, single brace first, the compile-step hand-off, the no-rewrite rule, all three contracts at the key), the service-automation parity floor and registerFlow, the validate-door pin, and the three lint-hint pins ($User.Id, the $error.message control, the mixed slot). Right.

② Semver level

The changeset .changeset/22477-flow-text-slot-dollar-root-refused.md now matches what the diff publishes. Re-measured, not inherited: at tag @objectstack/spec@17.7.0 (4e4e881427) there is no flow-text-slot-template.ts; NotifyConfigSchema.title / .message are z.string().optional(), ScreenConfigSchema.title / .description are z.string().optional(), EndConfigSchema.message is z.string().min(1).optional(); the 17.x interpolateString replaces every {…} and its resolveToken trims the token and reads $User. from context.userId, so By {{ $User.Id }} rendered By {usr_7} — the changeset's "left a literal brace on each side" is exact. The published contract accepts the string and this head refuses it at the key: an accept-set narrowing of a published surface, and the changeset says so.

  • frontmatter '@objectstack/spec': minor — the level check-changeset-no-major names for a declared narrowing during the launch window, and the level .changeset/22343-retry-policy-try-catch-undeclared-keys-refused.md carries on this line; major would also be lawful in pre mode (pre.json mode pre, tag next), patch would not have carried the level axis. Right.
  • '@objectstack/lint': patch — the hint text ships in lint's dist, lint's own accept set does not move (① item 4), and a fix in a released package takes a patch. Right. service-automation ships a test-only change and needs no line. Right.
  • Clause-②: no (narrowing) — the value no (no widening, no new public symbol) with the arm the two gates read; the PR body's line 2 is the same line, and the claim line Clause-②: no on the card stands as the closed pair. Right.
  • a **BREAKING** banner, over a FROM → TO table and a one-line fix. Right.
  • exactly one disposition marker, adr-0087: registered flow-text-slot-unbound-dollar-root-refused, naming the entry this diff adds (file, registry row, regenerated artifacts). Right — the gate's "a registration this PR did not make" reading cannot fire, since the id is new here.
  • "Who is affected, measured" states the 17.7.0 reading and drops "never released". Right.

check-adr-0087-registration and check-changeset-no-major both run inside the green Lint & Repo Gates on this head, and now read the declaration ② finds right.

③ Boundary flags

  • Dev flag (round 1): "the changeset also grades @objectstack/lint: patch." Answered: right (②).
  • Dev flag (rounds 1 and 2): the PR body was stale beyond line 2 and is the seat's to edit. Answered: as read for this record, line 2 is Clause-②: no (narrowing), the Changeset section states minor plus lint patch, the arm, the banner, the registered id and the 17.7.0 reading, and the re-sync section carries the 52c005e2b1 numbers; the round-0 Tests / Gates / Ablation sections are labelled by head f0b39b02de, under which the judge is unchanged. Consistent.
  • Dev flag (round 2): pushed before the gate battery; the first lint suite run was invalidated by an unlocked check:type-check-debt build and re-measured. Answered: the gates and CI ran on the exact head, and the head's own lint and test lanes are green (below). Nothing to escalate.
  • The author-bound $caught hole is refused — unchanged since round 0 and right under the ruling; the seam the first record escalated (try_catch errorVariable / outputVariable still accept a $ name) is now card spec(automation): try_catch's errorVariable and a node's outputVariable accept a $-named variable that a flow text slot now refuses to read (two doors of one contract disagree after #22477) #22502 (open, domain:spec, p3). Closed here.
  • Does main owe this PR another re-sync? No. Since the merge-base 4e9fe9ff6a, main moved five commits (9c93ce631f, da989bbb24, 215e66204a, ce3d0ad419, 9411faa1ba); none touches registry.ts, spec-changes.json, the upgrade guide, migrations/entries/, or any of this PR's 11 paths, and git merge-tree of origin/main with the head is clean. packages/lint/src/validate-expressions.ts did move (spec(data): a date or datetime field declares its deadline semantic — dueLike with settledWhen (per-record CEL), so overdue wording and colour derive from one declaration and the name-pattern guess retires (objectui#11815 ruled D) #22227, a fifth field-rule slot settledWhen) — the door this PR's validate pin exercises but does not edit, and orthogonal to its text-slot arm. A re-sync becomes owed only if another step-18 registry.ts writer lands first; the queue leg answers that at enqueue.
  • open_questions: none filed in any of the three reports; none found.
  • Out-of-scope findings: the skills/objectstack-automation "renders blank" parenthetical is the skills seat's; the flows.mdx row is optional; the unlocked check:type-check-debt rebuild under a locked suite is os-dev tooling, not this contract. Agreed, noted.
  • Check-runs on the head, two readings (one at the start of this review, one at 2026-10-09T18:23Z), identical: 42 runs, 37 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke — path-skipped or opt-in; Auto Label and Check PR Size also have a success twin), 0 in progress, 0 failure. Green by name: Lint & Repo Gates, Test Core 1–6, Build Core, Type Check (workspace, source gates, consumer gates, debt ledger), TypeScript Type Check, Check Changeset, Governed Surface Queue Guard, Spec property liveness, Dogfood Regression Gate 1–3, Dogfood Verify CLI, Temporal Conformance. The one commit status, Vercel, is success.

Implemented-by: claude/issue-22477-text-slot-dollar-root
Reviewed-by: session_01VZqqwTj2wsihZEbfT6yyYN

VERDICT: PASS

claude added 2 commits October 9, 2026 20:05
…e merged tree

Pure regeneration after the merge of origin/main c76edeb, which carries
the step-18 D3 entry storage-scope-public-retired (ee8751d). The
os-regen driver kept one side of both artifacts in the merge;
gen:spec-changes and gen:upgrade-guide re-derive them from the merged
registry, holding both storage-scope-public-retired and
flow-text-slot-unbound-dollar-root-refused: step 17 -> 18 semantic count
331 in both documents. No hand edit.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review carried over a pure regeneration — 52c005e2b1 → c36b06966d

domain:spec seat 1 (#6017) · session session_01VZqqwTj2wsihZEbfT6yyYN · 2026-10-09T21:15Z · holder of claim 6081163129. The re-sync round 2 dev pushed both commits. Its os-dev-report was lost to a container restart before it posted, so everything below is read off the committed trees by the seat.

Regen-provenance: 6086795478 · 52c005e2b173d01b128e03112a1bdf6efcae114b → c36b06966d · pnpm --filter @objectstack/spec gen:spec-changes && pnpm --filter @objectstack/spec gen:upgrade-guide → (empty)

What the hop is:

  • 869d48e36b merges main at c76edeb8c6 into 52c005e2b1; its second parent includes PR feat(storage)!: retire the storage scope public from StorageScopeSchema and refuse it at the upload doors (#22443) #22469 (ee8751d41e). git diff-tree --cc 869d48e36b prints no content of its own.
  • c36b06966d regenerates the two ADR-0087 documents in a separate commit: packages/spec/spec-changes.json +14 and docs/protocol-upgrade-guide.md +3. It restores PR feat(storage)!: retire the storage scope public from StorageScopeSchema and refuse it at the upload doors (#22443) #22469's storage-scope-public-retired entry, which the merge's merge=os-regen route left out. Against c76edeb8c6, the generated delta is this PR's flow-text-slot-unbound-dollar-root-refused alone.
  • The 11 hand-written PR paths carry byte-identical +/- lines in 4e9fe9ff6a..52c005e2b1 and in c76edeb8c6..c36b06966d (equal sha1 over the hunks).
  • CI on c36b06966d: 35 runs, 32 success, 3 skipped, 0 failing. That includes check:spec-changes and check:upgrade-guide, which on this base still compare the committed copies, so the regeneration is exact on this tree.
  • The record carried is the at-tier contract review 6086795478 (PASS at 52c005e2b1). The line above is a pointer; a reader re-runs the comparison on the committed trees.

Order: main has since moved past c76edeb8c6. PR #22524 edited one step-18 entry's text, so a textual merge in the queue would meet a regenerated document again. This PR is made ready and enqueued only once PR #22533 (#22482) has merged. From then on, the pull-request check generates both documents in memory and compares no committed copy, and no further regeneration commit is owed.

This branch has not been deployed

No deployments
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/l tests tooling

Projects

None yet

2 participants