Skip to content

fix(spec): a notify title / message refusal prescribes the single-brace {record.name} its renderer reads - #22124

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22081-notify-refusal-brace-prescription
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22081-notify-refusal-brace-prescription

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22081

Clause-②: no

A notify flow node's title / message refusal now prescribes '{record.name}', the single-brace spelling the notify executor reads. It used to answer with the shared template sentence, which prescribes '{{record.name}}'. The build's flow-double-brace-interpolation rule then flagged that spelling on the same node, and the notify renderer sent it inside a stray pair of braces. Every other template slot keeps its sentence. Triage grade 6039475060, as amended by 6042027032 and 6042955917. Rework round 1 (6046879490) applied: @objectstack/spec's public face does not move.

What changed

  • Per-slot prescription, not a both-conventions sentence. The refusing site can know the slot. The notify title / message are declared in NotifyConfigSchema (packages/spec/src/automation/io-node-config.zod.ts). That declaration can carry its own sentences, so the ruling's both-conventions fallback is not needed. The union's message comes from the schema built at the slot. The build's flow judge (flowNodeConfigRefusals, which FlowSchema, registerFlow and os validate share) quotes that same message.
  • A package-internal constructor, packages/spec/src/shared/typed-expression-input.ts. shared/index.ts and the root barrel do not re-export it; refinement-projection.ts is the precedent. It holds cronExpressionInput(ExpressionSchema, refusals) and templateExpressionInput(ExpressionSchema, refusals) over one private union builder. The accept set is fixed by the dialect, and the refusal sentences are an argument. CronExpressionInputSchema and TemplateExpressionInputSchema are built from it with their shared sentences. TYPED_EXPRESSION_SOURCE_REQUIRED and TYPED_EXPRESSION_DIALECT_ONLY are byte-unchanged. The notify slots are built from it with their own sentences.
    • Cycle avoidance. expression.zod.ts imports the module at runtime, so the module must not import expression.zod.ts back. It imports ExpressionSchema and TypedExpressionDialect as TYPES only (import type, erased), and the caller passes ExpressionSchema in. Each dialect's envelope arm is still spelled once, inside the module.
    • Why two constructors, not one generic. A single generic typedExpressionInput(expression, dialect, …) was measured first. Its expression.safeExtend({ dialect: z.literal(dialect) }) fails type-checking, because a ZodLiteral over a generic dialect D is not provably assignable to the ExpressionDialect key it narrows (TS2322). So each constructor narrows with a concrete literal.
    • Public face measured unchanged. The declarations tsc emits for CronExpressionInputSchema, TemplateExpressionInputSchema and their input types are byte-identical at the merge base and at HEAD (26 lines each, tsc exit 0 both). expression.zod.d.ts exports the same 31 names, and neither constructor appears in it.
  • The string arm carries the slot's sentence too, not only the union. formatZodIssue and the API error mapper expand an invalid_union's branches beneath its own line. A branch that still named the shared sentence would print '{{record.name}}' under the right prescription. The leg-2 ablation below measures this.
  • The notify sentences (notifyTemplateRefusals(key), io-node-config.zod.ts) name the key and prescribe '{record.name}' or { dialect: 'template', source: '{record.name}' }. They also say why: the notify executor interpolates single-brace {token} placeholders, and a doubled brace keeps its outer braces. They are spelled with no doubled brace at all.
  • Docblocks. The tmpl docblock no longer says "Mustache-template" or shows only {{record.x}}. It now has one bullet per spelling, naming the renderer behind each: {{record.x}} for the formula template engine and the messaging, email and i18n renderers; {record.x} for a notify node's title / message, rendered by the flow interpolator; either for titleFormat. The TemplateExpressionInputSchema docblock gains the notify bullet. Its closing advice changes from "write {{var}} unless the renderer normalizes" to "write the spelling the slot's renderer reads".
  • Regenerated: content/docs/references/shared/expression.mdx. The module header now says a typed slot may carry its own refusal sentences, and it does not name the constructor. packages/spec/api-surface/** and packages/spec/export-origins/** are byte-identical to the merge base: git diff --stat 54ace18c6 HEAD -- packages/spec/api-surface packages/spec/export-origins prints nothing.

The one place the notify prescription lives

NOTIFY_TEMPLATE_PLACEHOLDER = '{record.name}' in packages/spec/src/automation/io-node-config.zod.ts. Both notify refusal sentences read it, and so does the existing blank-envelope source refusal (notifyTemplateSourceRequired, byte-identical output). When #22110 flips the notify convention on the v18 line, this constant is the prescription that flips with it. The slot .describe() prose explains the renderer and is rewritten by that card in any case. Per amendment 6042955917, this card does not wait on #22110.

Measurements

Before (a543e244f, read from packages/spec/src with tsx; reproduces the filer's readings on d4680d2820):

  • NotifyConfigSchema title = ' ' / '': invalid_union, TYPED_EXPRESSION_SOURCE_REQUIRED.template, ending Write '{{record.name}}' or { dialect: 'template', source: '{{record.name}}' }. title = 42 or a cel envelope: invalid_union, TYPED_EXPRESSION_DIALECT_ONLY.template, same ending. message gives the same. flowNodeConfigRefusals('notify', …) quotes it (node-config-refused-by-contract), and a doubled brace appears in every refusal tree.
  • lintFlowPatterns: both shared prescriptions draw one flow-double-brace-interpolation each on a notify title. This is now the control leg of the lint pin below, and it is green on this branch.

After (HEAD 681d77223): the same eight notify cases answer invalid_union with the notify sentence. '{record.name}' is prescribed, and no doubled brace appears in the union message, in any branch issue, or in the flow judge's message. The shared schema's two sentences are unchanged. The probe output is byte-identical to round 0's, so moving the constructor changed no refusal.

Pins

  • packages/spec/src/automation/io-node-config.test.ts: refusal code plus prescribed spelling plus named key, for blank values and for non-template values on both keys; no doubled brace anywhere in the issue tree; the flow judge quotes the prescription; control: the shared sentences still prescribe '{{record.name}}'.
  • packages/spec/src/shared/typed-expression-envelope-dialect.test.ts: templateExpressionInput, imported from the internal module, accepts exactly what TemplateExpressionInputSchema accepts and parses to the same value; it refuses with the given sentence by kind, and those sentences are the only refusal text; control: PromptTemplate.user, which keeps the shared input, still refuses with the '{{record.name}}' sentences. The existing objects.0.titleFormat pin stays. Keeping the constructor off the public face is held by check:api-surface, which fails on any added export.
  • packages/spec/src/shared/expression-dialect-docs.pin.test.ts: the tmpl docblock has a {{record.x}} bullet naming messaging and email (not notify), and a {record.x} bullet naming notify and flow-double-brace-interpolation (not messaging or email). The pin checks which renderer goes with which spelling, not the wording.
  • packages/lint/src/lint-flow-patterns.test.ts: lint round trip. Each spelling the notify refusal prescribes is read out of the refusal text, not re-typed in the test. Each one parses through NotifyConfigSchema and FlowSchema and draws no flow-double-brace-interpolation. Control: the shared sentence's two prescriptions each draw the finding.
  • packages/qa/dogfood/test/expression-conformance.test.ts: cronExpressionInput and templateExpressionInput join the census roster. Without them, NotifyConfigSchema.title / .message would drop out of discovery and template-notify-content would go STALE. Both positions stay head-discovered. The constructors' own definitions live in a plain .ts module that the .zod.ts scan never reads.

Ablation (one-time, at 681d77223; scripts/ablation-replace.mjs wrap mode under a restore trap; spec tests resolve src through relative imports, so no rebuild was needed):

  • Leg 1: NOTIFY_TEMPLATE_PLACEHOLDER set back to '{{record.name}}' (anchor 1 → 0, blob 9f53bb6cfb5c → 05ffd58adbd3). io-node-config.test.ts: 3 failed / 33 passed. Restored: blob == HEAD, git diff HEAD empty.
  • Leg 2: the internal module's string arm carries a doubled-brace sentence instead of refusals.sourceRequired (blob 6222e3e7f5be → 0b6ae37053ed). 2 failed / 73 passed, both "no doubled brace in the tree" pins. Restored: blob == HEAD, git diff HEAD empty. The trap re-verified both blobs.
  • The lint round trip was not ablated by rebuilding @objectstack/spec. Its control leg shows the rule flags the shared prescriptions on the same flow shape.

Local verification (HEAD 681d77223)

Each run went through the shared verify lock; the verdict line is quoted.

  • pnpm --filter '@objectstack/lint...' build: dependency closure plus lint, VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec test: 622 files, 18587 passed, 1 todo.
  • pnpm --filter @objectstack/spec typecheck: exit 0. The test layer holds its debt ledger: 52 files, 246 errors, 135 pinned signatures, no new ones.
  • pnpm --filter @objectstack/lint test: 123 files, 5680 passed. The round-trip pin alone (-t "notify slot refusal"): 3 passed.
  • pnpm --filter @objectstack/lint typecheck: exit 0. Test layer: 2 files, 6 errors held.
  • pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/expression-conformance.test.ts: 7 passed. The first attempt answered Failed to resolve entry for package "@objectstack/verify", an unbuilt prerequisite, so it was NOT MEASURED. It was re-run after the gate pass had built the workspace.
  • pnpm --filter @objectstack/spec check:generated: All 15 generated artifacts are up to date after gen:api-surface, gen:export-origins and gen:docs. Those three were the only artifacts it proved stale. The first two now regenerate to the merge base's bytes.
  • eslint --no-inline-config --format json over the 8 touched TS files: 8 files, 0 errors, 0 warnings, exit 0. Every file resolves a config (--print-config). eslint.config.mjs sets no parserOptions.project / projectService, so the linting is not type-aware and a verdict on an untouched file cannot move with this diff. This is a narrowing; the repo-wide pnpm lint is CI's.

Session: session_01RPo7FUd6bSnAfkWMAKi848 (PM dispatch, claim 6044705682).

Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 109 commands at 681d77223, run one at a time with each exit code captured before any pipe and reconciled with --ran: 109 of 109 exit 0. Three of them first answered exit 3, PREREQUISITE NOT MET, because their packages were unbuilt: check:skill-examples, check:dual-build-cjs-loads and check:lean-entry-closure. They were re-run once check:type-check-debt's re-measure had built the workspace, and each exited 0. --ran with the final coded record: 109 derived famil(ies) accounted for — 109 run, 0 NOT-MEASURED (a DERIVED zero — all 109 recorded an exit code and none of them is 3).

Not measured locally (declared to CI): @objectstack/service-automation. Its notify tests assert refused at \title`and the executor's contract wording, never the template sentence. Also the whole-workspace type-check lanes, the path-scheduled CI jobs, and the repo-widepnpm lint`.

origin/main (54ace18c6) is merged in with a merge commit via scripts/pm/os-regen-merge.sh. Main moved one merge=os-regen path, scripts/platform-object-tenancy-census.json, which this branch never edited. The merge brought no other overlap with this diff.

Acceptance notes

  • Triage's control pin names "an email or messaging template slot". No spec slot of either kind is typed with the template input: EmailTemplateDefinition.subject / bodyHtml are z.string(), and messaging declares none. The control is taken at the two slot families that keep the shared input, PromptTemplate.system / user (described as {{var}}) and Object.titleFormat (renderers take either).
  • packages/lint/scripts/check-doc-formula-expressions.mjs, EXPRESSION_SLOT_TYPES tripwire, re-read after the move: still dormant. The tripwire fires only on a property assignment that carries @example JSDoc tags and whose initializer names one of the four public schema names. NotifyConfigSchema carries zero @example tags, and its title / message initializers (templateExpressionInput(ExpressionSchema, …)) name none of the four. So it is blind to those two slots as before the move, and nothing is there for it to see. Noted, not filed.
  • service-automation/src/builtin/notify-node.ts has a comment that still calls the two slots TemplateExpressionInputSchema. The input is identical; only the constructor's sentences differ. Comment only, outside domain:spec, noted.

claude added 3 commits October 7, 2026 19:05
… spelling its renderer reads

The shared template input's refusals prescribe '{{record.name}}', which the
notify executor's flow interpolator leaves inside a stray pair of braces and
the build's flow-double-brace-interpolation rule flags. The notify slots are
now built with templateExpressionInput and refuse with sentences prescribing
'{record.name}', kept in one constant; every other template slot keeps its
sentence. The tmpl and TemplateExpressionInputSchema docblocks say which
renderers read which braces.

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…int round trip and the shared control

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…n reference page

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

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

16 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

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

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 54ace18c669a776c7e849708039c7876ac534cee

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

claude added 3 commits October 7, 2026 21:09
The constructor the notify title/message share with TemplateExpressionInputSchema
moves out of expression.zod.ts (which shared/index.ts re-exports) into
shared/typed-expression-input.ts, which no barrel re-exports, so the public face
does not widen. ExpressionSchema is passed in rather than imported, which keeps
the module free of a runtime cycle. The changeset drops its export bullet.

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…n reference page

api-surface/ and export-origins/ return to the merge base byte for byte; the
reference page no longer names the internal constructor.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 681d77223e45405c820fbbf022b865986db8abc1
Local-runs: none

Inputs read, and nothing else: card #22081 (body and all 8 comments: triage grade 6039475060 with amendments 6042027032 and 6042955917, claim 6044705682, dev reports 6046827692 and 6048233920, REWORK round 1 6046879490, ACCEPT 6048258709); PR #22124 (body, the 10-file list, its one comment, and the net diff of the head against main at merge base 54ace18c6); the head's check-runs. Files cited below were read at the head over the REST contents API. The PR object named this head at the start and at the end of the read.

① Derived judgments

Accept set (every input that parsed before parses after; every input refused before is refused after):

  1. NotifyConfigSchema.title / .message move from TemplateExpressionInputSchema.optional() to templateExpressionInput(ExpressionSchema, notifyTemplateRefusals(key)).optional(). The constructor builds the same two arms — a z.string() refined by NON_BLANK_STRING and transformed to { dialect: 'template', source }, and ExpressionSchema.safeExtend({ dialect: z.literal('template') }) — under the same union error map keyed on typeof issue.input === 'string'. Only the sentences move; the refusal code (invalid_union) and path are unchanged, and the source-or-ast refusal the envelope arm still raises on its own is pinned unchanged. Right.
  2. CronExpressionInputSchema and TemplateExpressionInputSchema are now cronExpressionInput(ExpressionSchema, typedExpressionRefusals('cron')) / templateExpressionInput(ExpressionSchema, typedExpressionRefusals('template')), where typedExpressionRefusals reads the two existing TYPED_EXPRESSION_* records. The deleted typedExpressionStringArm / typedExpressionUnionParams are reproduced inside the constructor arm for arm. The two shared template sentences are byte-unchanged in the diff. The string arm's refine message is the same record entry it read before. Right — a pure relocation.
  3. notifyTemplateSourceRequired (blank-envelope refusal) now interpolates NOTIFY_TEMPLATE_PLACEHOLDER, whose value is the literal it replaced, so the output string is byte-identical. Right.
  4. The notify sentences prescribe '{record.name}' and { dialect: 'template', source: '{record.name}' }, name the key, and carry no doubled brace anywhere (the NOTIFY_TEMPLATE_RENDERER sentence describes the doubled brace without spelling one). The lint round-trip pin in packages/lint reads both prescriptions out of the refusal text by regex (the envelope regex requires source: ', so it skips the earlier { dialect: 'template', source } span and lands on the prescription) and parses each through NotifyConfigSchema and FlowSchema, drawing no flow-double-brace-interpolation. This is the card's measured defect, closed in the direction triage ruled: the refused notify slot prescribes the notify convention; the shared sentence and both renderers are untouched. Right.
  5. No .describe() string, JSON Schema, authorable key or error-code ledger entry moves: union error maps and refine messages are not emitted into JSON Schema, the file list carries no json-schema/** or authorable-surface/** path, and check:generated (inside the green TypeScript Type Check job) reports the artifacts current. Right.

Public face (judged on the exports map, per the contract-review rule that an unaddressable .d.ts is shipped bytes, not published surface):

  1. New module packages/spec/src/shared/typed-expression-input.ts exports cronExpressionInput, templateExpressionInput and the interface TypedExpressionRefusals. At the head, shared/index.ts re-exports ./expression.zod and not this module; the root barrel does not name it; package.json exports has no wildcard subpath (only ./shared and the other fixed entries). packages/spec/api-surface/shared.json and export-origins/shared.json have the same blob ids at the head as at the merge base (cf260910f154… and 33e5c6b4bbae…), and neither names any of the three; the precedent the dev cites holds (NON_BLANK_STRING is likewise absent). Not published — right. The round-0 widening (templateExpressionInput on @objectstack/spec/shared) that REWORK 6046879490 refused is cured at this head.
  2. TypedExpressionRefusals is imported type-only into the two .zod.ts files and used only by a non-exported helper in expression.zod.ts; the exported schemas' declaration types are structural ZodUnion shapes that do not reference it. The dev's measurement that the emitted .d.ts for the two schemas and their input types is byte-identical is theirs; what this review relies on is that check:api-surface reads the built dist/*.d.ts and is green inside TypeScript Type Check. Right.
  3. The shipped src/**/*.zod.ts sources now import one more plain .ts sibling absent from the tarball. expression.zod.ts already imported ./refinement-projection, ./lazy-schema and ./strict-object the same way at the merge base, so no new hole is opened. Right.
  4. The two .zod.ts files import ../shared/typed-expression-input at runtime; that module imports ./expression.zod as types only, so the runtime import graph gains one edge and no cycle. Right.

Docs and instruments the diff implies:

  1. content/docs/references/shared/expression.mdx is AUTO-GEN and the regenerated paragraph is the module-header docblock verbatim; check:docs is green. The tmpl docblock drops "Mustache" and lists one bullet per spelling naming its renderers; the pin test checks the relation (which renderer sits against which spelling), not the wording. The TemplateExpressionInputSchema docblock now says {{var}} is this schema's slots' spelling and names the notify exception. Right.
  2. The dogfood census roster (EXPRESSION_INPUT_SCHEMAS) gains the two constructor names. The roster's own header requires a narrowed or renamed declaring schema to be listed on the same commit, or the template-notify-content ledger row goes STALE; Dogfood Regression Gate is green, so both notify positions are still discovered. Right, and owed by the roster, not optional.
  3. ADR-0032 D3 ("one delimiter, double braces") is contradicted by what the notify renderer, the lint rule and 71 of 73 in-repo templates already do; this PR aligns a refusal sentence to the shipped renderer and changes neither renderer nor rule. Triage amendments 6042027032 / 6042955917 rule it the 17.x prescription and file [v18] flow text slots: read ADR-0032 §3's {{ }} delimiter instead of single-brace {token} (notify title/message and the other flow string slots), converting only what renders the same #22110 to execute D3 on the v18 line; the code names the ADR, the card and the one constant that flips. Under Prime Directive [WIP] Add Chinese version of the documentation #13 this is a declared, recorded, scheduled deviation, not a changeset quietly doing the opposite. Right under the ruling — the record holds that it stays a deviation until [v18] flow text slots: read ADR-0032 §3's {{ }} delimiter instead of single-brace {token} (notify title/message and the other flow string slots), converting only what renders the same #22110 lands.
  4. Triage's three pins are all present: (a) each prescribed notify spelling parses and draws no finding (lint-flow-patterns.test.ts); (b) the control — triage named "an email or messaging template slot", none of which is typed with the template input (the dogfood ledger carries exactly three template rows: template-prompt, template-title-format, template-notify-content), so the control is taken at PromptTemplate.user and Object.titleFormat, the two families that keep the shared sentence; (c) the tmpl docblock relation pin. Right.

② Semver level

  • .changeset/22081-notify-refusal-single-brace.md: "@objectstack/spec": patch, body line Clause-②: no, no (widening) / (narrowing) arm. What the diff publishes from a released package is a changed refusal sentence on two slots plus docblock prose — a bug fix in a released package, so patch (never none, never skip-changeset) is the level the Post-Task Checklist prescribes. No export is added, removed or renamed; no type changes; the accept set is unchanged (①.1–①.6). Patch matches the diff.
  • The Clause-②: no line on the claim 6044705682, in the PR body and in the changeset is read against both limbs: no accept-set widening (①.1–①.3) and no public-face expansion (①.6). It was false at round 0's head and is true at this head. Holds.
  • The changeset's sentences read against the diff: "every value that parsed still parses … same invalid_union code at the same path" (①.1); the shared TYPED_EXPRESSION_*.template sentences unchanged (①.2); "no export is added, removed or renamed, and no type changes" (①.6–①.7); the tmpl and TemplateExpressionInputSchema docblock claims (①.10). Each holds.
  • @objectstack/lint and @objectstack/dogfood are touched test-only; no changeset is owed for either.

③ Boundary flags

open_questions is [] in both dev reports — nothing to answer there. The dev's flags (deviations, out-of-scope findings and the PR's Acceptance notes), each answered:

  1. Two test-only files outside the claimed surface (packages/lint/src/lint-flow-patterns.test.ts, packages/qa/dogfood/test/expression-conformance.test.ts). Answered — right: lintFlowPatterns lives in packages/lint, so the round-trip pin cannot sit in spec; the roster edit is owed by the roster's own header (①.11).
  2. Control pins at PromptTemplate.user and titleFormat instead of an email/messaging slot. Answered — right (①.13).
  3. Two concrete constructors rather than one generic (TS2322 on safeExtend over a generic literal). Answered — accepted: the shipped shape type-checks on the green gates and has no public consequence; the template constructor keeps its round-0 name.
  4. The cycle is avoided by import type plus passing ExpressionSchema in. Answered — right (①.9).
  5. PR body stored without an attribution footer; attribution in body prose. Process, not contract; no gate reads it.
  6. Verify-lock queueing and model-free commit trailers. Process, not contract.
  7. Out-of-scope: check-doc-formula-expressions.mjs EXPRESSION_SLOT_TYPES tripwire. Verified dormant at the head: the regex names the four public schema names; the notify initializers now read templateExpressionInput(ExpressionSchema, …), which matches none of them, and NotifyConfigSchema carries no @example, which is the branch's other precondition. One correction to the note, escalated to the seat: before this PR the initializer TemplateExpressionInputSchema.optional() did match the regex, so the tripwire's name half no longer covers these two slots — an @example added later to title / message would go unreported. A one-line widening of EXPRESSION_SLOT_TYPES to the two constructor names restores it; machine face, non-blocking.
  8. Out-of-scope: packages/services/service-automation/src/builtin/notify-node.ts:266 comment "template slots (TemplateExpressionInputSchema)". Read at the head: the input shape is identical, so the comment misnames the constructor, not the contract. Outside domain:spec. Escalated to the seat as a carrier question: [v18] flow text slots: read ADR-0032 §3's {{ }} delimiter instead of single-brace {token} (notify title/message and the other flow string slots), converting only what renders the same #22110 rewrites the notify convention and touches this file; the correction can ride there or in the next service-automation PR's Acceptance notes.
  9. ADR-0032 D3 tension (①.12). Non-blocking under the triage ruling; the seat should keep [v18] flow text slots: read ADR-0032 §3's {{ }} delimiter instead of single-brace {token} (notify title/message and the other flow string slots), converting only what renders the same #22110 pointed at NOTIFY_TEMPLATE_PLACEHOLDER, which the code already does.

Check-runs on the head as read in this act: 46 runs — 39 success, 7 skipped (Auto Label ×2 and Check PR Size ×2 on label-event re-triggers after each ran success once; Packed-tarball smoke (opt-in) ×2 and Console Pin Gate, filtered by design), 0 failure, 0 in progress. All seven required contexts — Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard — are success. The Vercel commit status is pending (a status, not a check-run). Read 2026-10-07T22:45Z.

Implemented-by: claude/issue-22081-notify-refusal-brace-prescription
Reviewed-by: session_01RPo7FUd6bSnAfkWMAKi848

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants