Skip to content

feat(spec)!: flow text slots read the {{ }} delimiter, refusing a single-brace token with its hole spelling (#22110) - #22315

Merged
objectstack-fleet[bot] merged 26 commits into
mainfrom
claude/issue-22110-flow-text-slot-double-brace
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 26 commits into
mainfrom
claude/issue-22110-flow-text-slot-double-brace

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22110
Clause-②: yes (narrowing: the text slots' single-brace tokens are refused, while {{ }} holes now render there and a $ variable gains a hole spelling; corrected from the lossless-conversion reading by the seat in comment 6063190355)

The line above is the claim's, copied verbatim as the dispatch requires. Its parenthetical predates the measurement: no spelling measured lossless, so this PR registers no D2 conversion (step 2 below). Every single-brace token in a text slot is refused with its remedy. The changeset carries the same value (Clause-②: yes) without the parenthetical, so the published changelog does not state a conversion that does not exist. The direction is a narrowing, graded major on the v18 pre line, and the changeset carries the ADR-0087 disposition registered flow-text-slot-single-brace-refused.

The rulings this executes

  • ADR-0032 (Accepted), Decision 3: "One delimiter, {{ }}; single { } deleted." (docs/adr/0032-unified-expression-layer.md:78). Its representation table names the text-template role: notify.title/body, titleFormat, email/notification.
  • ADR-0087 D2: a spelling converts automatically only where it renders the same; "A spelling that renders differently is refused at registration with its remedy, ⛔ never converted."

Measurement first

Step 1: the slots the single-brace interpolator reads (this repo, at the merge base 238222d8c)

The census is a TypeScript-AST walk over git ls-files '*.ts': every object literal carrying id, type and config, every string leaf under config, with a nested node counted on its own. Source sites are listed below. Test sites are counted separately, and the census script is in the report.

slot role source strings / tokens
notify.title text, stringifyForTemplate(interpolate(…)) 19 / 22
notify.message text 11 / 16
screen.description text, interpolateText 1 / 1
screen.title text 0
end.message (refused) text, interpolateText 0
notify.recipients / recipients[] value: hands an id or a list over 23 / 23
notify.sourceId · actionUrl · source.* value / reference 11 · 5 · 4
screen.defaults.* · recordId · field defaultValue value 3 · 0 · 0
subflow.input.* · script.inputs.* · map.input.* value 5 · 9 · 0
loop.collection · map.collection value: a list, by type 6 · 7
http.* (whole config) value (interpolated before its parse) 4
*.filter.* filter (interpolateFilter) 9
create_record.fields.* · assignments.* value slots, CEL's (#19939) 1 (the kept {$User.Id}) · 0

The five text slots are the positions whose rendered value is only ever text. Every other position hands its resolved value over with its type: a whole-string {x} returns the raw value. The template engine always returns a string, so moving any of them would change what a value position accepts (the dispatch's value-slot stop condition). They keep the single brace. The interpolator serves no slot outside flows: it is not exported from @objectstack/service-automation, and the messaging and email renderers have their own {{ }} readers.

In-tree text-slot sites: examples/app-showcase (24 strings, 30 tokens) and examples/app-todo (5 strings, 7 tokens). All 37 tokens are paths, one of them {$error.message}. The card's "46 in 2 files" counted a {record.…} pattern across all notify keys, including recipients and sourceId.

Step 2: the lossless check (H2)

Each spelling was rendered through the shipped interpolator (the notify path stringifyForTemplate(interpolate(…)), and the screen / end path interpolateText) and through the shipped templateEngine.evaluate over the same variables, with {x} rewritten to {{ x }}. Abridged; the full 90-row grid is in the report.

spelling input 17.x interpolator template engine verdict
Hello {record.name} and {record.name}, {record.owner.name}, {rows.0}, {summary}, {n1.result} string, number, 0, boolean, null, absent, '', ISO date string, object, array the text the same text SAME
any path a Date value (what a CEL now() / today() assignment stores) "2026-10-08T09:30:00.000Z" (quotes included) 2026-10-08T09:30:00.000Z DIFF
{x} as a whole screen / end text an object · an array · a Date [object Object] · x,y · Thu Oct 08 2026 … JSON · JSON · ISO DIFF
{$error.message} any the message refused: $ was not a hole character DIFF
{n1} with only n1.result set empty text {"result":"r"} DIFF
{amount * 2}, {round(x)}, {NOW()}, {TODAY() + 1}, {$User.Id} a value refused: logic or a non-variable, not a hole no spelling
a stored {{record.name}} {Acme} (outer braces kept) Acme DIFF (the lint used to warn)

Verdict: no spelling is lossless. Every path spelling has a DIFF input (a Date), the whole-slot screen / end case also differs on objects and arrays, and the rest have no hole spelling. Per the dispatch's stop condition, no conversion is registered. Every spelling is refused with its remedy, and the D3 record is the semantic entry flow-text-slot-single-brace-refused. The card's second pin therefore reads as its "(when lossless)" allows: a stored Hello {record.name} is refused at registration with Hello {{ record.name }} named.

The PM's hypotheses

  • H1, holds with a correction. The interpolator is builtin/template.ts (interpolateString, now around :357). The text slots it serves are notify title / message, screen title / description and the refusing end message. It reads many more positions than the card's "every other flow string slot" suggests, and all of those are value-like (the step 1 table).
  • H2, falsified. The engine is packages/formula/src/template-engine.ts, but no spelling renders the same on every input (step 2). It also could not read a $-named variable at all.
  • H3, holds. NOTIFY_TEMPLATE_PLACEHOLDER is the one constant both notify refusals and the blank-envelope refusal read, and it flips to {{ record.name }}. EXPRESSION_SLOT_TYPES lacked the two internal constructors, and both are added. The stale TemplateExpressionInputSchema comment in notify-node.ts is rewritten.
  • H4, holds with a different shape. flow-double-brace-interpolation was the only rule teaching the single brace on these slots. It does not flip to flag single braces: the build door already refuses them at error through the spec's one judge, and a second reading would repeat it. Instead it exempts the text slots (a {{ }} there is the spelling), names them in its hint, and its bare-$ sibling reads a text slot outside its holes with a hole prescription.

What changes

  • Renderer (package-internal to @objectstack/service-automation, builtin/template.ts; the package exports . only, so its public surface does not move): renderTextSlot(value, variables) is the ONE text renderer, used by notify title / message, screen title / description and the end refusal message. It replaces interpolateText and notify's stringifyForTemplate(interpolate(…)). It runs templateEngine.evaluate over textTemplateScope(variables): each flow variable is a root, a dotted node-output key is nested, a declared variable wins over a flat key sharing its head (the interpolator's precedence), and a variable's own object is never written into. If the slot renders no text at all the result is undefined, so a screen still falls back to its node label. A template that does not compile throws FlowTextTemplateError, a guard refusal. A screen's recordId keeps the single brace, through a local ref that does what interpolateText did.

  • Judge (@objectstack/spec/automation, new flow-text-slot-template.ts): FLOW_NODE_TEXT_SLOTS, flowNodeTextSlotSources, textSlotTemplateRefusal and TEXT_SLOT_TEMPLATE_REFUSAL. It refuses every {…} the 17.x interpolator substituted, ignoring the inside of a {{ }} hole. Remedies by token kind:

    Nothing is kept, so a text slot never mixes the two dialects.

  • Shared grammar: the interpolator's token grammar moved from flow-value-slot-template.ts into a package-internal flow-template-token.ts that both judges import, so the dialect is read in one place. The value-slot judge's behaviour is unchanged, and its suites are green.

  • Doors:

    • NotifyConfigSchema (inside its existing superRefine), ScreenConfigSchema (a new superRefine; one dropped-refinement site, ledgered 679 → 680) and EndConfigSchema refuse a single brace at the key. The end refusal also lands at FlowSchema.parse.
    • registerFlow and validateStackExpressions run the same judge, then compile each slot with validateExpression('template', …). {{ a + b }}, an unknown formatter or an unbalanced hole is refused before a run.
  • @objectstack/formula: PATH_ONLY_RE admits $ so that {{ $error.message }} is a hole. This is a widening of the hole grammar; an unbound $ path renders nothing. Landing note: this file is outside the claim's surface. Measurement put the producer of the $error gap here, and the fault-handler spelling had no {{ }} form without it.

  • Contract text: NOTIFY_TEMPLATE_PLACEHOLDER is now {{ record.name }}. The notify, screen and end .describe()s, the notify and screen descriptor help (the Studio inspector text), and the docblocks that taught the single brace (shared/expression.zod.ts, shared/typed-expression-input.ts, builtin-node-config.zod.ts, flow-node-expression-paths.ts) are rewritten.

  • Ledger: the D3 semantic entry flow-text-slot-single-brace-refused and its step-18 rationale fragment (order 89). There is no D2 conversion and no tombstone, because no key is removed.

  • In-tree sites: all 29 example strings, the docs (content/docs/automation/flows.mdx gains a "Text slots read double-brace holes" section, plus content/docs/getting-started/common-patterns.mdx and packages/services/service-automation/README.md), and the test fixtures that register flows (two dogfood fixtures and seven service-automation test files) now use {{ }}.

Tests and gates (at 11bdb8c96 unless noted)

Builds, tests and typechecks ran through scripts/pm/os-verify-lock.sh, each read from its VERDICT line. The box is shared.

  • New pins:
    • packages/spec/src/automation/flow-text-slot-template.test.ts: the slot list, every token kind's remedy, the hole and $ paths passing, and the screen contract.
    • packages/services/service-automation/src/builtin/text-slot-template.test.ts: the card's three pins.
      • Hello {{ record.name }} renders the name.
      • A stored Hello {record.name} is refused at registration with its hole spelling.
      • Control: {{ record.name }} == 'Acme Corp' in an edge condition is refused at registration.
      • It also covers every text slot at the door, compile errors at the door and past it (a guard, nothing sent), {{ $error.message }} on a fault edge, a screen's holes beside a single-brace recordId, and the renderer's measured SAME/DIFF rows.
    • packages/lint/src/validate-expressions.text-slot.test.ts: the build door through runAuthoringRules('validate', …).
    • In existing files: the formula $-path test, the lint double-brace / bare-$ text-slot cases, and a validate-flow-template-paths case proving a typo inside a hole is still reported.
  • Suites: @objectstack/service-automation 177 files / 2171 passed; @objectstack/lint 128 / 5850; @objectstack/formula 43 / 1258; @objectstack/example-todo 7 / 238; @objectstack/example-showcase 33 / 408; @objectstack/dogfood 7 named files / 58 (expression-conformance, the two schedule-organization files, seed-ownership-claim-dispatch, automation-flow-clone-door, flow-durable-suspend, flow-trigger-record-credential-mask); @objectstack/spec src/automation src/shared src/conversions src/migrations 84 / 2658, plus the repo-project step18-rationale-merge / conversions-major18-merge 2 / 21. The non-spec suites were read at d54cc713d. The second main merge (11bdb8c96) touched none of these packages' sources except packages/spec/src/migrations/registry.ts (a sibling's step-18 entry), so the spec subset and the two merge tests were re-run there with the counts above.
  • Typecheck (exit 0 each, at d54cc713d): spec, formula, service-automation, lint, dogfood, example-todo, example-showcase.
  • Generated artifacts: pnpm --filter @objectstack/spec check:generated reports all 15 current at 11bdb8c96, after pnpm --filter @objectstack/spec build (exit 0). Before that, --fix regenerated api-surface/, export-origins/ and content/docs/references/automation/**. dropped-refinements.baseline.json was hand-edited (one site).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 119 commands at d54cc713d, the same list as at f94f92250. All 119 ran with their exit codes captured before any pipe. 118 answered exit 0 on the first pass at f94f92250. check:doc-authoring answered exit 1: two #22110 ids in the dogfood ledger's prose. Those ids were stripped (d54cc713d) and the gate re-ran with exit 0, alongside check:nul-bytes, check:cross-package-test-inputs, check:issue-citations and check:published-readme-links. The --ran reconciliation reports: "119 derived famil(ies) accounted for — 119 run, 0 NOT-MEASURED". check-changeset-no-major reads the Clause-② axis from a PR payload, so locally that axis is NOT MEASURED (no PR); CI reads this body. After the second merge, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check:migration-registry, check:spec-changes, check:upgrade-guide and check:nul-bytes were re-run at 11bdb8c96 (exit 0 each), together with the 15-artifact check:generated.
  • Narrowing, declared: other flow-registering consumers (trigger-record-change, plugin-approvals, runtime, cli, metadata-protocol, the rest of dogfood) were not run locally. They carry no single-brace text-slot site by the census and a line grep. CI runs them.

Acceptance notes

  • hotcrm (69 single-brace tokens per the card) could not be read from this container. Every hotcrm text-slot token will be refused at registration until it is rewritten. Each refusal names its hole spelling, and a stored flow is skipped at boot with a warn naming it.
  • skills/** (Tier H, not in this PR): these files now teach a spelling the doors refuse.
    • skills/objectstack-automation/SKILL.md:108-109 (a notify title / message example in {record.title} / {$User.Id}).
    • skills/objectstack-automation/SKILL.md:224-236 (lists "notify message/title" among the single-brace values, and marks {{ }} as wrong).
    • skills/objectstack-automation/evals/flows-triggers-approvals.json:17 (expects "title with single-brace interpolation").
  • objectui: the Studio editors for these slots insert the single brace. The card names this as the follow-up for triage's objectui round.
  • Observations, not filed:
    • AutomationEngine.celScope nests a flat dotted key (n1.result) into an existing variable's own object, which mutates the flow variable. The text renderer builds fresh containers instead. No defect was reproduced; no carrier.
    • The template engine reads its formatter locale from extra.locale, so a flow variable named locale sets the text slots' formatter locale. This is documented on the package-internal text renderer.
    • validate-flow-template-paths judges the path of a {{ path | formatter }} hole like a bare one (patch round 2), and a bracket-indexed path ({{ rows[0].subjcet }}) with each [i] read as .i, the way the formula engine's resolvePath resolves it (patch round 3). A quoted index (['x']) stays unjudged, as an arithmetic token.
    • packages/services/service-automation/README.md's expressions table still says field values interpolate with braces, which [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939 made false. That row is not this card's.

Patch round 2 (contract review FAIL 6063558021 → seat order 6063702421 → 0b094026d)

Written into this body by the domain:spec seat 2 from the dev's report 6065199188; the role file reserves a later body edit to the seat.

  • (1) packages/spec/src/shared/expression.zod.ts: the TemplateExpressionInputSchema docblock's closing sentence names {{var}} for a notify node's title / message too. The {var} clause is gone. New pin: src/shared/template-expression-input-canon.test.ts.
  • (2) packages/spec/src/automation/flow-text-slot-template.ts: singleBraceTokens drops a token that touches a brace on either side. A hole missing one brace (Total {{ amount }, Total { amount }}) gets no rewrite from the judge, and the compile step every door runs next reports it as an unbalanced template (invalid-template at config.title, pinned at the objectstack validate door with no three-brace text in the message).
  • (3) packages/lint/src/validate-flow-template-paths.ts templateRefsIn and its docblock: inside a {{ }} hole, the path before an optional | formatter is judged like a bare one. The docblock describes both dialects, because the single-brace interpolator still renders the value-like positions (interpolate); this PR deleted only interpolateText.
  • (4) The changeset describes the package-internal renderer and the guard refusal instead of naming renderTextSlot / FlowTextTemplateError.
  • Ablations, one per code fix, each restored to the HEAD blob: (1) reddens template-expression-input-canon.test.ts; (2) reddens flow-text-slot-template.test.ts > prescribes no rewrite for a token touching exactly one brace; (3) reddens validate-flow-template-paths.test.ts > judges the path of a {{ path | formatter }} hole like a bare one.
  • Readings at 0b094026d: spec src/automation + src/shared 70 files / 2020 passed; lint whole suite 128 files / 5852 passed; service-automation text-slot tests 4 files / 46 passed; typecheck exit 0 for spec and lint; check:generated 15 / 15 current. dispatch-gates --ran: 119 derived, 119 run, 0 NOT-MEASURED. check-changeset-no-major --event: LEVEL AXIS … yes (narrowing). check-adr-0087-registration: registered flow-text-slot-single-brace-refused.

Patch round 3 (contract review FAIL 6065484352 → seat order 6065501303 → 5bfa9ae1f)

Written into this body by the domain:spec seat 2 at 2026-10-08T20:27Z from the dev's report 6068392390 (the comments before it on this PR are read: the two contract reviews are what rounds 2 and 3 answer, and the CI note 6067723298 is carried into the CI line below); the role file reserves a later body edit to the seat.

  • 171cea002, the four items:
    • (1) packages/spec/src/shared/expression.zod.ts, the tmpl docblock: the notify bullet that prescribed {record.x} is gone. The {{record.x}} bullet says that since protocol 18 a notify node's title / message read {{record.x}}, and that a single brace is refused at every door.
    • (2) In the same file, TYPED_EXPRESSION_SOURCE_REQUIRED's docblock says the notify slots prescribe the same {{ }} hole.
    • (3) template-expression-input-canon.test.ts holds the three docblocks with one predicate. The spec-wide sweep found no other docblock or describe that prescribes {var} for a notify, screen or end text slot.
    • (4) packages/lint/src/validate-flow-template-paths.ts templateRefsIn reads [i] as .i inside a hole, with one finding pin and one no-finding pin.
  • c673c8635: the bracket pin spreads a typed record, so check:test-typecheck holds. This repaired the Type Check · workspace red at 171cea002.
  • b5d3cd532: expression-dialect-docs.pin.test.ts pinned the old tmpl relation, which item (1) necessarily turned red. It now pins the protocol-18 relation.
  • 0788b58e1 / 5bfa9ae1f: origin/main 3599fef12 merged via os-regen-merge.sh. The only conflict was service-automation engine.ts's import block, resolved as the union. The regeneration restores main's builtinNodeConfigKeysJudged export beside this branch's text-slot exports. Range-diff: commits 1–16 show =. The PR's line multiset is identical before and after the merge (54 files, 2261 lines).
  • Ablations, each restored to the HEAD blob: (1) reddens both tmpl pins (2 failed / 10 passed); (2) 1 failed / 5 passed; (4) 1 failed / 63 passed. The CI repro (lint test typecheck) exits 1.
  • Readings at 5bfa9ae1f, after a full build (73 / 73):
  • CI at 5bfa9ae1f (seat note 6067723298):

Generated by Claude Code

claude added 13 commits October 8, 2026 12:24
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added 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

45 anchor(s) derived from 4 changed package(s); no hand-written page names any of them. ⚠️ 8 changed file(s) yielded no anchor (packages/services/service-automation/README.md, packages/spec/api-surface/automation.json, packages/spec/dropped-refinements.baseline.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
  • 8 changed file(s) yielded no anchor (packages/services/service-automation/README.md, packages/spec/api-surface/automation.json, packages/spec/dropped-refinements.baseline.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 05c7c3fa3b074e00e8cb60c70c15944228d3c0eb → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 05c7c3fa3b074e00e8cb60c70c15944228d3c0eb

⚠️ 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: 73b682847b6ac2b4be13e9a650eb0ea13bf45794
Local-runs: none

Inputs, and nothing else: card #22110 (body and all eight comments), PR #22315 (body, the 51-file list, the net diff dc4a5c630..73b682847, +1666 / -374), and the check-runs on the head, read twice; the second reading is the one recorded under ③. The branch head fetched into a review ref is the PR head. Governing text re-read at origin/main: ADR-0032 Decision 3 and ADR-0087 D2 / D3. Rendered at 2026-10-08T15:41Z by an isolated subagent of the seat session named below; the dispatching seat's own conclusions were not an input.

① Derived judgments

  1. NotifyConfigSchema title / message (bare string, or a template envelope's source): the accept set narrows — every {…} token the 17.x interpolator substituted is refused at the key, inside the existing superRefine, through the one judge textSlotTemplateRefusal; {{ }} holes, a $-named path and a formatter pass. Right. ADR-0032 §3's representation table names notify.title/body as the template role, the refusal leads with one sentence and names the hole spelling of each token (§1d), and the pins in io-node-config.test.ts cover both spellings.
  2. ScreenConfigSchema title / description: a new root superRefine with the same judge; recordId, defaults and a field defaultValue keep the single brace (each hands a resolved value over, not text — pinned). Right. The one hand-edited ledger line (dropped-refinements.baseline.json, automation/ScreenConfig root, 679 to 680) is the ratchet's own designed route — its header: a new gap has to be a reviewed line in a diff — and matches the NotifyConfig / EndConfig precedent and the sibling growth in [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939's landing. Reviewed here: right.
  3. EndConfigSchema message on a refused outcome: the same judge, also reached at FlowSchema.parse (pinned at nodes.1.config.message). Right.
  4. Value slots untouched. flow-value-slot-template.ts moves its token grammar onto the package-internal flow-template-token.ts (not exported from automation/index.ts); the regexes move byte-identical and valueSlotTemplateRefusals still returns nothing for the kept kinds (pinned). Right, and keeping that grammar off the barrel is right: a published copy of a retired dialect would be new public surface.
  5. The two doors. registerFlow (engine.ts) and validateStackExpressions run the judge on every slot flowNodeTextSlotSources finds and then compile it with validateExpression('template', …), at error. Both validateStackExpressions and lintFlowPatterns are registered with commands: ALL in authoring-rules.ts, so objectstack validate, build and lint all refuse the single brace — the H4 exemption in flow-double-brace-interpolation loses no command its signal. Right (ADR-0032 §1a).
  6. Public surface, @objectstack/spec/automation: +6 exports (FLOW_NODE_TEXT_SLOTS, FlowNodeTextSlot, FlowNodeTextSlotSource, TEXT_SLOT_TEMPLATE_REFUSAL, flowNodeTextSlotSources, textSlotTemplateRefusal), api-surface/automation.json and export-origins/automation.json regenerated to match. Right: one judge shared by three contracts, the engine and lint has to be public.
  7. @objectstack/formula PATH_ONLY_RE admits $: a hole-grammar widening; an unbound $ path renders nothing; buildScope assigns extra last, so a flow variable overrides a built-in root. Right, at the producer, outside the claim's surface and declared by the seat. The dev's note that extra.locale sets the formatters' locale is read off the engine and is accurate.
  8. @objectstack/service-automation: interpolateText deleted; renderTextSlot, textTemplateScope and FlowTextTemplateError added. All package-internal — src/index.ts and builtin/index.ts re-export nothing from builtin/template.ts, and the exports map is . alone — so this package's public surface does not move; what publishes is the renderer change. Right that the changeset names no removed export. One renderer for the three text slots keeps the service-automation: honour outcome: 'refused' on the flow end node — a terminal refused run status (distinct from failed) with the interpolated message persisted on the run (lane 2 of the #14945 ruling 2′) #15788 ruling; textTemplateScope never writes into a variable's own object and the declared-variable precedence is pinned. Right.
  9. NOTIFY_TEMPLATE_PLACEHOLDER flips to {{ record.name }}, and the rider's three items (6048446951) are all delivered: the placeholder, the two names added to EXPRESSION_SLOT_TYPES, the stale TemplateExpressionInputSchema comment in notify-node.ts rewritten. Right.
  10. Lint: flow-double-brace-interpolation exempts the five text slots, its hint names them, and a text-slot bare-$ check outside the holes prescribes the hole. Right as built.
  11. No D2 conversion; D3 semantic entry flow-text-slot-single-brace-refused plus the step-18 rationale fragment (order 89). Right. D2's scope is losslessly mappable changes only, semantic changes excluded; the measurement is pinned in text-slot-template.test.ts (a Date renders JSON-quoted under 17.x and as ISO text under the engine; a whole-slot object renders [object Object] against JSON), and a metadata conversion cannot know a variable's run-time type. The card's second pin reads with its own "when lossless" qualifier: refused with the exact rewrite.
  12. In-tree sites. All 29 example strings, the docs, the dogfood fixtures and the registering test fixtures are rewritten. A residue sweep at the head (git grep over ts / mdx / md / json / yaml, non-test and test files, outside the diff) finds no single-brace text-slot site left, except the Tier H skill text named under ③. Right.
  13. Wrong — canon. packages/spec/src/shared/expression.zod.ts:402-403, inside the TemplateExpressionInputSchema docblock this very diff edits, still closes with: write the spelling the slot's renderer reads — {{var}} on this schema's slots, {var} on a notify node's. That sits seven lines under the bullet the diff rewrote to say the opposite, and it ships as the hover text of the spec tarball's .d.ts. ADR-0032 Decision 4, the same ADR this card executes: fix the canon first — remove every anti-pattern from the spec's own JSDoc before shipping the contract, the model emits what it is shown. One-line fix: drop the {var} clause.
  14. Wrong — an edge of the one judge. textSlotTemplateRefusal on a hole with exactly one stray brace — Total {{ amount } or { amount }} — reads the inner { amount } as a single-brace PATH token (singleBraceTokens drops a token only when BOTH neighbours are braces) and prescribes Total {{{ amount }} / {{ amount }}}. The engine then refuses that as an invalid hole (loud), but the prescription is a wrong correct-form, and ADR-0032 §1d makes the message contract part of the interface. Every door prints it. Fix: a token touching exactly one stray brace is an unbalanced hole — skip the token refusal so the compile step reports it; the pinned Total {{ amount and the balanced cases are unaffected.

② Semver level

  • Changeset .changeset/22110-flow-text-slot-double-brace.md: @objectstack/spec major, @objectstack/service-automation major, @objectstack/lint major, @objectstack/formula minor; Clause-②: yes (narrowing) in the fixed spelling; ADR-0087 marker registered flow-text-slot-single-brace-refused, which names the D3 entry the diff adds. No skip-changeset. Check Changeset is green on the head.
  • The Clause-②: line agrees in all three carriers. The claim's line was corrected to yes (narrowing) by the seat (6063190355); the PR body carries yes (narrowing: …), and clause2-line.mjs's readArmToken takes the first word inside the parenthetical, so it reads narrowing — the same reading the dev's --event run reported.
  • yes is right: the public face widens (+6 spec exports; {{ }} holes now render in five text slots; the formula hole grammar admits $). (narrowing) is right: three node contracts' accept set shrinks on a published authoring surface. The reader's own description of yes (narrowing) — a diff that widens one surface and narrows another, both facts true and both read — is this diff.
  • major on the v18 line is right. .changeset/pre.json on main is mode: pre, tag: next, so the no-major guard stands down for the pre window and a breaking narrowing grades major (18.0.0-next); byte-for-byte the grading of [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939's landed 19939-flow-value-slot-template-dialect-refused.md (spec / service-automation / lint major, yes (narrowing)). formula: minor for a pure widening is right. The breaking body carries the FROM / TO table, the one-line fix and the four token kinds' remedies, as a breaking changeset must.
  • Prose nit, no grading effect: the changeset and PR body cite renderTextSlot as if it were a public symbol of @objectstack/service-automation; it is package-internal (①.8).

③ Boundary flags

  • open_questions A / B — A, judged independently of the seat: ADR-0087 D2 admits only lossless mappings and the Date / whole-slot rows are pinned in the diff; B would need a maintainer ruling that accepts a rendering change, and nothing pulls for one. Answered; not escalated.
  • Deviations, each: the Clause-② parenthetical — resolved by the seat's correction and the changeset's line (②); the file surface beyond the claim — each a producer measurement showed (formula $, the two new spec modules, the screen / end contracts, the engine's end render point, the dogfood ledger, common-patterns.mdx, the README): accepted; H4 as an exemption: accepted (①.5, ①.10); no D2 entry: right (①.11); the background-build and lock-queue narration: process, no effect on the diff; two os-regen-merge.sh merges with the regeneration as its own commit, and Type Check · source gates green on the head: right; worktree cleanup: not a review matter.
  • out_of_scope_findings, each: skills/objectstack-automation/SKILL.md:108-109 and :224-236 plus evals/flows-triggers-approvals.json:17 still teach the single brace (confirmed at the head) — Tier H, out of this PR's reach, and the seat files that card on landing (recorded); the Studio editors for these slots — triage's objectui round, as the card names; hotcrm's 69 tokens — the repo:hotcrm seat; AutomationEngine.celScope nesting into a variable's object — pre-existing, no defect reproduced, not this card; the README's stale field-value row — pre-existing since [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939, not this card.
  • Escalated — needs a carrier, not 承接者:无: validate-flow-template-paths skips a hole carrying a formatter. Verified: its token reader at :203 is /\{([^{}]+)\}/g, which captures the inner text of a {{ }} hole, and a | then fails the path parse silently, so {{ record.subjcet | upper }} draws no finding while the bare hole does (the diff pins only the bare one). This blind spot is NEW: the single-brace dialect had no formatter syntax, so until this change every path in a text slot was judged. A typo in a formatted hole passes objectstack validate and renders empty at run time with no log — a metadata-authoring trap under Prime Directive chore: version packages #10, which takes a filed card. The seat files it (lint lane); not blocking for this PR.
  • Checked and not a gap: validate-field-consumers reads record.x inside a {{ }} hole through its .x reference shape, and its template-range scan matches the inner { record.x }, so a notify text consumer is still credited under the new spelling.
  • Serial: [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939 pass 1 (0e9e7b706) is an ancestor of the merge base dc4a5c630; the pass-2 overlap is declared serial by the claim. Right.
  • Governed surfaces: none in the file list (Governed Surface Queue Guard green); 2040 changed lines, under the 5000 threshold; head repo is the base repo.
  • Check-runs on the head, second reading: 19 success — among them Build Core, Governed Surface Queue Guard, Check Changeset, Check PR Size, Spec property liveness, Type Check · consumer gates, Type Check · source gates, Type Check · debt ledger, Dogfood Regression Gate (2/3), Dogfood Verify CLI, Build Docs; 2 skipped (Console Pin Gate, Packed-tarball smoke); 0 failed; and 11 still in_progress, named: Lint & Repo Gates, Test Core (1/6), (2/6), (3/6), (4/6), (5/6), (6/6), Dogfood Regression Gate (1/3), (3/3), Temporal Conformance (live PG + MySQL), Type Check · workspace. A run in progress is not a pass: five of the seven required contexts had not concluded on this head at this reading, and they were not waited for.

Implemented-by: claude/issue-22110-flow-text-slot-double-brace
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: FAIL

Two reasons, both in the diff's own text, both one-line: ①.13 (the stale {var} sentence in expression.zod.ts:402-403, against ADR-0032 D4) and ①.14 (the wrong prescription for a one-stray-brace hole in textSlotTemplateRefusal, against ADR-0032 §1d). Every other judgment above holds, and the semver reading stands as written. A patch round touching those two files with their pins, green check-runs, and a record on the new head is what stands between this diff and PASS. The escalated lint blind spot is a filed card, not a condition on this PR.


Generated by Claude Code

claude added 2 commits October 8, 2026 15:51
…formatted-hole paths, changeset wording (#22110)

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0b094026dcb0c8fef22e0572d510a8a8c9d4d8e7
Local-runs: none

Inputs, and nothing else: card #22110 (body and all eleven comments, the two rulings 6063190355 / 6063702421 and the earlier record included), PR #22315 (body, the 53-file list, the net diff 4e4111ca0..0b094026d, +1792 / -380, and the round-2 diff e899a2c81..0b094026d, 8 files, +130 / -10), the earlier ## Contract review on this PR (6063558021, FAIL at 73b682847b), and the check-runs on the head, read at 2026-10-08T17:20:50Z. The PR head was fetched into a review ref of its own and read with git show / git grep; the merge base is the PR's base sha. Governing text re-read on origin/main: ADR-0032 Decisions 1a / 1d / 3 / 4 and the representation table, ADR-0087 D2 / D3, AGENTS.md Prime Directives #10 / #12 / #14 and the changeset rules. This is a review of the WHOLE PR at this head, rendered by an isolated subagent of the seat session named below; the dispatching seat's own conclusions were not an input.

① Derived judgments

The earlier record's two FAIL reasons, at this head:

  1. ①.13 of 6063558021 — resolved. packages/spec/src/shared/expression.zod.ts:402-403, the closing sentence of the TemplateExpressionInputSchema docblock, now reads: write the spelling the slot's renderer reads, {{var}} on this schema's slots and on a notify node's title / message alike. The {var} clause is gone. Pinned by the new packages/spec/src/shared/template-expression-input-canon.test.ts, a source-text read of exactly that docblock (same package, seeded from import.meta.url, so check:cross-package-test-inputs is untouched): no notify sentence names a single-brace {var} unless it says "refused". Right — for that docblock. See ①.17 and ①.18 for the two sentences the pin's scope does not reach.
  2. ①.14 of 6063558021 — resolved. packages/spec/src/automation/flow-text-slot-template.ts:121-124 singleBraceTokens now drops a token that touches a brace on EITHER side, so Total {{ amount }, Total { amount }}, {{x} and {x}} draw no token refusal (pinned: the judge returns undefined for all four) and the compile step every door runs next reports the unbalanced hole. Pinned at the objectstack validate door (validate-expressions.text-slot.test.ts): one finding, rule expression-invalid, severity error, where naming notify title at config.title, message containing "unbalanced", NOT led by TEXT_SLOT_TEMPLATE_REFUSAL, no three-brace text, and the compile code invalid-template. A genuine token beside a stray brace (Total {{ amount } by {owner}) still gets its own rewrite with nothing three-braced (pinned). Right. The earlier wrong correct-form is gone. Observation, not a gap: both doors continue past the compile step once the judge refuses (engine.ts:10700, validate-expressions.ts:1767), so for that mixed text the unbalanced hole surfaces on the author's second run; the sentence the first run prints is correct about the token it names, which is what ADR-0032 §1d asks. Running the compile step regardless would land both findings in one round — optional, no finding.
  3. The escalated flag of 6063558021 — fixed in-round, and right. packages/lint/src/validate-flow-template-paths.ts:214-220 templateRefsIn: inside a {{ }} hole (a brace on both sides of the match) the path before an optional | formatter is judged like a bare one; in a single-brace token a | stays unjudged as before. Pinned in both directions (Case {{ caseRecord.subjcet | upper }} draws flow-template-unknown-field; {{ caseRecord.subject | truncate:'40' }} draws nothing). The docblock now describes both dialects, which is accurate: the interpolator was not deleted — only interpolateText was — and at this head interpolate / interpolateString / interpolateFilter still render every value-like position (the call sites: crud-nodes filter and fields, http whole config, logic-nodes assignment values, loop / map collection and map input, notify recipients / sourceObject / sourceId / templateData / actionUrl / payload / actorId, screen recordId / defaults / field defaultValue / script inputs, subflow input). The seat's own correction (6065269979) says the same.
  4. The wording fix — right. The changeset no longer names renderTextSlot / FlowTextTemplateError as if public; it says "one renderer inside @objectstack/service-automation (package-internal; the package's public exports do not change)" — true: the package's exports map is . alone and neither src/index.ts nor builtin/index.ts re-exports anything from builtin/template.ts. Its new sentence "a hole that does not compile fails the node with a guard refusal, which a fault edge does not route" is true at this head: FlowTextTemplateError calls markGuardRefusal(this) (template.ts:372-383), and engine.ts:11146 sets faultEdge to undefined when isGuardRefusal(execErr) and rethrows. The PR body carries the same correction plus a ## Patch round 2 section, written by the seat as the role file requires.

The whole PR's accept-set and public-surface changes, each confirmed at this head:

  1. NotifyConfigSchema title / message (bare string or template envelope source): narrowed — every {…} token the 17.x interpolator substituted is refused at the key inside the existing superRefine through the one judge textSlotTemplateRefusal; {{ }} holes, a $-named path and a formatter pass (io-node-config.zod.ts:396-416, pinned in io-node-config.test.ts). Right: ADR-0032 §3 names notify.title/body as the template role; the refusal leads with one sentence and names the hole spelling of every path token (§1d).
  2. ScreenConfigSchema title / description: narrowed by a new root superRefine with the same judge; recordId, defaults and a field defaultValue keep the single brace (each hands a resolved value over, pinned). Right. The one hand-edited ratchet line (dropped-refinements.baseline.json, automation/ScreenConfig root site, 679 to 680) is the ledger's designed route for a reviewed new gap and matches the NotifyConfig / EndConfig precedent — reviewed here, right.
  3. EndConfigSchema message on a refused outcome: the same judge, also reached at FlowSchema.parse (pinned at nodes.1.config.message). Right.
  4. The value slots are untouched. The token grammar moved byte-identical from flow-value-slot-template.ts into the package-internal flow-template-token.ts (absent from automation/index.ts, and rightly: a published copy of a retired dialect would be new public surface); valueSlotTemplateRefusals still keeps the date macros and $User (pinned). Right.
  5. The two doors. registerFlow (engine.ts:10688-10708) and validateStackExpressions (validate-expressions.ts:1756-1774) run the judge on every slot flowNodeTextSlotSources finds — nested region nodes included, by the region-prefixed where — then compile each with validateExpression('template', …) at error. Right (ADR-0032 §1a); the H4 exemption in flow-double-brace-interpolation loses no command its signal, since validateStackExpressions runs under every command.
  6. @objectstack/spec/automation public surface: +6 exports (FLOW_NODE_TEXT_SLOTS, FlowNodeTextSlot, FlowNodeTextSlotSource, TEXT_SLOT_TEMPLATE_REFUSAL, flowNodeTextSlotSources, textSlotTemplateRefusal), api-surface/automation.json and export-origins/automation.json regenerated to match. Right: one judge shared by three contracts, the engine and lint has to be public.
  7. @objectstack/formula PATH_ONLY_RE admits $: a hole-grammar widening for every consumer of the template engine; an unbound $ path renders nothing (pinned). Right, at the producer, declared by the seat on the engine lane.
  8. @objectstack/service-automation: interpolateText deleted; renderTextSlot, textTemplateScope, FlowTextTemplateError added — all package-internal, so the public surface does not move; what publishes is the renderer change. One renderer for the five text slots keeps the service-automation: honour outcome: 'refused' on the flow end node — a terminal refused run status (distinct from failed) with the interpolated message persisted on the run (lane 2 of the #14945 ruling 2′) #15788 ruling (pinned byte-identical for end and screen); textTemplateScope never writes into a variable's own object and the declared-variable precedence is pinned. Right.
  9. The slot enumeration holds: exactly five text slots. Measured against the executors at this head (the call-site list in ①.3): every other interpolated position hands its resolved value over with its type, and the approval contract interpolates no text slot (its message: lines are refusal text in addIssue). The examples/app-showcase message: 'Task "{record.title}" is done.' left single-brace at :738 is a subflow.input value, which the child's notify renders as {{ message }} — right.
  10. NOTIFY_TEMPLATE_PLACEHOLDER is {{ record.name }}, and the rider's three items (6048446951) are delivered: the placeholder; templateExpressionInput and cronExpressionInput added to EXPRESSION_SLOT_TYPES; the stale TemplateExpressionInputSchema comment in notify-node.ts rewritten. Right.
  11. No D2 conversion; D3 semantic entry flow-text-slot-single-brace-refused with the step-18 rationale fragment (order 89). Right. D2 admits lossless mappings only; the Date and whole-slot-object rows are pinned in text-slot-template.test.ts, and a metadata conversion cannot know a variable's run-time type. The card's second pin reads with its own "when lossless" qualifier: refused with the exact rewrite.
  12. In-tree sites. All 29 example strings, the docs (flows.mdx's new section and table, common-patterns.mdx, the regenerated references, the README row), the dogfood fixtures and ledger entry, the descriptor help and the registering test fixtures are rewritten. A residue sweep at this head over ts / mdx / md / json, outside skills/** and generated trees, finds no single-brace text-slot authoring site left; the single-brace strings that remain in lint-flow-patterns.test.ts:1516/1554/1579 and validate-flow-template-paths.test.ts are fixtures of other rules' graph walks and of the single-brace reader, never fed to a door; the conversions/registry.ts:1105-1154 pairs are the graduated 11.x flow-node-notify-config-aliases entry's before / after fixtures — history by design, with the 18 D3 entry carrying the remedy. Right.
  13. Wrong — canon, the same class the earlier record failed on (①.13), one file, a second docblock. packages/spec/src/shared/expression.zod.ts:512-515, the docblock of the exported tmpl helper — the helper the notify title / message .describe() names as the way to write the envelope, so its hover is the first thing an author of a notify envelope reads — still says: {record.x} — a notify flow node's title / message are rendered by the flow interpolator, which reads single braces only; a {{record.x}} keeps its outer braces in the sent text, and the build's flow-double-brace-interpolation rule flags it. All three claims are false at this head (the renderer is the template engine, {{record.x}} is the spelling, and the rule now exempts the text slots), and the sentence is a prescription of the refused form. Pre-existing text (merge base :514), outside the round-2 pin's one-docblock scope, in the file this diff edits twice; it ships in the spec tarball's .d.ts. ADR-0032 Decision 4: remove every anti-pattern from the spec's own JSDoc before shipping the contract — the model emits what it is shown. One-line fix: rewrite the bullet (since protocol 18 a notify node's title / message read {{record.x}} too, and a single-brace token there is refused).
  14. Wrong — canon, same file. packages/spec/src/shared/expression.zod.ts:297-299, the docblock of the exported TYPED_EXPRESSION_SOURCE_REQUIRED: "The notify node's title / message, rendered by the flow interpolator, prescribe {record.name} (automation/io-node-config.zod.ts)." False at this head: NOTIFY_TEMPLATE_PLACEHOLDER is {{ record.name }} and the renderer is the template engine. Pre-existing, same class, same fix shape: one sentence (the notify slots take their own sentences so they can name the key; they prescribe the same {{ }} hole). Pin for ①.17 and ①.18: widen template-expression-input-canon.test.ts to read the docblocks above export function tmpl and export const TYPED_EXPRESSION_SOURCE_REQUIRED with the same predicate (a notify sentence naming a single-brace placeholder, not saying "refused"), so the canon in this file is held as one set.

② Semver level

  • Changeset .changeset/22110-flow-text-slot-double-brace.md: @objectstack/spec major, @objectstack/service-automation major, @objectstack/lint major, @objectstack/formula minor; Clause-②: yes (narrowing) in the fixed spelling; ADR-0087 marker registered flow-text-slot-single-brace-refused, naming the D3 entry the diff adds. No skip-changeset. Check Changeset is success on the head, twice (15:57Z, and 17:19Z after the seat's body edit).
  • The Clause-②: line agrees in all three carriers. The claim's line was corrected to yes (narrowing) (6063190355); the PR body's first lines carry yes (narrowing: …), which scripts/pm/clause2-line.mjs readArmToken reads as narrowing — the arm regex takes the first word inside the parenthetical and the colon after it is neither an identifier character nor a pipe; the changeset carries yes (narrowing).
  • yes is right: the public face widens (+6 spec exports; {{ }} holes now render in five text slots; the formula hole grammar admits $). (narrowing) is right: three node contracts' accept set shrinks on a published authoring surface, and objectstack validate refuses what it accepted. major on the v18 line is right: .changeset/pre.json on main is mode: pre, tag: next, so a breaking narrowing grades major (18.0.0-next), byte-for-byte the grading of [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939's landed sibling changeset; formula: minor for a pure widening is right. The breaking body carries the FROM / TO table, the one-line fix and the four token kinds' remedies, as a breaking changeset must, and after round 2 names no package-internal symbol as public.
  • examples/app-showcase, examples/app-todo and packages/qa/dogfood are private: true, so no changeset is owed for them. ①.17 / ①.18 are docblock prose with no grading effect.

③ Boundary flags

  • open_questions: round 2 declares none. Round 1's A / B stands answered A, judged independently: ADR-0087 D2 admits only lossless mappings, the Date / whole-slot rows are pinned, and B would need a maintainer ruling that accepts a rendering change, which nothing pulls for.
  • Round-2 deviations, each: the dev did not edit the PR body — right under the role file, and the seat wrote the body edit (the ## Patch round 2 section and the package-internal wording are there, read at this head); item (2) pinned as "no refusal" at the spec function and by code / path at the lint door — right, the spec has no code of its own for an unbalanced hole; item (1)'s new same-package canon pin — right, and its scope is the gap ①.17 / ①.18 name; the lock-queue and killed-build narration — process, no effect on the diff.
  • Round-2 out_of_scope_findings, each: (a) validate-flow-template-paths skips a hole whose path uses bracket indexing ({{ rows[0].subjcet }}) — escalated: this takes a filed card (lint lane), not "the next PR to edit the file". This PR's own docs (flows.mdx's new section) and changeset teach {{ rows[0].subject }} as a text-slot spelling, so an author following the shipped text reaches it; under 17.x rows[0].x was unresolvable (not a VARIABLE_PATH, and square brackets are outside ARITHMETIC_CHARSET), so no working text-slot path escaped the rule before this change and a documented one does now — the same class the earlier record escalated for formatters. A misspelt field under it passes objectstack validate and renders empty with no log: a metadata-authoring trap under Prime Directive chore: version packages #10. Warning-severity rule, not blocking; one-line fix for the card: inside a hole, normalise [n] to .n before the dotted-path test. (b) "the order said the interpolator is deleted; only interpolateText was" — answered, right (①.3).
  • Round-1 flags, standing: skills/objectstack-automation/SKILL.md :108-109, :224-236 and evals/flows-triggers-approvals.json:17 still teach the single brace (confirmed at this head) — Tier H, out of this PR's reach under Prime Directive feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14; the seat's card. Noted for sequencing: authoring stays open to every seat, so that skills PR can be opened now and the maintainer's word requested in the next batch, which is how ADR-0032 Decision 4's "skills … before shipping the contract" is met before the 18 line leaves pre mode. The Studio editors — triage's objectui round; hotcrm's 69 tokens — the repo:hotcrm seat; AutomationEngine.celScope nesting and the README's stale field-value row — pre-existing, not this card.
  • Serial: [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939 pass 1 is an ancestor of the merge base; the pass-2 overlap is declared serial. Right.
  • Governed surfaces: none in the 53-file list (Governed Surface Queue Guard success); 2172 changed lines, under the 5000 threshold; head repo is the base repo; draft; mergeable clean against main at the reading.
  • Check-runs on 0b094026d, read at 17:20:50Z: 42 runs, all completed — 38 success, 4 skipped (the re-run Auto Label and Check PR Size after the body edit, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failed, 0 in progress. The seven required contexts all success: Lint & Repo Gates, TypeScript Type Check, Test Core (and its six shards), Dogfood Regression Gate (and its three), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Their conclusions are the gate verdicts; nothing was run locally.

Implemented-by: claude/issue-22110-flow-text-slot-double-brace
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: FAIL

Two reasons, one file, both one-sentence: ①.17 (packages/spec/src/shared/expression.zod.ts:512-515, the tmpl docblock still prescribing {record.x} for a notify title / message) and ①.18 (:297-299, the TYPED_EXPRESSION_SOURCE_REQUIRED docblock still saying the notify slots prescribe {record.name}), both against ADR-0032 Decision 4 and both the class the earlier record failed on; the round-2 pin holds one docblock and these are the other two in the same file. Both earlier FAIL reasons and the escalated lint flag are resolved at this head, the semver reading stands, and the check-runs are green. A patch round rewriting those two sentences, widening the canon pin to those two docblocks, green check-runs and a record on the new head is what stands between this diff and PASS. The bracket-index blind spot is a filed card, not a condition on this PR.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI on f84cd82d2 (the merge-main round after #22014 closed): Lint & Repo Gates is red on a repo-wide gate, not this PR · domain:spec seat 2 (#18549) · session session_01DhTqaEHqPVSVnAkjG3jywn · 2026-10-09T05:31Z.


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ULE_ID` for the reasoning (objectstack-ai#22339)

Part of objectstack-ai#22161
Clause-②: yes (widening: `os explain` accepts a rule id, and
`packages/lint` exports the rule explanations)

## What changes

- **`field-no-consumers` and `security-owd-unset` print one verdict
sentence and one fix.** Their long reasoning moves into one static
explanation per rule id, `RULE_EXPLANATIONS` / `explainRule()` in
`@objectstack/lint` (new module
`packages/lint/src/rule-explanations.ts`, exported from the root barrel
and from a new import-free entry,
`@objectstack/lint/rule-explanations`). Nothing else carries a copy.
- **`os explain RULE_ID`** (the maintainer's spelling, one positional,
no `rule` sub-word): a schema name resolves exactly as before; otherwise
an exact rule id resolves to its explanation (`--json` prints `{ rule,
covers, paragraphs }`). The no-argument listing also names the rule
explanations (`--json` adds `rules: [{ id, covers }]`). An unknown id
exits 1 and names both lists.
- **The `rule:` line carries the pointer, spelled once** —
`explainPointer()` / `authoringFindingDetailLines()` in
`packages/cli/src/utils/format.ts`, used by the build advisory printer,
the gating-error printer (validate, build, verify, init), the validate
advisory list, and `os lint`'s rule line. It appears only for a rule id
the table holds, so it never names a command that would answer
"unknown". The hint line is labelled `fix:`.
- **`os explain`'s schema lookup reads own keys only.** `os explain
constructor` / `__proto__` printed `Schema: Object … undefined` and
threw `schema.required is not iterable` on main. They are now refused as
unknown ids (`6d2eb857c`; pinned in `test/explain-rule-id.test.ts`; with
the own-key check reverted → 1 failed | 10 passed).
- **The `fix:` line is always a fix** (patch round 2).
`expression-invalid`'s authored source is a quote, not a fix, so it now
ends the finding's `message` as `` — source: `…` `` and its `hint` is
empty: the CLI prints no `fix:` line for it, and the source still
reaches the text face and the runtime 422 issue. Four other hints that
carried no instruction now open with one: `component-props-invalid` (its
consequence moved into the message),
`flow-time-relative-descriptor-invalid`, `react-prop-missing-required`
(the contract-description branch), `liveness-experimental-property`.

The maintainer's shape, as `os validate` and `os build` now print it on
a tutorial-shaped project:

```text
  ⚠ object "my_app_ticket" · field "description": declared, but nothing in this stack displays or reads it (inert)
    fix: add it to a view column or a form section, or remove the declaration
    rule: field-no-consumers  at objects[1].fields.description — `os explain field-no-consumers` for what counts as a consumer
```

```text
  • object "my_app_ticket": custom object declares no sharingModel (OWD); the runtime falls back to 'private', but the baseline must be an authored decision
      fix: declare sharingModel: 'private' (owner + shares; recommended), 'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children)
      rule: security-owd-unset  at objects[1].sharingModel — `os explain security-owd-unset` for why the baseline must be declared
```

## Measured (local CLI built from this branch; tutorial-shaped project:
`my_app_note` + `my_app_ticket`, a grid view on `title`/`status`)

| | before (`59d993c97`) | after |
|---|---|---|
| `os validate`, `field-no-consumers` | one line, 852 chars; no fix, no
rule id | 114 / 77 / 126 chars (verdict / fix / rule) |
| `os build`, `field-no-consumers` | 852 + 692 + 62 chars | 114 / 77 /
126 |
| `os validate`, `security-owd-unset` | 374 + 175 + 58 chars | 156 / 160
/ 130 |
| one `os dev --compile` run | printed once (the compile child), not
again at serve | printed once, same shape |

- **H1 holds:** the message and the build-time "Give … a consumer" text
(the finding's `hint`) are built in
`packages/lint/src/validate-field-consumers.ts`. `os validate` printed
the registry advisory as its `⚠` line only (`commands/validate.ts`),
with no fix and no rule line; `os build` printed message, hint and rule
line through `printAuthoringAdvisories`.
- **H2 holds, with one addition:** the `rule:` line is the CLI
printer's, not the rules' (`utils/format.ts`, two printers). The pointer
is spelled there once. `os validate`'s advisory list had no rule line at
all, so it now renders the same two lines through the same helper
(below).
- **H3 holds:** 16 `os explain` schema names, 214 rule id constants
exported from `packages/lint` — intersection empty (no rule id is a
single word). Pinned in `packages/cli/test/explain-rule-id.test.ts`
(lowercased, against every exported rule id constant).
- **H4 does not hold:** one `os dev --compile -p PORT --fresh` run
printed the warning once (`grep -c field-no-consumers` = 1, before and
after). No printer change was made for it.
- **H5:** far more than 8 over-long rules (below), so this PR builds the
mechanism and shortens `field-no-consumers` and `security-owd-unset`
only. The dead-button `action-governance` line is not an author-time
rule: it is the boot-time `logger.warn` in
`packages/objectql/src/action-governance.ts` (`[action-governance]
declared script actions with NO handler …` — 163 chars as the source
writes it, plus a `{count, actions}` payload; its sibling "registered
handlers with NO declaration" line is 628). It lives outside
`packages/lint` and outside the CLI printer, so it is named here and not
edited.

## Landing outside the claim's file surface, and why

- `packages/cli/src/utils/format.ts` — the H2 printer:
`explainPointer()` and `authoringFindingDetailLines()`; both printers
render through them.
- `packages/cli/src/commands/validate.ts` — measured: `os validate`
printed a registry warning with no fix and no rule line, so a shortened
message would have reached the maintainer's first-named command with no
pointer. The text face now prints the two lines under each registry
advisory via the same helper. The `warnings` list `--strict` and
`--json` read is unchanged.
- `packages/cli/src/commands/lint.ts` — `os lint` prints the same
shortened message; its rule line gains the same pointer (one call to
`explainPointer`).
- `packages/lint/package.json`, `packages/lint/tsup.config.ts`,
`packages/lint/src/rule-id-barrel-exports.test.ts` — the new
`./rule-explanations` entry. `format.ts` is documented as "a pure
formatter with no rule-engine import", and every command imports it;
loading the `@objectstack/lint` root barrel after `@objectstack/spec`
measured 456–547 ms (three runs), which every command (`os explain
object` included) would otherwise pay. The entry's module imports
nothing (pinned by a source scan); its keys and the `field-no-consumers`
roots list are literals held to the rule's constants by
`rule-explanations.test.ts`.
- `packages/cli/README.md` — the `os explain` row.
- Tests updated for the new text:
`packages/cli/src/utils/author-time-rules.test.ts` (read the field from
`where`, not `message`),
`packages/cli/test/truncation-remainder-notices.test.ts` (`fix:` label),
`packages/cli/test/validate-build-gate-parity.test.ts` (classifies
`authoringFindingDetailLines` as presentation). No test outside
`packages/lint` / `packages/cli` pins either old message.

## Tests, round 1 (all local, this branch; head `315a26618` unless a run
names another; round 2's readings are under `## Patch round 2`)

New pins: `packages/lint/src/rule-explanations.test.ts` (every key is an
exported rule id under its own key; `covers` fits the pointer; no
tracker number in the text; the roots paragraph equals `CONSUMER_ROOTS`
/ `CARRIER_ROOTS`; exact-id lookup; the module imports nothing), the
shape pins in `validate-field-consumers.test.ts` and
`validate-security-posture.test.ts` (verdict line and fix line, exact),
`packages/cli/test/explain-rule-id.test.ts` (H3 disjointness; every
pointer target resolves through `Explain.run`; the printed verdict /
`fix:` / `rule:` lines of each rule's REAL finding; schema lookup
unchanged; unknown id exits 1), and
`packages/cli/test/rule-line-explain-pointer.e2e.test.ts` (spawns `os
validate` and `os build`; a `*.e2e` file, so the nightly tier).

- `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` →
`Test Files 128 passed (128)`, `Tests 5853 passed (5853)` (at
`ed786eb3e`; no lint file changed after it).
- `pnpm --filter @objectstack/lint run typecheck` → exit 0,
`check:test-typecheck: OK — … 2 file(s) / 6 error(s) / 2 pinned
signature(s) held`.
- `pnpm --filter @objectstack/cli run typecheck` → exit 0,
`check:test-typecheck: OK — … 3 file(s) / 28 error(s) / 6 pinned
signature(s) held` (at `315a26618`).
- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2 --shard=N/3` (the full unit tier, in three foreground
shards because one run exceeds the container's foreground cap under
load): shard 1 `89 passed` / `1523 passed` and shard 2 `88 passed, 1
failed` (at `ed786eb3e`); shard 3 `89 passed` / `1264 passed` (at
`315a26618`). The shard-2 failure was
`src/utils/author-time-rules.test.ts` reading the field name from
`message`; fixed in `315a26618` and re-run with
`test/lint-per-package-authoring-seam.test.ts` → `2 passed` / `10
passed`.
- `--project integration` (declared to CI as a whole), the ten files
that spawn validate / build / verify / lint and read their text:
`test/build-text-face-advisory-count`, `verify-author-time-stage`,
`validate-per-package-authoring-parity`, `union-fold-command-parity`,
`authoring-rule-command-parity`, `validate-view-container-name`,
`build-view-container-name`, `picklist-reference-doors`,
`lint-per-package-authoring-parity`,
`validate-lint-mapping-connector-source` → `Test Files 10 passed (10)`,
`Tests 62 passed (62)`.
- `OS_TEST_TIERS=nightly … vitest run
test/rule-line-explain-pointer.e2e.test.ts` → `2 passed`;
`validate-json-warning-parity.e2e.test.ts` (the `⚠` line still pairs
with `--json`) → `3 passed` (both on the `ed786eb3e` tree).
- Ablation (one-shot, nothing kept): `scripts/ablation-replace.mjs`
replaced `explainPointer`'s return with `''` in
`packages/cli/src/utils/format.ts` (anchor 1 → 0, blob `9d90c98c409d` →
`4427f41a866d`), `test/explain-rule-id.test.ts` → `3 failed | 7 passed`;
restored, blob `9d90c98c409d` == HEAD, `git diff HEAD` empty.
- Cross-package type read: `packages/cli` builds against
`@objectstack/lint/rule-explanations`, an entry that exists only in the
rebuilt `dist/` (`dist/rule-explanations.{js,cjs,d.ts,d.cts}`), so the
CLI build read the rebuilt declarations. CJS `require` and ESM `import`
of the entry both load (`['field-no-consumers', 'security-owd-unset']`).
- ESLint, narrowed to the diff: `npx eslint --no-inline-config --format
json` over the 18 changed `.ts` files → 18 files in the JSON report, 0
errors, 0 warnings; `eslint.config.mjs` never enables type-aware linting
(no `parserOptions.project`, its own comment at `:327`), so this diff
cannot move a verdict on an untouched file. Repo-wide `pnpm lint` is
CI's.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands` (no paths) at
`315a26618` derived 78 commands, a superset of the 51 at dispatch. Ran
all 78: 76 exit 0; `pnpm check:dual-build-cjs-loads` and `pnpm
check:i18n-coverage` exit 3, PREREQUISITE NOT MET (packages outside the
CLI's build closure have no `dist/` in this worktree) — NOT MEASURED,
CI's. Reconciliation: `✓ dispatch-gates --ran: 78 derived famil(ies)
accounted for — 76 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit
3).` Also run, exit 0: `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity` (the three
artifact-roster gates whose roster sits under `packages/`),
`check:published-readme-exports`, `check:published-readme-links`,
`check:cli-examples-parity`. Control-character scan over every changed
file: no match.

## Second stage — the over-long rules this PR does not shorten

Measured by running the whole `packages/lint` suite at `59d993c97` (127
files, 5843 tests) with a scratch hook that recorded, per rule id, the
longest `message` of any finding pushed: 240 rule ids fired, 157 with a
message over 200 characters. Lengths are the `message` alone (the
printed line adds `where` and `: `). A rule id that fired in no test is
not in this count.

**Second stage, in `packages/lint` (not touched here) — 124 rule
id(s):**

- `validate-rls-predicate-enforceability.ts`:
`rls-predicate-unparseable` 2056, `rls-predicate-unenforceable` 1679,
`rls-predicate-unknown-user-variable` 1544,
`rls-predicate-unknown-field` 1399, `rls-predicate-over-budget` 1259
- `validate-sharing-rule-enforceability.ts`:
`sharing-rule-unlowerable-condition` 1093,
`sharing-rule-object-not-shareable` 774,
`sharing-rule-object-controlled-by-parent` 660,
`sharing-rule-runtime-variable-condition` 492
- `validate-rule-schema-formats.ts`:
`validation-rule-json-schema-unknown-format` 1049
- `validate-action-dispatch-contract.ts`:
`action-dispatch-contract-mismatch` 927
- `validate-component-props.ts`: `component-props-invalid` 920,
`component-props-unknown-key` 844
- `validate-sortable-fields.ts`: `sort-field-unprovisioned` 844,
`sort-field-unsortable` 369, `sort-field-unknown` 287
- `validate-dataset-measure-aggregates.ts`:
`measure-aggregate-field-type-refused` 809,
`dimension-json-stored-field-refused` 531
- `validate-component-types.ts`: `component-type-unknown` 807
- `validate-flow-trigger-readiness.ts`:
`flow-time-relative-descriptor-invalid` 796,
`flow-time-relative-descriptor-unroutable` 532,
`flow-trigger-unroutable` 518, `flow-api-trigger-secret-missing` 336,
`flow-trigger-unknown-event` 222
- `validate-hook-body-writes.ts`: `hook-body-write-unprovisioned-anchor`
792, `hook-body-write-unknown-field` 465, `hook-body-source-unparseable`
212
- `validate-react-page-props.ts`: `react-chart-drilldown-invalid` 784,
`react-chart-aggregate-invalid` 507, `react-chart-field-unprovisioned`
429, `react-block-needs-record-context` 267,
`react-page-source-unparseable` 211
- `validate-action-body-writes.ts`:
`action-body-write-unprovisioned-anchor` 778,
`action-body-write-unknown-field` 433, `action-record-write-discarded`
293, `action-body-source-unparseable` 212
- `validate-flow-node-writes.ts`: `flow-node-write-unprovisioned-anchor`
739, `flow-node-write-unknown-field` 421
- `validate-security-posture.ts`:
`security-controlled-by-parent-ambiguous-relation` 697,
`security-fls-unknown-field` 593,
`security-controlled-by-parent-no-relation` 526,
`security-master-detail-ungranted` 449, `security-owd-alias` 354,
`security-delegation-missing-reason` 210
- `validate-preset-comparands.ts`: `filter-preset-comparand` 669
- `validate-visibility-predicates.ts`:
`visibility-predicate-unknown-function` 655,
`visibility-predicate-over-budget` 507, `visibility-bare-identifier`
411, `visibility-predicate-syntax` 341, `visibility-root-mislayered` 304
- `validate-predicate-path-refs.ts`: `predicate-rhs-path-shaped` 648,
`predicate-path-unrooted` 434, `predicate-path-unresolved` 371
- `validate-page-visualization-bindings.ts`:
`page/visualization-without-binding` 629
- `validate-translatable-sections.ts`:
`translation-section-name-missing` 553
- `validate-nav-object-servability.ts`: `nav-object-unservable` 528
- `validate-searchable-fields.ts`: `searchable-field-unprovisioned` 512,
`searchable-field-unsearchable` 449, `searchable-field-unknown` 295
- `validate-widget-bindings.ts`: `dashboard-filter-field-unprovisioned`
509, `chart-field-unknown` 407, `dashboard-filter-field-not-included`
370, `widget-filter-field-unknown` 364, `dashboard-filter-field-unknown`
333, `chart-dimensions-missing` 306, `widget-filter-field-not-included`
282, `widget-measures-missing` 255, `widget-sortby-unselected` 242,
`chart-measures-missing` 224, `widget-legacy-analytics-unrenderable` 205
- `validate-rule-compilability.ts`:
`validation-rule-json-schema-uncompilable` 489,
`validation-rule-regex-uncompilable` 482
- `validate-page-field-bindings.ts`: `page-field-unprovisioned` 484,
`page-section-group-unknown` 214
- `data-model-rules.ts`: `unique/legacy-organization-composite` 478,
`unique/unscoped-declared-index` 427, `unique/double-declaration` 381
- `validate-mapping-target-fields.ts`: `mapping-target-field-unknown`
466
- `validate-ai-agent-authoring.ts`: `default-agent-legacy-alias` 466,
`default-agent-outside-roster` 388, `agent-authoring-withdrawn` 352
- `validate-readonly-hook-writes.ts`: `hook-api-update-readonly-field`
464, `hook-api-update-readonly-when-field` 267
- `validate-approval-approvers.ts`:
`approval-approvers-may-resolve-empty` 451,
`approval-approver-not-membership-tier` 272
- `validate-dataset-references.ts`: `dataset-field-not-included` 446,
`dataset-field-unknown` 296, `dataset-filter-field-unknown` 294,
`dataset-include-unknown` 260
- `validate-list-view-field-refs.ts`: `list-view-field-dotted` 445,
`list-view-field-unknown` 355
- `validate-chart-bindings.ts`: `chart-measure-unknown` 442,
`chart-axis-not-selected` 327
- `validate-ai-tool-references.ts`: `ai-skill-tool-unresolved` 441
- `lint-view-refs.ts`: `view-ref-nav-view-missing` 437,
`view-key-collision` 257
- `validate-nav-target-refs.ts`: `nav-target-unresolved` 426
- `validate-managed-api-methods.ts`:
`object/managed-api-method-unaffordable` 412
- `validate-readonly-flow-writes.ts`: `flow-update-readonly-field` 410,
`flow-update-readonly-when-field` 317
- `validate-action-name-refs.ts`: `action-name-undefined` 409
- `validate-readonly-action-writes.ts`:
`action-api-update-readonly-when-field` 396
- `lint-flow-credential-literals.ts`: `flow-credential-literal` 390
- `validate-filter-tokens.ts`: `filter-token-unknown` 386
- `validate-print-page-blocks.ts`: `print-page-block-unprintable` 368
- `validate-org-axis-red-lines.ts`: `org-axis-cross-org-bu-grant` 365
- `validate-nav-access.ts`: `nav-object-ungranted` 353
- `validate-empty-combinators.ts`: `filter-empty-combinator` 352,
`filter-empty-node` 226
- `validate-translation-references.ts`: `translation-target-unknown`
345, `translation-option-key-unknown` 230
- `validate-object-references.ts`:
`object-reference-unregistered-platform` 324
- `validate-object-field-refs.ts`: `object-field-ref-unknown` 320
- `validate-seed-state-machine.ts`: `seed-value-outside-state-machine`
320
- `validate-semantic-roles.ts`: `semantic-role-field-unprovisioned` 291
- `validate-view-containers.ts`: `view-container-shape` 290
- `validate-ai-surface-affinity.ts`: `ai-skill-surface-mismatch` 281
- `validate-flow-filter-tokens.ts`: `flow-filter-token-unknown` 278
- `validate-dashboard-action-refs.ts`:
`dashboard-action-route-unresolved` 242,
`dashboard-action-target-undefined` 239
- `validate-retired-permission-residue.ts`:
`permission-retired-lifecycle-residue` 235
- `validate-seed-replay-safety.ts`:
`seed-insert-mode-duplicates-on-replay` 222
- `validate-capability-references.ts`: `capability-reference-unknown`
219
- `validate-form-layout.ts`: `form-section-group-unknown` 214

**Excluded this round — files open PRs objectstack-ai#22268, objectstack-ai#22315, objectstack-ai#22319 edit — 19
rule id(s):**

- `validate-expressions.ts`: `expression-invalid` 2027
- `lint-flow-patterns.ts`: `flow-multi-write-unfiltered` 656,
`flow-decision-mode-invalid` 528, `flow-loop-body-uncontained` 522,
`flow-try-catch-without-catch` 520,
`flow-approval-revise-target-not-service-owned` 366,
`flow-decision-unconditional-branch` 342, `flow-error-label-not-fault`
315, `flow-inert-node-condition` 286, `flow-runas-unscoped` 284,
`flow-branch-label-unmatched` 272, `flow-decision-inclusive-overlap`
250, `flow-default-edge-with-condition` 239,
`flow-multiple-default-edges` 211, `flow-time-relative-antipattern` 208,
`flow-date-equality-filter` 208
- `validate-flow-template-paths.ts`: `flow-template-field-unprovisioned`
440, `flow-template-lookup-traversal` 349, `flow-template-unknown-field`
258

**Message lives outside `packages/lint` —
`packages/spec/src/kernel/functional-completeness.ts` (named, not
edited) — 8 rule id(s):**

- `functional-completeness.ts`: `view/row-color-without-colors` 793,
`view/layout-without-binding` 673, `webhook/without-triggers` 594,
`view/tree-without-parent-field` 592, `field/summary-without-operations`
319, `field/formula-without-expression` 259,
`field/choice-without-options` 250,
`field/relationship-without-reference` 239

**Not an author-time registry rule — 4 rule id(s):**

- `lint-startup-registry-verdict.ts` (the repo gate
`check:startup-registry-verdict`): `startup-open-vocabulary-verdict`
779, `startup-verdict-assertive-wording` 731
- `data-model-rules.ts` `lintDataModel` (`os lint`'s own data-model
rubric): `relationship/master-detail-required` 462,
`rollup/non-numeric-aggregand` 364

Each second-stage rule takes the same shape: move the long text into
`RULE_EXPLANATIONS` (the pointer then appears on its `rule:` line by
itself), leave one verdict sentence and one fix, and pin the new shape
in the rule's own test. The excluded three files can follow once objectstack-ai#22268,
objectstack-ai#22315 and objectstack-ai#22319 land.

## Acceptance notes

- The `fix:` label now prefixes the hint under every author-time finding
the CLI prints (build, validate, verify, init), not only the two
shortened rules. Round 2 measured every hint producer for text that is
not a fix and changed five (listed under `## Patch round 2`); every
other rule's hint text is unchanged. Borderline rows were counted as
fixes, because each carries an instruction or a spelling to write:
`validate-component-types.ts:150`,
`validate-flow-trigger-readiness.ts:632`, `runtime-gate.ts:1020`,
`lint-liveness-properties.ts:254` / `:273`, and the `fix` snippets in
`functional-completeness.ts`.
- `expression-invalid`'s runtime 422 issue now carries `hint: ''`; its
message carries the source. objectui's save-advisory toast already skips
an empty hint (`saveAdvisoryToast.ts:97`). The one place that prints the
bare value is the deduped operator log line in `metadata-protocol`
`runtime-authoring-gate.ts:1130` (`… (${advisory.hint})`), which now
ends in `()` for an `expression-invalid` warning. It is cosmetic,
server-log only, and not changed here.
- `os validate`'s text face now shows `fix:` and `rule:` lines under
every registry warning (it showed neither before); the `warnings` list
`--strict` and `--json` read is unchanged, so
`validate-json-warning-parity.e2e.test.ts` still pairs the faces.
- Runtime publish gate: `security-owd-unset` also runs at the metadata
write door, so a Studio / REST / MCP refusal carries the shorter message
and hint too; the explanation is reachable from the CLI only.
- `origin/main` `e9a1f5c40` is merged (`d33862bde`, a merge commit).
- No new gate and no length ratchet (the ruling); each shortened rule's
own test pins its shape.

## Patch round 2 (seat order `6067462250` → `74bed8f56`)

Written into this body by the `domain:spec` seat 2 at 2026-10-08T20:46Z
from the dev's report `6068666691`; the role file reserves a later body
edit to the seat.

- **Measured, non-fix hints** (static read of the 274 `hint:` values in
`packages/lint/src`, plus the `fix:` values in
`functional-completeness.ts` and the shared hint helpers). Five, at the
stop condition's limit, none in the three excluded files:
- `authoring-rules.ts:687`, `expression-invalid`: quoted the source. The
source now ends the message, and the hint is empty.
- `validate-component-props.ts:336`, `component-props-invalid`: context
only. The hint is the fix, and the consequence moved into the message (a
CLI-only rule).
- `validate-flow-trigger-readiness.ts:497`,
`flow-time-relative-descriptor-invalid`: context only. The hint opens
with the instruction.
- `validate-react-page-props.ts:1166`, `react-prop-missing-required`:
the hint was the binding's description alone. It is now `Pass REQ={…}:
DESCRIPTION`.
- `lint-liveness-properties.ts:247`, `liveness-experimental-property`:
the hint was a statement. It now opens with an instruction.
- **Pin:** `packages/cli/test/explain-rule-id.test.ts` runs the real
registry adapter on the tutorial's action and prints through
`printAuthoringRuleErrors`. It asserts exactly two lines, the source
inside the verdict line and no `fix:` line. Ablated with the dist leg
(adapter reverted, lint rebuilt): 1 failed | 11 passed. Restored to the
HEAD blob, and the rebuilt dist carries no marker.
- **Docs blocks re-rendered from the printer:**
`content/docs/getting-started/build-with-claude-code.mdx` `:309`–`:313`
and `content/docs/ui/react-pages.mdx` `:361`–`:363`, `:403`–`:405`. The
`:403` block was already stale before this PR (the fallback hint where
the contract has a description). Cross-lane on objectstack-ai#6023.
- **Changeset:** it names the runtime-wire `message` change for
`expression-invalid`. Its count of the reworded rules that reach a
runtime response is corrected in patch round 3, below.
- **Readings:**
- lint: 128 files / 5853 passed, and typecheck exit 0, both at
`e84732425`.
- cli: unit 4 files / 120 passed and integration 4 files / 21 passed
(the spawn tests that print or read `expression-invalid`), and typecheck
exit 0, all at `819444f50`.
  - ESLint on the 7 changed `.ts` files: 0 errors / 0 warnings.
- `dispatch-gates --commands` (no paths) at `819444f50`: 106 derived
(round 1's 78 plus 28 docs/spec families), all 106 exit 0.
- The three dist-reading gates exited 3 on the fresh worktree and exit 0
after the remaining packages were built.
- `--ran`: 106 run, 0 NOT-MEASURED. The 20 changeset/text families
re-ran on `74bed8f56`: exit 0.
- Round 1's two NOT-MEASURED gates (`check:dual-build-cjs-loads`,
`check:i18n-coverage`) also exit 0 at `6d2eb857c`.
- **Line budget:** round 2 is 10 files, +87 / -18. The whole PR against
`e9a1f5c40` is 28 files, +919 / -81. Governed paths touched: 0.

## Patch round 3 (contract review FAIL `6068965879` → seat order
`6068983639` → `1e016895c`)

Written into this body by the `domain:spec` seat 2 at 2026-10-08T21:10Z
from the dev's direct report; the role file reserves a later body edit
to the seat. One commit, `.changeset/22161-rule-message-one-line.md`
only (+4 / -2). Each sentence was checked against `surfaces`,
`runtimeTypes` and severity in the code before it was written.
- **The reworded hints at the gate.** Only
`flow-time-relative-descriptor-invalid` reaches a 422 `hint`: it is an
`error` on `flow` writes.
- `liveness-experimental-property` is always a `warning` on
`email_template`, `mapping` and `datasource` writes, so it would ride
the 2xx `advisories`. No ledger row on those types is `experimental`
today, so it reaches no runtime response yet. This corrects the seat's
own order, which put it on the 422.
  - `component-props-invalid` is CLI-only.
  - `react-prop-missing-required` judges no `page` write at the gate.
- **`security-owd-unset` at the `object` write door.** A custom object
(neither `isSystem` nor `sys_`-named) with no `sharingModel` is refused
with a 422, and its issue now carries the new `message` and `hint`, both
quoted verbatim. `where` and `path` still carry the object; `os explain
security-owd-unset` prints the incident.
- **`expression-invalid`**: the source rides the issue `message` at the
gate for `flow`, `action`, `hook` and `object` writes. An `error` lands
in the 422, a `warning` in the 2xx `advisories`. The runtime `hint` is
`''`.
- **`os explain` unknown id:** it still exits 1. The text changes from
`Unknown schema: "X"` to `Unknown schema or rule id: "X"`, followed by a
`Rules with an explanation: …` line. The `--json` `error` changes the
same way.
- **Gates at `1e016895c`:** the 20 families `dispatch-gates --commands`
derives for the changeset, plus `check-changeset-fixed.mjs`, all exit 0.
No `PREREQUISITE NOT MET`. `main` was not merged (the push was
accepted).

---
_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
…al-less readings outside packages/spec say D5 refuses it (objectstack-ai#22357)

Fixes objectstack-ai#22345

Clause-②: no (narrowing)

`domain:services` seat 1, branch
`claude/issue-22345-pre-d5-reading-pass`, dispatched under the claim
`6068302217`, executing triage `6068146867`. The pre-D5 family's
`packages/spec` half is objectstack-ai#22302 / PR objectstack-ai#22327 and is not touched here.

## What this does

- **Retires `RunProvenanceContext`** from
`@objectstack/service-automation`: the interface, its arm of
`RunDataContext`, the type export in `src/index.ts` and the README
export list. `RunDataContext` is now `interface RunDataContext extends
RunIdentityContext {}`, which is exactly the set of shapes
`resolveRunDataContext` returns. Only type declarations change.
- **Closes the pre-D5 reading outside `packages/spec`.** 24 comment or
docstring sites in 11 packages said, in the present tense, that a
principal-less or `{ flowRunId }`-only context falls open, is handed
through, or is skipped by the data security middleware. Each now says
that the middleware used to do this, and that ADR-0096 D5 refuses such a
context. These edits are comment text only.
- **No runtime behaviour changes.** Comments were stripped with
`scripts/js-comment-mask.mjs` `stripComments` and whitespace was
collapsed. After that, 18 of the 20 changed `.ts` files are
byte-identical to base `35afb15878`. The other two are
`runtime-identity.ts` and `index.ts`. They differ only by the removed
`interface RunProvenanceContext`, the `RunDataContext` declaration, and
the dropped name in the type export. The class body between those two
declarations is byte-identical.

## H1: producers and readers (base `35afb15878`)

- `git grep -n RunProvenanceContext` finds 4 hits:
`runtime-identity.ts:75` (the declaration), `:124` (the union),
`index.ts:195` (the type export) and `README.md:451` (the export list).
There is no other reference in `packages/**`, `apps/**`, `examples/**`
or `packages/qa/**`. The pinned sibling `objectui` at `a58626c88d` has
none either: `git grep -E 'RunProvenanceContext|RunDataContext'` exits
1, and in the same shallow fetch `service-automation` matches 14 files
as the control.
- `RunDataContext` by name: `runtime-identity.ts:124` (the declaration),
`:178` (the return type of `resolveRunDataContext`), `:275` (a parameter
of `stampSystemInsertOwner`), `index.ts:195` and `README.md:450`. It has
one reader outside the package:
`plugin-approvals/test-typecheck-debt.json:43`. That is a ledgered
TS2352 on `record-lock-schedule-run.integration.test.ts:150`, which
casts `resolveRunDataContext(...)` to a record because the union's
provenance arm had no `isSystem`.
- **No production code builds an engine context that carries `flowRunId`
and no principal.** Every data node resolves its context through
`resolveRunDataContext` (`crud-nodes.ts:484/572/720/812`,
`plugin.ts:1248`). `engine.ts:6079` stamps `flowRunId` on the run's
`AutomationContext`, which is not an engine context. Only tests build
such a context:
- `objectql/src/engine.test.ts:601` (a bare engine; it pins hook
provenance);
- `plugin-security/src/delegated-admin-gate.test.ts:137` and
`system-write-guard.test.ts:94` (gate units);
  - `principal-less-strict-mode.test.ts:135` (refused by D5).

The `provenance: { flowRunId }` fixtures with no session in the
plugin-approvals, plugin-audit and service-storage tests are
`HookContext` shapes. None of these names the type.
- **The docstring said the type "survives for the non-data provenance
uses that motivated objectstack-ai#3712". None was found.** The objectstack-ai#3712 use is the
approvals record lock. It reads `HookContext.provenance.flowRunId`
(`plugin-approvals/src/lifecycle-hooks.ts:489`), which objectql's
`buildProvenance` builds from any `ExecutionContext`, and
`RunIdentityContext` already carries `flowRunId`.
- Result: the retirement condition holds, and the type is retired.

## H2: what D5 does today (on `main`)

- `plugin-security/src/security-plugin.ts:2451`: `if
(opCtx.context?.isSystem) return next()`. This system short-circuit runs
first.
- `:2654`-`:2656`: `if (isPrincipalLessContext(opCtx.context)) throw
principalLessDenial(...)` throws `PermissionDeniedError`, `403
PERMISSION_DENIED`, for every verb. It runs after the package-managed,
system-row, curated-capability, audience-anchor, engine-owned-write and
delegated-admin gates. The predicate is at `:383`-`:387`. The probes
agree with it: `getReadFilter` returns the deny filter (`:5947`), and
`canReadObject` (`:6384`) and `canExport` (`:6778`) answer `false`.
Landed in `a3bcbcf3ca` (PR objectstack-ai#22297), and `git merge-base --is-ancestor
a3bcbcf origin/main` exits 0.
- `resolveRunDataContext` (`service-automation/src/runtime-identity.ts`)
returns, by `runAs`:
- `runAs: 'system'`: `{ isSystem: true, actor: 'svc:flow:NAME', userId?,
tenantId?, positions: [], permissions: [], flowRunId? }`;
- `runAs: 'user'` with a user: `{ isSystem: false, userId, positions,
permissions, tenantId?, flowRunId? }`;
- no user: it throws `UnscopedRunDataAccessError`
(`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`).

## H3: the enumeration (the pin)

**Why there is no test file.** The repo's closest precedents pin a
relation or a structure, never wording.
`rest-server-docblock-position.test.ts` says so: "Wording is not pinned
here on purpose: nothing parses these sentences". Nothing parses these
comments either. So the pin is this command and its output, re-runnable
on any tree:

```
git grep -n -i -E "middleware('s)? (skips (when|every|its)|skipped (a|when|every|its)|would skip|waves|waved|takes its principal-less|SKIPS)|(skips|skipped) when there is no (principal|identity)|wave[sd]? (it |them )?straight through|principal-less (fall-open|hand-off|.return next)|empty-principal (fall-open|skip)|(falls|fell|fall|falling) open for principal-less|plugin-security('s)? (principal-less )?(falls|fell) open|indistinguishable from passing no context|security[- ]skipped|ADR-0096 E1|straight to .next\(\)|(be|been) handed straight through|middleware handed it" -- . ':!packages/spec/**' ':!**/CHANGELOG.md'
```

It gives 82 lines at base `35afb15878` and 76 at head `f3a9675f4b`. The
grep is line-based, so the sweep also paired subject and claim terms
across 8-line windows to catch sentences split over lines. That window
sweep found the fixed sites below that the grep alone misses.

### Fixed in this PR: current tense and false now (base positions)

| # | Site | The false sentence |
|:-|:-|:-|
| 1 | `service-automation/src/runtime-identity.ts:56-74` |
`RunProvenanceContext`: "the empty-principal fall-open …
indistinguishable from passing no context at all" (retired with the
type) |
| 2 | `service-automation/src/runtime-identity.ts:85-86` |
`UnscopedRunDataAccessError`: "the data security middleware skips when
there is no principal" |
| 3 | `service-automation/src/runtime-identity.ts:220-223` | "presenting
none means the data security middleware skips every principal gate" |
| 4 | `service-automation/src/runtime-identity.ts:333-336` |
`runIsUnscopedUserMode`: "a principal-less context that the security
middleware would wave straight through" |
| 5 | `service-automation/src/engine.ts:6057-6058` |
`resolveRunContext`: "the data security middleware skips when there is
no identity" |
| 6 | `service-automation/src/builtin/crud-runas.test.ts:219-220` |
"which the data security middleware waves straight through" |
| 7 | `service-automation/src/builtin/crud-runas.test.ts:258-259` | "the
data security middleware skips" |
| 8 | `service-analytics/src/strategies/objectql-strategy.ts:410-411` |
"reaches the engine principal-less and plugin-security falls open" |
| 9 | `plugin-security/src/security-plugin.ts:6061-6065` |
`getMetadataReadableFields`: "mirrors the engine middleware, which skips
its grant-based gates for a caller with no permission sets" |
| 10 | `plugin-security/src/get-metadata-readable-fields.test.ts:8-10,
:84` | "the engine middleware skips its whole gate for such a caller";
"middleware-mirroring fall-open" |
| 11 | `platform-objects/src/system/sys-secret.object.ts:50-51` | "read
with no principal (middleware falls open for principal-less internal
calls)" |
| 12 | `metadata-protocol/src/protocol.ts:11233-11237` | "this read
passes no context"; "the middleware takes its principal-less `return
next()`" |
| 13 |
`metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts:7-9`
| "plugin-security hands … straight to `next()`" |
| 14 | `plugin-auth/src/auth-plugin.ts:2480-2482` | "— the
principal-less hand-off (ADR-0096)" |
| 15 | `plugin-auth/src/auth-manager.ts:3384-3386` | "(the security
middleware's principal-less hand-off, ADR-0096)" |
| 16 | `plugin-auth/src/scim-connection-service.ts:121-122` | same |
| 17 |
`plugin-auth/src/principal-less-producers-system-context.test.ts:13-15`
| "A context with neither … is the security middleware's principal-less
hand-off" |
| 18 | `runtime/src/http-dispatcher.ts:1244-1246` | "these calls carry
NO ExecutionContext, so the data engine's security middleware skips RLS
/ FLS / CRUD / tenant scoping entirely" (the facade has carried `{
...caller, isSystem: true }` since objectstack-ai#3914) |
| 19 | `runtime/src/http-dispatcher.ts:1510-1511` | "(the security
middleware's principal-less hand-off, ADR-0096)" |
| 20 |
`runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts:551-556`
| "which the security middleware hands straight through"; "once a
principal-less context is denied too" |
| 21 |
`trigger-record-change/src/record-change-integration.test.ts:395-396` |
"the data security middleware skips when there is no principal" |
| 22 | `qa/dogfood/test/flow-runas-schedule.dogfood.test.ts:10-12` |
"the security middleware SKIPS (it delegates auth to the auth layer)" |
| 23 |
`qa/dogfood/test/declarative-endpoint-anonymous-guest.dogfood.test.ts:15-17`
| "once the engine-level deny for the principal-less hand-off lands" |
| 24 | `examples/app-showcase/src/automation/flows/index.ts:1584-1586` |
"Without this it relies on the 'no identity → security-skipped'
fall-through" |

### The pin's 76 lines at head `f3a9675f4b`, each with its class

- **Fixed here (15):** the lines of rows 1-24 that still match, now in
their corrected form: `examples/…/flows/index.ts:1586`,
`protocol.platform-store-system-opt-in.test.ts:8, :9`,
`protocol.ts:11237`, `auth-plugin.ts:2481`,
`principal-less-producers-system-context.test.ts:14`,
`scim-connection-service.ts:122`,
`get-metadata-readable-fields.test.ts:9`, `security-plugin.ts:6062`,
`declarative-endpoint-anonymous-guest.dogfood.test.ts:17`,
`dispatcher-plugin.endpoint-fallback.integration.test.ts:552`,
`http-dispatcher.ts:1514`, `crud-runas.test.ts:220`, `engine.ts:6058`,
`runtime-identity.ts:206`.
- **Historical, left (36):**
- Release text, not edited in a code PR:
`.changeset/21908-by-id-producers-opt-in.md:6`,
`.changeset/21908-principal-less-producers-final.md:8`,
`.changeset/21908-principal-less-strict-mode.md:20` and
`content/docs/releases/v17/17-0.mdx:302`.
- Decision records (Tier H): `docs/adr/0096-…:11, :38, :156` and
`docs/adr/0138-…:111, :635`.
- "Before …" / "used to …" / "which D5 closes":
`auth-manager.org-slug-guard-system-context.test.ts:10`,
`http-dispatcher.membership-system-context.test.ts:10`,
`principal-less-strict-mode.test.ts:9`, `security-plugin.ts:359, :2650,
:6212`, `zero-set-capability-fold.test.ts:40`,
`zero-set-masking.test.ts:37`, `webhook-system-context.pin.test.ts:11`,
`datasource-system-context.pin.test.ts:16`,
`inbox-system-context.ts:16`, `sql-http-outbox.ts:473`,
`system-context.pin.test.ts:18` (service-messaging),
`settings-system-context.pin.test.ts:11`, `metadata-store.ts:143, :174,
:196, :197, :198` and
`owner-of-private-object-under-strict-mode.dogfood.test.ts:8`.
- objectstack-ai#3597 and objectstack-ai#1888 history: `analytics-rls.dogfood.test.ts:9, :163`,
`flow-runas-fixture.ts:27`, `flow-runas.dogfood.test.ts:30`,
`execution-context-bridge.test.ts:16`,
`service-analytics/src/plugin.ts:427` and `objectql-strategy.ts:852`.
- **True, left (15):**
- Names the hand-off as what ADR-0096 D5 closes: `auto-enqueuer.ts:30`,
`redeliver-guard.ts:70`, `datasource-admin-plugin.ts:104`,
`datasource-secret-binder.ts:40`, `fan-out-system-context.ts:25`,
`outbox-dispatcher-scope.ts:94, :118, :135`,
`settings-service-plugin.ts:414, :538` and `settings-service.ts:104`.
- Negation: `public-form-grant-masking.test.ts:33, :233` ("never the
principal-less hand-off").
- Describes the replacement:
`tenant-audit-update-delete-half-repairs.test.ts:798`.
- This PR's changeset quoting the removed sentence:
`.changeset/22345-run-provenance-context-retired.md:17`.
- **A string literal, not a comment, left (2):** both are test assertion
messages that name the failure shape:
`dispatcher-plugin.endpoint-fallback.integration.test.ts:571` and
`runas-grant-resolution.integration.test.ts:102`.
- **The sibling ADR-0056 D2 family, left (3):**
`export-permission-axis.test.ts:143` (a test title),
`rest/src/rest-server.ts:2640` and
`runtime/src/domains/automation.ts:295`. These describe an AUTHENTICATED
caller with zero permission sets, not a principal-less one. See the
Acceptance notes.
- **Another subject (5):** `docs/adr/0111-…:126` (the sharing middleware
skipping `sys_record_share`), `better-auth-schema-parity.test.ts:13`,
`can-write-object-admission.test.ts:576` and `security-plugin.ts:6521`
(step 2.5 with no payload), and `text-match-sql.ts:237`.

## H4: `objectql/src/engine.ts` (`domain:engine`), not changed

The sentence is now at `:5466`-`:5469`, after PR objectstack-ai#22337 landed: "A
context carrying only write PROVENANCE (`{ flowRunId }`, all an
identity-less flow run has — objectstack-ai#3712) is such a case: it says what
produced the write, not who is calling, and surfaces through {@link
buildProvenance} instead."

**Reading:** the sentence does not assert the pre-D5 behaviour. It says
nothing about the security middleware admitting or skipping that
context. It describes `buildSession` returning no session for it, and
that is still what the code does. So it is not changed, and this diff
contains no objectql file.

Its parenthetical producer claim, "all an identity-less flow run has",
is a different staleness. It has been false since objectstack-ai#3760: a user-less
`runAs: 'user'` run never reaches the engine, and a `runAs: 'system'`
one carries `isSystem` and `actor`. It is listed in the Acceptance notes
with its family.

## H5: retirement, dependents and reverse verification

- **Why an interface rather than a type alias.** This was probed with
tsc 6.0.3. The alias `type RunDataContext = RunIdentityContext` prints
as `RunIdentityContext | undefined` in diagnostics. That re-spells
plugin-approvals' ledgered TS2352 signature (`'RunDataContext |
undefined'`) and turns its `check:test-typecheck` red (one ARRIVED, one
VANISHED). The interface keeps the name, so no consumer ledger moves.
- **Dependents typecheck.** `turbo run typecheck
--filter="...^@objectstack/service-automation"` covers all 18
dependents. The six other published packages this diff touches were
added with explicit `--filter`s. That is 24 packages, and all 24 declare
a `typecheck` script. Result: `Tasks: 89 successful, 89 total`, 36
cached, at `c19dde3b5c`, before the merge of `main`.
- **Reverse verification.** A probe file went into
`plugin-approvals/src`. That package resolves
`@objectstack/service-automation` through `exports` to
`dist/index.d.ts`, rebuilt from this branch. tsc reported:
- `TS2305: Module '"@objectstack/service-automation"' has no exported
member 'RunProvenanceContext'`;
- `TS2739: Type '{ flowRunId: string; }' is missing the following
properties from type 'RunDataContext': isSystem, positions,
permissions`.

The probe was removed by a trap, and `git status --porcelain` printed
nothing afterwards.

## Changeset

`.changeset/22345-run-provenance-context-retired.md` grades
`@objectstack/service-automation` as `minor`: BREAKING, an accept-set
narrowing of one type and one removed type export, with the `!` banner.
It also grades four packages as `patch` for comment text only. Their
edited comment text ships, as measured in the built `dist`:

| Package | Edited text found in |
|:-|:-|
| `plugin-security` | `dist/index.js` and `dist/index.d.ts` |
| `runtime` | `dist/index.js` and `dist/index.d.ts` |
| `service-analytics` | `dist/index.js` |
| `platform-objects` | `dist/index.js` |

The edited comments of `plugin-auth` and `metadata-protocol` do not
ship. As a control, the code tokens next to them are present in the
bundle (`withSystemContext(rawEngine)` 2, `CREDENTIAL_PROBE_CONTEXT` 3,
`sys_metadata_audit` 8).

ADR-0087 disposition: `not-required (runtime-interface-only
packages/services/service-automation/src/runtime-identity.ts#RunDataContext)`.
The gate verified it: "verified: …#RunDataContext (interface)".

## Cross-lane paths (comment-only, declared)

- `plugin-security`, `plugin-auth`, `runtime`, `metadata-protocol`,
`platform-objects`, `service-analytics` and `trigger-record-change`:
rows 8-21 of the table.
- `packages/qa/dogfood` and `examples/app-showcase`, both private: rows
22-24.
- `objectql`: read under H4 and not changed.

## Verification (head `f3a9675f4b` = this branch merged with `main` at
`54c3ce10ce`; PR objectstack-ai#22337 landed meanwhile; no conflict)

- `pnpm --filter @objectstack/service-automation exec vitest run
--maxWorkers=2`: `Test Files 177 passed (177)`, `Tests 2171 passed
(2171)`. Before the merge, at `c19dde3b5c`, it was 176 / 2163.
- `pnpm --filter @objectstack/service-automation typecheck`:
`check:test-typecheck: OK … 0 file(s) / 0 error(s)`.
- The edited test files, one run each, all passed:

  | Test file | Tests passed |
  |:-|-:|
  | plugin-security `get-metadata-readable-fields.test.ts` | 7 |
| metadata-protocol `protocol.platform-store-system-opt-in.test.ts` | 7
|
  | plugin-auth `principal-less-producers-system-context.test.ts` | 4 |
| runtime `dispatcher-plugin.endpoint-fallback.integration.test.ts` | 21
|
  | trigger-record-change `record-change-integration.test.ts` | 9 |

- After the merge, `turbo run build --filter=!@objectstack/docs` gave
`72 successful, 72 total`.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 79 commands from 22 paths at
`f3a9675f4b`, and all 79 exited 0. `--ran` with recorded exit codes: "79
derived famil(ies) accounted for — 79 run, 0 NOT-MEASURED (a DERIVED
zero …)". Some of the gates' own verdict lines:
- `check-adr-0087-registration`: "1 declared-breaking changeset(s), each
carrying an ADR-0087 disposition";
- `check-system-context-census`: "OK — 118 elevation read sites in 20
packages";
- `check:nul-bytes`: "OK (scanned 10334 text file(s) … no raw ASCII
control bytes)";
- `check:dual-build-cjs-loads`: "106 published require entry point(s)
across 66 package(s) load";
- `check:published-files`, `check:dts-closure`,
`check:test-source-alias`, `check:cross-package-test-inputs` and
`check-issue-citations`: green.
- ESLint on the 20 changed `.ts` files: `errors=0 warnings=0`, with 20
files counted from `--format json`. That run was narrowed to the changed
files, which holds only because no file's lint result depends on another
file: the population is the config's
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block, and
`eslint.config.mjs:327-328` states that the config "never enables
type-aware linting (no `parserOptions.project` …)". The full lint is
CI's.
- Declared narrowings:
- The dependents typecheck ran before the merge. The commits the merge
brought in move no `service-automation` export, and CI's TypeScript Type
Check runs them all.
- The full test suites of the comment-only packages, and the Dogfood
Regression Gate, are left to CI. Each of their changed files is
comment-identical to base, as measured above.

## Acceptance notes

- **Sibling family, outside this card's definition (ADR-0056 D2, the
deny baseline).** These sites say that the middleware skips its CRUD
gate for an AUTHENTICATED caller with zero permission sets. The step-2
CRUD gate has not been guarded on a resolved set since that change. The
sites are `rest/src/rest-server.ts:2639-2642`,
`runtime/src/domains/automation.ts:295-303` ("this surface refuses where
`/data` falls open"),
`plugin-security/src/export-permission-axis.test.ts:143-146` (a title
and a comment) and
`plugin-security/src/baseline-composition.test.ts:157-159`. Not a
principal-less reading, so left as they are. Taker: none.
- **The objectstack-ai#3712 producer premise, false since objectstack-ai#3760, with no admission
claim.** These sites say that a schedule-triggered run reaches the data
layer as `{ flowRunId }` with no session: `objectql/src/engine.ts:5467`
and `:5524`, `plugin-security/src/delegated-admin-gate.test.ts:133-135`,
`system-write-guard.test.ts:89-91`,
`plugin-audit/src/comment-access-hooks.test.ts:186`,
`service-storage/src/attachment-access-hooks.test.ts:147-151`,
`plugin-approvals/src/approval-service.test.ts:2239-2242` and
`lifecycle-hooks.ts:484-488`. The units they introduce still test real
hook and gate behaviour for that shape. Taker: none.
- **Runtime strings, outside "comments only".** The
`UnscopedRunDataAccessError` message (`runtime-identity.ts:109-113`) and
the run-setup warning (`engine.ts:6133-6135`) say a principal-less run
"would execute UNSCOPED (elevated, RLS-bypassing)". On a kernel with
plugin-security, that run is now a 403. Both are pinned:
`crud-runas.test.ts:238` and `schedule-runas-e2e.test.ts:122` assert
`/UNSCOPED/`. Not changed, because no runtime behaviour moves in this
PR. The prescription they give (declare `runAs: 'system'`) is still
right.
- **Test titles are strings, so they are left.**
`plugin-security/src/security-plugin.test.ts:2317, :2571, :2674` read
"(gate is before the fall-open)", and
`can-write-object-admission.test.ts:640` reads "before the fall-open".
The gates still run before the D5 refusal, so the DENIES assertions
hold. `trigger-schedule/src/schedule-runas-e2e.test.ts:95-96` reads
"runs the flow UNSCOPED".
- **Left on purpose:**
- `service-automation/src/engine.ts:6292`, "Surfaces the user-less
fail-open (see helper)", names the objectstack-ai#1888 case. It is not a claim about
the middleware.
- `objectql-strategy.ts:852` and `analytics-rls.dogfood.test.ts:8` say
"the bridge passes no ExecutionContext". That is a tense slip inside a
past-tense paragraph; the bridge has passed the caller's context since
objectstack-ai#3602.
- **An orphaned docblock.** `runtime/src/http-dispatcher.ts:1241-1254`
is bound to no declaration, the shape
`rest-server-docblock-position.test.ts` records for this file. Its text
is corrected here; its position is not moved.
- **A plugin-security design question, not answered here.**
`getReadableFields` still answers the full field set (minus posture
fields) for a caller with no permission sets, a caller the middleware
now refuses. The docstring now says that, instead of calling it
"mirroring". No consumer was measured reaching it with such a context.
- **Not changed:**
- `resolveRunDataContext` keeps its `| undefined` return type although
no path returns `undefined`.
- `skills/objectstack-query/SKILL.md:66` ("`{ flowRunId }` for
provenance alone") is Tier H and stays with the card the PR objectstack-ai#22327
review says is owed.
- **A possible overlap.** PR objectstack-ai#22315 (`domain:spec` seat 2) edits
`service-automation/src/engine.ts` and `README.md` in other regions.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_

---------

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4e275f15df0e5c991442b0b49ab4b02800678f4a
Local-runs: none

Inputs, and nothing else: card #22110 (body and all fifteen comments — the claim 6059160333, the rulings 6063190355 / 6063702421 / 6065501303, the four dev reports including the merge report 6076389514, and the three seat ACCEPTs), PR #22315 (body, the 54-file list, and the net diff 3054516ef..4e275f15d, +1854 / −407, 2261 changed lines — the merge base of this head against origin/main is the PR's own base sha 3054516ef, so that diff is the net diff), the three earlier ## Contract review records on this PR (6063558021 FAIL, 6065484352 FAIL, 6068757622 PASS at 5bfa9ae1f) with the seat's CI notes (6067723298, 6069399520, 6074946922), and the 35 check-runs plus the combined commit status on the head, read at 2026-10-09T07:41Z. The head was fetched into a private ref (refs/review/pr-22315-b) and read with git show / git diff / git grep; the shared checkout was not touched (0 dirty paths before and after); nothing was built, run or re-run. Governing text re-read on origin/main: ADR-0032 Decisions 3 and 4 and its representation table, ADR-0087 D2 / D3, AGENTS.md Prime Directives #10 / #12 / #14 and the changeset rules. A whole-PR review at this head by an isolated subagent of the seat session named below; the dispatch order and the seat's own conclusions were not inputs.

① Derived judgments

This head against the PASS head 5bfa9ae1f — what the merges changed. Three merges of origin/main sit between them, each through the merge script with no conflict markers: bff973d87 (main c6fc938ce), f84cd82d2 (main ca135dcc4 — the 39 commits including #22380, #22392, #22393, #22406, #22368), 4e275f15d (main 3054516ef — four commits including #22416's 27a8b33de). The seat's "twice" counts the merge round's two; the first merge was the seat's own re-run push.

  1. The PR's delta is byte-identical across the merges. The sorted +/− line multiset of git diff -U0 3599fef12 5bfa9ae1f (the PASS head against its merge base) equals that of git diff -U0 3054516ef 4e275f15d — 2269 lines each, an empty interdiff — and the 54-file set is the same. So nothing this PR adds or removes moved; what remains is whether main's own edits to the seven files both sides touched combine with it correctly at this head. Each read at the head, not taken from the merge report:
  2. packages/spec/src/migrations/registry.ts — right. STEP18_RATIONALE is the union: 107 fragments, ids in C-sorted order (verified over the whole list at the head; scripts/step18-rationale-merge.test.ts runs inside the green Test Core). This branch's flow-text-slot-single-brace-refused sits at order 89 between flow-script-subflow-config-undeclared-keys-refused (87) and flow-value-slot-template-dialect-refused (88), tied at 89 with flow-builtin-node-config-undeclared-keys-refused and main's platform-global-object-organization-column-retired; main's sys-view-definition-retired and try-catch-and-retry-policy-undeclared-keys-refused (fix(spec)!: close the shared retry policy, and judge try_catch config keys at the build doors #22380) sit at 90 in their sorted places. The list's own docblock allows the tie and renders ties by id. The three D3 semantic entries (this branch's and main's two) are all present in step18. check:generated runs inside the green TypeScript Type Check, so the migration, spec-changes and upgrade-guide artifacts are current for this union. Observation, no finding: fix(spec)!: close the shared retry policy, and judge try_catch config keys at the build doors #22380's fragment opens "Then the retry policy closes" as a continuation of the builtin-keys fragment, and at order 90 it renders after three order-89 fragments instead of the two it followed on main — a prose join main had already made with platform-global…; the docblock and the merge test accept it.
  3. packages/lint/src/validate-expressions.ts — right, and the question the dispatch asked. fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads an unbound ctx/os member (#22274) #22392 / fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads parent (#22157) #22268's option-visibleWhen root and member verdicts (OPTION_VISIBLE_WHEN_BOUND_ROOTS / OPTION_VISIBLE_WHEN_BOUND_MEMBERS, optionVisibleWhenRootIssue, about :1119-1270, called from the objects' fields walk about :2298) and this PR's text-slot pass (about :1982-1992, inside the flows' node walk, right after flowNodeValueTemplateRefusals) are disjoint passes over different metadata. No continue main added precedes the text-slot pass in the node loop — the three continues above it are inside the declared-slot sub-loop. The pass still runs under validateStackExpressions, registered commands: ALL (authoring-rules.ts about :557-560), so validate, build and lint all refuse the single brace. Nothing this PR refuses or prescribes at the build door changed.
  4. packages/services/service-automation/src/engine.ts — right. The import union holds both sides (flowNodeTextSlotSources / textSlotTemplateRefusal at :54, main's builtinNodeConfigKeysJudged at :59). fix(spec)!: close the shared retry policy, and judge try_catch config keys at the build doors #22380 makes the undeclared-key walk stand aside for every builtin (if (builtinNodeConfigKeysJudged(node.type)) continue; at :10347), but that continue lives in validateNodeConfigKeys (:10331), while this PR's text-slot pass (:10735-10745) lives in validateFlowExpressions (:10570), inside the collectFlowGraphs walk (:10703, so region nodes are judged with their scope prefix); registerFlow calls both (:4412, :4424). The stand-aside does not bypass the text-slot judge. fix(service-automation, objectql): runAs refusal and run-setup warning texts hold on both kernels #22390 changed only the runAs warn text and comments; the end render point (renderTextSlot(endConfig.message, variables), about :11005) is intact.
  5. content/docs/automation/flows.mdx — right. Main's fix(spec)!: close the shared retry policy, and judge try_catch config keys at the build doors #22380 added the closed-retry paragraph (about :803) and reworded the config-key row and paragraph for try_catch; this PR's "Text slots read double-brace holes" section (about :284-330), the dialect-table row (about :1998) and callout item 4 (about :2029) are intact. The fault-edge variable table — the PASS record's ①.18 residue — is byte-identical, moved from :1618 to :1624 by the added paragraph.
  6. The other three overlap files — right. examples/app-showcase/src/automation/flows/index.ts: main changed one comment on InboundTaskWebhookFlow; the 23 text-slot rewrites are intact, and the two single-brace residues a grep finds there (:51 script inputs, :1223 http body) are value-like positions. packages/lint/src/validate-expressions.test.ts: disjoint hunks — this PR adds slot to the meta-test's receiver allowlist, main adds the finding(lint): a select option's visibleWhen reading parent passes os build and the object save door, and the server's option gate then faults open ("allowed through; the option's gate was NOT enforced") #22157 / lint: a select option's visibleWhen reading a member the option check never binds (os.org.id, os.env, ctx.locale) passes os build and the save door, and the server's option gate then faults open #22274 option suites. packages/services/service-automation/README.md: main dropped RunProvenanceContext from a type list (fix(service-automation)!: retire RunProvenanceContext; pre-D5 principal-less readings outside packages/spec say D5 refuses it #22357); this PR's dialect-table row is intact.

The whole PR's accept-set and public-surface changes, each confirmed at this head — the delta is the one the PASS record judged, re-read here rather than adopted:

  1. NotifyConfigSchema title / message (bare string or template envelope source): narrowed — inside the existing superRefine, after the blank-envelope refusal and its continue, textSlotTemplateRefusal(value.source) adds a custom issue at the key; {{ }} holes, a $-named path and a formatter pass. Pinned in io-node-config.test.ts for both spellings, the message led by TEXT_SLOT_TEMPLATE_REFUSAL and naming the hole spelling. Right: ADR-0032's representation table names notify.title/body as the text-template role whose IR is {dialect:'template'}, and Decision 3 deletes the single brace there.
  2. ScreenConfigSchema title / description: narrowed by a new root superRefine with the same judge; recordId, defaults and a field defaultValue keep the single brace (pinned; each hands a resolved value over). The hand-edited ratchet line (dropped-refinements.baseline.json, the root site under automation/ScreenConfig, 679 to 680) is the ledger's designed route for a reviewed new gap, and the refinement reaches the runtime through parseNodeConfig. Right.
  3. EndConfigSchema message on a refused outcome: the judge runs after the required-message refusal, with a return added so the two never double-report; reached at FlowSchema.parse (pinned at nodes.1.config.message). Right.
  4. Value slots untouched. The token grammar moved from flow-value-slot-template.ts into the package-internal flow-template-token.ts byte-identical (TEMPLATE_TOKEN, DATE_MACRO, VARIABLE_PATH, ARITHMETIC_CHARSET, EXPRESSION_SHAPE, celPath, celExpression — compared hunk against hunk); the module is absent from automation/index.ts, rightly, and valueSlotTemplateRefusals still keeps the date macros and $User (pinned). Right.
  5. The two doors (③ and ④): judge, then compile with validateExpression('template', …) at error; a slot the judge refuses skips its compile (continue), so for a text mixing a path token with an unbalanced hole the second finding surfaces on the author's next run — an observation the earlier record made, not a gap: the first sentence is right about the token it names (ADR-0032 §1d).
  6. @objectstack/spec/automation public surface: +6 exports (FLOW_NODE_TEXT_SLOTS, FlowNodeTextSlot, FlowNodeTextSlotSource, TEXT_SLOT_TEMPLATE_REFUSAL, flowNodeTextSlotSources, textSlotTemplateRefusal); api-surface/automation.json and export-origins/automation.json carry exactly those six against main (main's builtinNodeConfigKeysJudged is on both sides, so it is rightly absent from the diff). Right: one judge shared by three contracts, the engine and lint has to be public.
  7. @objectstack/formula PATH_ONLY_RE admits $: a hole-grammar widening for every consumer of the engine; an unbound $ path renders nothing (pinned). Right, at the producer, declared on the engine lane by the seat; graded minor.
  8. @objectstack/service-automation: interpolateText deleted; renderTextSlot, textTemplateScope, FlowTextTemplateError added — package-internal (exports is . alone at the head; neither src/index.ts nor builtin/index.ts re-exports from builtin/template.ts), so the public surface does not move and what publishes is the renderer change. One renderer for the five slots keeps the service-automation: honour outcome: 'refused' on the flow end node — a terminal refused run status (distinct from failed) with the interpolated message persisted on the run (lane 2 of the #14945 ruling 2′) #15788 ruling (the end / screen byte-identity probes are rewritten to engine-specific shapes); textTemplateScope writes only into fresh containers and a declared variable wins over a flat dotted key sharing its head (pinned); FlowTextTemplateError is marked a guard refusal, so a fault edge does not route it (pinned: nothing is sent). Two rendering edges, read off the code and declared in the diff's own text: a slot that renders no text at all returns undefined, so a screen title falls back to the node label (its .describe() says so) and a notify title meets its existing "title is required" guard exactly as a null-resolving token did before; a flow variable named locale is the formatters' locale. Right.
  9. NOTIFY_TEMPLATE_PLACEHOLDER is {{ record.name }}, and the rider 6048446951's three items are delivered: the placeholder; templateExpressionInput and cronExpressionInput added to EXPRESSION_SLOT_TYPES; the stale comment in notify-node.ts rewritten. Right.
  10. Lint: flow-double-brace-interpolation scans the config withoutTextSlots (the five keys by node type), its hint names those five as the only {{ }} positions, and the text-slot bare-$ check reads outside the holes and prescribes the hole; the spec: a notify node's refused title / message is told to write '{{record.name}}', the spelling the build's flow-double-brace-interpolation rule then flags on the same node and the notify renderer does not resolve #22081 round-trip pin's control moved to the node's single-brace actionUrl. Right as built — H4 as an exemption, because ③ already refuses at error and a second reading would double-report.
  11. No D2 conversion; D3 semantic entry flow-text-slot-single-brace-refused plus the step-18 fragment. ADR-0087 D2's scope is "losslessly mappable changes only" and "semantic changes are excluded … it goes to D3"; the measured DIFF rows (a Date rendered JSON-quoted by the 17.x interpolator and as ISO text by the engine; a whole-slot object rendered String(value) against JSON) are pinned in text-slot-template.test.ts, and a metadata conversion cannot know a variable's run-time type. The card's second pin reads with its own "when lossless" qualifier: refused with the exact rewrite. Right.
  12. Canon (ADR-0032 Decision 4) holds at this head. The three expression.zod.ts docblocks (TemplateExpressionInputSchema, tmpl, TYPED_EXPRESSION_SOURCE_REQUIRED) say a notify title / message reads {{var}} and that a single brace there is refused; the canon pin holds them as one set, and the moved expression-dialect-docs.pin.test.ts pins the protocol-18 relation with its anti-vacuity test kept. A sweep of packages/spec/src (non-test) for a single-brace placeholder beside a notify, screen or end text slot finds two lines, both saying it is refused (builtin-node-config.zod.ts:41, expression.zod.ts:511). The .describe()s, the descriptor help in notify-node.ts / screen-nodes.ts and the regenerated references all teach the hole. Right.
  13. The one-stray-brace edge and the lint path reader — right. singleBraceTokens drops a token touching a brace on either side (pinned at the spec function and at the objectstack validate door: invalid-template, no three-brace text); templateRefsIn judges a hole's path before | and reads [i] as .i, pinned both ways.
  14. In-tree sites. A sweep at this head over examples/**, packages/**/src, packages/qa and content/docs (non-test, outside the generated references and skills/**) for a single-brace token in a title / message / description string finds only JS ${…} template literals, the docs line that names the refused form as refused (flows.mdx:2029) and the two showcase value-like positions of ⑥. Right.
  15. Residue standing from the PASS record, unchanged, not conditions: the flows.mdx fault-edge variable table (⑤) still writes {$error} / {$error.message} with no position named — true of every value-like position, and the page's own section teaches the hole for a text slot; stringifyForTemplate's docblock (template.ts about :326-337) still motivates itself with a fault handler's {$error.message} — package-internal, no hover text. Carriers as the PASS record recorded them.

② Semver level

  • Changeset .changeset/22110-flow-text-slot-double-brace.md: @objectstack/spec major, @objectstack/service-automation major, @objectstack/lint major, @objectstack/formula minor; Clause-②: yes (narrowing) in the fixed spelling; the ADR-0087 marker registered flow-text-slot-single-brace-refused names the D3 entry the diff adds. The breaking body carries the FROM / TO table, the one-line fix, the four token kinds' remedies and the measured "not converted" rows, and names no package-internal symbol as public. No skip-changeset. Check Changeset is success on the head.
  • The Clause-②: line agrees in all three carriers. The claim's line was corrected to yes (narrowing) in 6063190355; the PR body's second line is Clause-②: yes (narrowing: …), whose arm clause2-line.mjs reads as narrowing; the changeset carries yes (narrowing).
  • yes is right: the public face widens (+6 spec exports; {{ }} holes now render in five text slots; the formula hole grammar admits $). (narrowing) is right: three node contracts' accept set shrinks on a published authoring surface, and objectstack validate refuses what it accepted. major is right: the four packages are at 17.7.0 on main and .changeset/pre.json is mode: pre, tag: next, so a breaking narrowing grades major on the 18 pre line, as [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939's landed sibling was graded; formula: minor for a pure widening is right. @objectstack/example-showcase, @objectstack/example-todo and @objectstack/dogfood are private: true, so no changeset is owed for them. The merges changed no package this PR publishes from, and the file list carries exactly one added changeset and no modified or deleted one of main's.

③ Boundary flags

  • open_questions: the merge-round report 6076389514 and rounds 2 and 3 declare none. Round 1's A / B stands answered A, judged independently: ADR-0087 D2 admits only lossless mappings and sends semantic changes to D3, the Date / whole-slot rows are pinned, and B would need a maintainer ruling that accepts a rendering change, which nothing pulls for. Answered; not escalated.
  • The merge-round report, each claim read against the head: three merges rather than the two it counts — the report describes its own round, and the earlier bff973d87 was the seat's re-run push (CI note 6069399520); no conflict markers and no regeneration owed — consistent with the empty interdiff and the green generated-artifact gates; the STEP18_RATIONALE union of 107 fragments in id order — verified (②); "merge 2 overlapped no file the branch edits" — right, 3054516ef's four commits touch none of the 54. The report carries no deviations list; its process notes (lock queue-timeouts re-run, a build-first ordering) have no effect on the diff. Not escalated.
  • Rounds 2 and 3 deviations and out_of_scope_findings: judged by the earlier records and standing — the expression-dialect-docs.pin.test.ts move (necessary, anti-vacuity kept), the order-89 tie (allowed), the PR body written by the seat (right under the role file; the ## Patch round 2 / 3 sections are in the body at this head), the sweep's near-misses (context-tokens.zod.ts's {recordId} and the 17.x fixtures in conversions/registry.ts describe value positions and history, still true).
  • Standing Tier H flag: skills/objectstack-automation/SKILL.md :107-109 (a notify title / message example in single braces) and :238-241 (lists notify title / message among the single-brace text slots and calls {{x}} the other dialect), plus evals/flows-triggers-approvals.json:17 — confirmed at this head; text this PR makes false, outside its reach under Prime Directive feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14; the seat's card at landing, which ADR-0032 Decision 4 wants opened before the 18 line leaves pre mode. The Studio editors — triage's objectui round, as the card names; hotcrm's 69 tokens — the repo:hotcrm seat; AutomationEngine.celScope nesting and the README's stale field-value row — pre-existing, not this card.
  • Serial: [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939 pass 1 (0e9e7b706) and fix(spec,service-automation)!: an undeclared config key on 10 more builtin node types is refused at the build doors; one judge per type #22319 (2f70c2222) are ancestors of the merge base 3054516ef; the [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939 pass-2 overlap is declared serial by the claim. Right.
  • Governed surfaces: none in the 54-file list (no path under docs/adr/, docs/NORTH-STAR.md, .claude/, skills/, AGENTS.md or CLAUDE.md); Governed Surface Queue Guard success; 2261 changed lines, under the 5,000 threshold; head repo is the base repo; draft.
  • Check-runs on 4e275f15d, 35, all completed: 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure, 0 pending; the combined commit status is success (Vercel). The seven required contexts are all success: Lint & Repo Gates (the ci(pm): check:pm-dispatch-gates is red on main — its live-tree case reads mkdtempSync(join(process.cwd(), …)) in a #22365 dogfood test as an unresolved temp base #22422 self-test red of f84cd82d2 is cleared by 27a8b33de in this head), TypeScript Type Check (with the spec generated-artifact gates), Test Core and its six shards ((2/6) green; (6/6)'s timing-drift step green against the dataset chore(ci): refresh the Test Core shard-timings dataset #22368 refreshed), Dogfood Regression Gate and its three, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Those conclusions are the gate verdicts; nothing was run here. No check is red or pending on this head.

Implemented-by: claude/issue-22110-flow-text-slot-double-brace
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: PASS

The three merges changed nothing this PR refuses or prescribes: its delta is byte-identical to the PASS-judged one, and in each of the seven files both sides edited, main's changes sit in different passes, methods or paragraphs from this PR's — above all, #22380's builtin key-walk stand-aside in validateNodeConfigKeys does not reach the text-slot judge in validateFlowExpressions, and #22392's option verdicts walk objects, not flows. Every accept-set and public-surface change is right at this head, the semver reading stands across the three carriers, no boundary flag escalates, and every check on the head is green. The Tier H skills residue is the seat's card at landing.


Generated by Claude Code

…ow-text-slot-double-brace

Conflict in packages/lint/src/validate-expressions.test.ts resolved as the union of
both sides' additions to the PLUMBING receiver-excuse set (the branch's slot entry and
main's five EvalUser member-list names); no case dropped, no assertion changed.

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

Inputs, and nothing else: card #22110 (body and all sixteen comments — the claim 6059160333, the rulings 6063190355 / 6063702421 / 6065501303, the five dev reports including the merge reports 6076389514 and 6077810372, and the three seat ACCEPTs), PR #22315 (body, the 54-file list, and the net diff 05c7c3fa3..ce4f6519f, +1854 / −407, 2261 changed lines — the merge base of this head against origin/main is the head's own main parent 05c7c3fa3, so that diff is the net diff), the four earlier ## Contract review records on this PR (6063558021 FAIL, 6065484352 FAIL, 6068757622 PASS at 5bfa9ae1f, 6076666309 PASS at 4e275f15d) with the seat's CI notes, and the 35 check-runs plus the combined commit status on the head, read at 2026-10-09T09:10Z. The head was fetched into a private ref (refs/review/pr-22315-d) and read with git show / git diff / git grep; the shared checkout was not touched (0 dirty paths before and after); nothing was built, run or re-run. A whole-PR review at this head by an isolated subagent of the seat session named below; the dispatch order and the seat's own conclusions were not inputs.

① Derived judgments

This head against the PASS head 4e275f15d — what the one merge changed. ce4f6519f is one merge of origin/main 05c7c3fa3 (7 commits, #22427 = 46692c118 among them) onto 4e275f15d, with one conflict. Each read at the head, not taken from the merge report:

  1. The PR's own delta is unchanged. The sorted +/− line multiset of git diff -U0 3054516ef 4e275f15d (the PASS head against its merge base) equals that of git diff -U0 05c7c3fa3 ce4f6519f — 2261 lines each, an empty interdiff; the 54-file set is the same, and GitHub's file list for the PR is that same set; git range-diff shows = for all 17 commits. So nothing this PR adds or removes moved, and what remains is whether main's edits to the files both sides touched combine with it correctly.
  2. packages/lint/src/validate-expressions.ts — right, and the question the dispatch asked. lint/objectql: a select option's visibleWhen reading current_user.roles (gone since ADR-0090 D3) or a computed key passes both doors and faults open at the server — the EvalUser-member level of the #22157 / #22274 family #22394's option-visibleWhen member verdicts, brought by fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads an unbound member of the acting user (#22394) #22427, are the helpers at about :1118–:1480 (OPTION_VISIBLE_WHEN_USER_ROOTS, OPTION_CHECK_ACTING_USER, the memoised optionVisibleWhenUserMembers, tickedList, membersReadUnder / membersReadThrough, optionVisibleWhenNamespaceMemberIssue, optionVisibleWhenUserMemberIssue), called from the OBJECTS' option pass (optionVisibleWhenRootIssue at about :2506), plus a docblock sentence in StackExpressionOptions and one comment. This PR's text-slot pass (about :2188–:2198) sits in the FLOWS' node walk, directly after flowNodeValueTemplateRefusals. Disjoint passes over different metadata. Both import blocks are present at the head (buildScope :87, RelationshipTraversalAnalysis :89, EvalUserSchema :121; flowNodeTextSlotSources / textSlotTemplateRefusal :96–:97). Between the node-loop head (:2070) and the text-slot pass the only continues are the three inside the declared-slot sub-loop (for (const found of resolveFlowNodeExpressions…)), so no path main added skips the judge. validateStackExpressions is still registered commands: ALL (authoring-rules.ts about :557–:560), so validate, build and lint all refuse the single brace. Nothing this PR refuses or prescribes at the build door changed.
  3. packages/lint/src/validate-expressions.test.ts — the one conflict, resolved right. The hunk is the PLUMBING receiver-excuse set of the receiver-scan meta-test (about :3414), not a test case. At the head the set carries this PR's 'slot' entry (with its four-line comment) AND main's five ('declaredUserMembers', 'boundUserMembers', 'listedNames', 'tickedNames', 'membersRead', with theirs); no entry is duplicated; the file's delta against 05c7c3fa3 is exactly the five-line 'slot' hunk, and against 4e275f15d it is main's 210 added lines (the lint/objectql: a select option's visibleWhen reading current_user.roles (gone since ADR-0090 D3) or a computed key passes both doors and faults open at the server — the EvalUser-member level of the #22157 / #22274 family #22394 describe and its seven cases). Order-free set, no assertion changed, each side's excuse kept — the union.
  4. The other overlap files — right. content/docs/automation/flows.mdx: the "Text slots read double-brace holes" section (:284), its link definition (:330), the dialect-table row (:1998) and callout item 4 (:2029) are intact; main's 403 row and trigger-route rewording sit outside them. examples/app-showcase/src/automation/flows/index.ts: main re-typed InquiryPurgeFlow to type: 'screen'; its one notify carries a token-free title / message and it has no screen node, so no text-slot judge reaches it; the 23 text-slot rewrites are intact, and the one single-brace message a grep finds (:738) is a subflow.input value. packages/spec/src/migrations/registry.ts: STEP18_RATIONALE is 107 fragments, ids C-sorted over the whole list; this branch's flow-text-slot-single-brace-refused (order 89) sits between flow-script-subflow-config-undeclared-keys-refused and flow-value-slot-template-dialect-refused; the D3 entry is present once in each list. The merge report's "no regeneration owed" is consistent with the empty interdiff and with check:generated running green inside TypeScript Type Check.

The whole PR's accept-set and public-surface changes, each confirmed at this head — re-read here rather than adopted from the earlier PASS records:

  1. NotifyConfigSchema title / message (bare string or template envelope source): narrowed — inside the existing superRefine, after the blank-envelope refusal and its continue, textSlotTemplateRefusal(value.source) adds a custom issue at the key; {{ }} holes, a $-named path and a formatter pass. Pinned in io-node-config.test.ts for both spellings, the message led by TEXT_SLOT_TEMPLATE_REFUSAL and naming the hole spelling. Right: ADR-0032's representation table names notify.title/body as the text-template role, and Decision 3 deletes the single brace there.
  2. ScreenConfigSchema title / description: narrowed by a new root superRefine with the same judge; recordId, defaults and a field defaultValue keep the single brace (pinned; each hands a resolved value over). The hand-edited ratchet line (dropped-refinements.baseline.json, the "" root site under automation/ScreenConfig, 679 to 680) is the ledger's designed route for a reviewed new gap, and the refinement reaches the runtime through parseNodeConfig. Right.
  3. EndConfigSchema message on a refused outcome: the judge runs after the required-message refusal, with a return so the two never double-report; reached at FlowSchema.parse (pinned at nodes.1.config.message). Right.
  4. Value slots untouched. The token grammar moved from flow-value-slot-template.ts into the package-internal flow-template-token.ts byte-identical (TEMPLATE_TOKEN, DATE_MACRO, VARIABLE_PATH, ARITHMETIC_CHARSET, EXPRESSION_SHAPE, celPath, celExpression, compared hunk against hunk); the module is absent from automation/index.ts, rightly, and valueSlotTemplateRefusals still keeps the date macros and $User (pinned). Right.
  5. The two doors. registerFlow calls validateNodeConfigKeys (:4412) and validateFlowExpressions (:4424); the text-slot pass (:10735) lives in the latter, inside the collectFlowGraphs walk, so region nodes are judged with their scope prefix; the builtinNodeConfigKeysJudged stand-aside (:10347) lives in the former and does not reach it. Both doors judge, then compile with validateExpression('template', …) at error; a slot the judge refuses skips its compile (continue), so for a text mixing a path token with an unbalanced hole the second finding surfaces on the author's next run — an observation, not a gap: the first sentence is right about the token it names (ADR-0032 §1d). Right.
  6. @objectstack/spec/automation public surface: +6 exports (FLOW_NODE_TEXT_SLOTS, FlowNodeTextSlot, FlowNodeTextSlotSource, TEXT_SLOT_TEMPLATE_REFUSAL, flowNodeTextSlotSources, textSlotTemplateRefusal); api-surface/automation.json and export-origins/automation.json carry exactly those six against main. Right: one judge shared by three contracts, the engine and lint has to be public.
  7. @objectstack/formula PATH_ONLY_RE admits $: a hole-grammar widening for every consumer of the engine; an unbound $ path renders nothing (pinned). Right, at the producer, declared on the engine lane by the seat; graded minor.
  8. @objectstack/service-automation: interpolateText deleted; renderTextSlot, textTemplateScope, FlowTextTemplateError added — package-internal (the exports map is . alone at the head; neither src/index.ts nor builtin/index.ts re-exports from builtin/template.ts), so the public surface does not move and what publishes is the renderer change. One renderer for the five slots keeps the service-automation: honour outcome: 'refused' on the flow end node — a terminal refused run status (distinct from failed) with the interpolated message persisted on the run (lane 2 of the #14945 ruling 2′) #15788 ruling; textTemplateScope writes only into fresh containers and a declared variable wins over a flat dotted key sharing its head (pinned); FlowTextTemplateError is marked a guard refusal, so a fault edge does not route it (pinned: nothing is sent). A slot that renders no text returns undefined, so a screen title falls back to the node label and a notify title meets its existing "title is required" guard. Right.
  9. NOTIFY_TEMPLATE_PLACEHOLDER is {{ record.name }}, and the rider 6048446951's three items are delivered: the placeholder; templateExpressionInput and cronExpressionInput in EXPRESSION_SLOT_TYPES (check-doc-formula-expressions.mjs:1013–:1014); the stale comment in notify-node.ts rewritten. Right.
  10. Lint: flow-double-brace-interpolation scans the config withoutTextSlots (the five keys by node type), its hint names those five as the only {{ }} positions, and the text-slot bare-$ check reads outside the holes and prescribes the hole; the spec: a notify node's refused title / message is told to write '{{record.name}}', the spelling the build's flow-double-brace-interpolation rule then flags on the same node and the notify renderer does not resolve #22081 round-trip pin's control moved to the node's single-brace actionUrl. Right as built — H4 as an exemption, because ⑨ already refuses at error and a second reading would double-report.
  11. No D2 conversion; D3 semantic entry flow-text-slot-single-brace-refused plus the step-18 fragment. ADR-0087 D2 admits losslessly mappable changes only and sends semantic changes to D3; the measured DIFF rows (a Date rendered JSON-quoted by the 17.x interpolator and as ISO text by the engine; a whole-slot object rendered String(value) against JSON) are pinned in text-slot-template.test.ts, and a metadata conversion cannot know a variable's run-time type. The card's second pin reads with its own "when lossless" qualifier: refused with the exact rewrite. Right.
  12. Canon (ADR-0032 Decision 4) holds at this head. The three expression.zod.ts docblocks (TemplateExpressionInputSchema, tmpl, TYPED_EXPRESSION_SOURCE_REQUIRED) say a notify title / message reads {{var}} and that a single brace there is refused; the canon pin holds them as one set, and the moved expression-dialect-docs.pin.test.ts pins the protocol-18 relation with its anti-vacuity test kept. A sweep of packages/spec/src (non-test) for a single-brace placeholder on a line naming a notify, screen or end text slot finds only lines saying it is refused (builtin-node-config.zod.ts:41, expression.zod.ts:511), the graduated 11.x alias fixtures in conversions/registry.ts, and a script.inputs value. The .describe()s, the descriptor help in notify-node.ts / screen-nodes.ts and the regenerated references all teach the hole. Right.
  13. The one-stray-brace edge and the lint path reader — right. singleBraceTokens drops a token touching a brace on either side (pinned at the spec function and at the objectstack validate door: invalid-template, no three-brace text); templateRefsIn judges a hole's path before | and reads [i] as .i, pinned both ways.
  14. In-tree sites. A sweep at this head over examples/**, packages/**/src, packages/qa and content/docs (non-test, outside the generated references and skills/**) for a single-brace token in a title / message / description string finds only the docs line that names the refused form as refused (flows.mdx:2029), the showcase subflow.input value of ④, descriptor help of value-like keys (crud-nodes.ts:702, screen-nodes.ts:125 / :150) and the 11.x fixtures. Right.
  15. Residue standing from the earlier PASS records, unchanged, not conditions: the flows.mdx fault-edge variable table (:1624) still writes the error variables single-braced with no position named — true of every value-like position, and the page's own section teaches the hole for a text slot; stringifyForTemplate's docblock (template.ts about :332–:335) still motivates itself with a fault handler's single-brace $error path — package-internal, no hover text. Carriers as recorded before.

② Semver level

  • Changeset .changeset/22110-flow-text-slot-double-brace.md: @objectstack/spec major, @objectstack/service-automation major, @objectstack/lint major, @objectstack/formula minor; the fixed spelling Clause-②: yes (narrowing); the ADR-0087 marker registered flow-text-slot-single-brace-refused names the D3 entry the diff adds. The breaking body carries the FROM / TO table, the one-line fix, the four token kinds' remedies and the measured "not converted" rows, and names no package-internal symbol as public. No skip-changeset. Check Changeset is success on the head.
  • The Clause-②: line agrees in all three carriers. The claim's line was corrected to yes (narrowing) in 6063190355; the PR body's second line is Clause-②: yes (narrowing: …), whose arm scripts/pm/clause2-line.mjs readArmToken reads as narrowing — its exact pattern takes the arm word and refuses only an identifier character or a pipe after it, and the colon is neither; the changeset carries yes (narrowing).
  • yes is right: the public face widens (+6 spec exports; {{ }} holes now render in five text slots; the formula hole grammar admits $). (narrowing) is right: three node contracts' accept set shrinks on a published authoring surface, and objectstack validate refuses what it accepted. major is right: the four packages are at 17.7.0 on main and .changeset/pre.json at 05c7c3fa3 is mode: pre, tag: next, so a breaking narrowing grades major on the 18 pre line, as [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939's landed sibling was graded; formula: minor for a pure widening is right. @objectstack/example-showcase, @objectstack/example-todo and @objectstack/dogfood are private: true, so no changeset is owed for them. The merge changed no package this PR publishes from, and the file list carries exactly one added changeset and no modified or deleted one of main's.

③ Boundary flags

Implemented-by: claude/issue-22110-flow-text-slot-double-brace
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: PASS

The merge changed nothing this PR refuses or prescribes: its delta is byte-identical to the one the PASS records judged, and in validate-expressions.ts #22394's option-member verdicts walk objects while this PR's text-slot pass walks flows, with both import sets present and no new short-circuit between the node-loop head and the judge; the one conflict, the PLUMBING set, is the verbatim union of both sides. Every accept-set and public-surface change is right at this head, the semver reading stands across the three carriers, no boundary flag escalates, and every check on the head is green. The Tier H skills residue is the seat's card at landing.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 09:12
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 09:12
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 2b61f2d Oct 9, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22110-flow-text-slot-double-brace branch October 9, 2026 09:41
os-tesla pushed a commit that referenced this pull request Oct 9, 2026
… merging main at 3ca71b6 (step 18: 64 conversions, 327 semantic entries)

main added one step-18 semantic entry since bf492c8:
flow-text-slot-single-brace-refused (#22315). At protocol 18 both
generators project every step-18 entry, so both documents gain it, and
the guide's 17 -> 18 intro paragraph carries its summary. The conversion
ids are unchanged.

Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
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

2 participants