Skip to content

feat(spec): notify title/message are template slots — bare string or tmpl envelope - #22063

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22054-notify-title-template-input
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22054-notify-title-template-input

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22054

Clause-②: yes (narrowing: the template envelope is newly accepted, and a blank or whitespace bare string is newly refused at title / message, with NotifyConfigParsed re-shaped; a BREAKING accept-set narrowing, @objectstack/spec minor under the launch-window convention, per contract review 6033083158)

What changed

The expression dialect table in packages/spec/src/shared/expression.zod.ts lists notification subjects and bodies as template slots. NotifyConfigSchema.title and NotifyConfigSchema.message were z.string(), so a notify node written with tmpl`…` was refused. This PR follows the triage direction (the first of the card's two):

  • Spec (packages/spec/src/automation/io-node-config.zod.ts). title and message are typed TemplateExpressionInputSchema.optional(), the input every other template slot uses. Both the bare string and the { dialect: 'template', source } envelope parse. The parse normalizes the bare string to that envelope, so both spellings of one text parse to the same value.
    • The mutual-exclusion rule (template against title/message), the templateData rule and the "needs a content source" rule are unchanged.
    • One rule is added, for this slot only. A template envelope on either key must carry a non-blank source. The shared input's envelope arm is the persistence contract and admits an ast-only envelope or a whitespace source. The executor renders source only, so without this rule such a title would fail every run and such a message would go out empty.
  • Executor (packages/services/service-automation/src/builtin/notify-node.ts, declared cross-lane file). The executor reads cfg.title?.source and cfg.message?.source and interpolates them exactly as before. Before this change it read the slot whole: interpolate walks an object key by key, and stringifyForTemplate then serialized it as JSON (H1 reading below). The descriptor's title/message descriptions now state the {token} interpolation instead of "sent verbatim". The descriptor keeps type: 'string': the Studio form edits the bare-string spelling.
  • Every published sentence about the two keys is now true. The .describe() texts, the schema docblock and the conflict-refusal text said the text is "sent verbatim". The executor interpolates it, so that sentence was already false before this PR. They now say which placeholder spelling the slot's renderer reads: the flow's single-brace {token}.
  • Generated: content/docs/references/automation/io-node-config.mdx (gen:docs). No other spec artifact moved. check:generated reports all 15 up to date.

Measurements

H1: what the executor did with an envelope. Measured with interpolate and stringifyForTemplate from template.ts, with record = { priority: 'P1', subject: 'Server down' }:

input before (base d5a14dd5) after
bare '[{record.priority}] {record.subject}' [P1] Server down [P1] Server down (unchanged)
tmpl`[{record.priority}] {record.subject}` through interpolate + stringifyForTemplate {"dialect":"template","source":"[P1] Server down"} not reached: the executor reads source
same envelope, through the whole node refused at the execute-time parse: config.title: Invalid input: expected string, received object delivered [P1] Server down

Ablation of the executor read, with the new spec and the old read interpolate(cfg.title ?? '', …): all three render pins in notify-template-slots.test.ts go red. The bare string goes red too, because the parse now hands the executor an envelope for both spellings. The delivered title was {"dialect":"template","source":"[won] Deal Acme"}. The restore was verified (blob equal to HEAD, git diff HEAD empty).

Premise check. The card says defineFlow refuses the envelope. On main at d5a14dd5 it does not. FlowSchema.safeParse and defineFlow accept a notify node with a tmpl title, and AutomationEngine.registerFlow registers it. The refusal comes only at execute time, from parseNodeConfig (config.title: Invalid input: expected string, received object). The flow builds and registers, then fails every run. The card's core premise holds: the schema disagrees with the dialect table.

Pins (spec io-node-config.test.ts, executor notify-template-slots.test.ts):

value at title / message parse render
'[{stage}] Deal {dealName}' (bare) ok, normalized to { dialect: 'template', source } [won] Deal Acme
tmpl`[{stage}] Deal {dealName}` / { dialect: 'template', source } ok, same value [won] Deal Acme (same text)
42, true, ['a'], { source }, { dialect: 'cel', source } refused, one issue at the key: invalid_union, message equal to TYPED_EXPRESSION_DIALECT_ONLY.template node refused before anything is sent
'', ' ' refused: invalid_union, message equal to TYPED_EXPRESSION_SOURCE_REQUIRED.template n/a
{ dialect: 'template', ast: … }, { dialect: 'template', source: ' ' } refused: custom at the key, message naming source n/a

Reverse runs. The new spec pins were run against the base schema file (restored from d5a14dd5, trap-restored, blob verified). Result: 7 red (the 6 new pins and the updated "accepts every declared key"), 27 green. Disabling only the new source rule turns exactly 1 pin red. A cross-package type check: writing cfg.title?.trim() in the executor makes tsc red with TS2339 … on type '{ dialect: "template"; source: string; } | …', so service-automation reads the rebuilt .d.ts.

H3: the subject alias conversion. No change is needed.

  • subject: 'X' alongside title: tmpl`X` are structurally different values, so flow-node-notify-config-aliases keeps both. The strict gate then refuses subject with its guidance.
  • subject alone, as a bare string or an envelope, still renames onto title and parses.
  • The guidance's "with DIFFERENT text" is now "with a DIFFERENT value", which is true in this case as well.
  • Nothing is lost silently. No stored pre-17 flow can carry the pair, because an envelope title never parsed before this PR.

H5: the expression-slot machinery. Nothing new sees these keys as expression slots.

  • They are not on FLOW_NODE_EXPRESSION_PATHS, so the lint validateExpression walk and the registration expression pass do not visit them.
  • The generic {token} path walk (validate-flow-template-paths) recurses into objects, so it reads an envelope's source as it read the bare string.
  • authorable-surface, liveness and the strictness ledger record keys, and the key set is unchanged. All three gates are green with no artifact moved.
  • check:api-surface is green with no artifact change.

Acceptance notes

  • Placeholder spelling. These two slots render through the flow's interpolate(), so the placeholder is {record.name}. A {{record.name}} renders with its outer braces left in ({Acme}), for a bare string and an envelope alike. That was already true for bare strings. The .describe() texts now say it.
    • The card's own example (tmpl`[{{record.priority}}] {{record.subject}}`) now parses and renders [{P1}] {Server down}.
    • Three shared texts outside this PR's surface still show {{var}} as the spelling to write: the shared template refusals TYPED_EXPRESSION_SOURCE_REQUIRED.template and TYPED_EXPRESSION_DIALECT_ONLY.template, and the tmpl docblock. This is reported to the seat; it is not changed here.
  • Blank strings: a BREAKING accept-set narrowing. A blank bare string ('' or whitespace-only) at either key was accepted on main and is now refused, by the shared template input's non-blank rule.
    • title: '' used to parse and then fail every run with "notify: title is required", so it fails either way, now earlier.
    • A whitespace-only title passed that guard and was delivered. It is now refused.
    • A blank or whitespace-only message was delivered as an empty or blank body. It is now refused.
    • Measured: zero blank notify title/message values in the 31 in-repo authoring files and at the objectui pin. objectui's flow inspector deletes a cleared key (setAtPath) only for '', so a whitespace-only Studio entry is stored.
    • Graded as the repo graded the same rule in f81afe3: feat(spec)!, Clause-②: yes (narrowing), an ADR-0087 not-required (no-migration-prescription) marker with this census, and a BREAKING line, shipped as minor under the launch-window convention (contract review 6033083158; patch round 1, 9bfb746a35).
    • The parse output also changes: NotifyConfigSchema.parse(...).title / .message go from a string to { dialect: 'template', source }, and NotifyConfigParsed with them. The notify executor, the one reader of parse output in this repo, reads .source.
  • Studio form. The descriptor keeps type: 'string' for both keys, so the Studio form authors the bare string. objectui's FlowNodeConfigField renders a text control with String(value), so a code-authored envelope would display as [object Object] there. That is outside this repo and is noted for the objectui owner.
  • Not merged with main. origin/main gained 2 commits since d5a14dd5 (packages/spec/src/ui/** and metadata-protocol). They share no file with this diff.
  • PR feat(spec)!: the build doors refuse a builtin node config value its executor contract refuses, with its location #21974 also edits notify-node.test.ts. The new executor pins live in their own file, notify-template-slots.test.ts, so the two PRs do not conflict there.

Local verification (final commit 01ef8368e5)

Every reading below was taken on this branch. packages/spec is byte-identical from ed7166a5e2 to the final commit 01ef8368e5. The only later change is one line in notify-template-slots.test.ts.

  • pnpm --filter @objectstack/spec build (JS and DTS): exit 0.
  • pnpm --filter @objectstack/spec check:generated: exit 0, all 15 artifacts up to date. check:api-surface, check:authorable-surface, check:docs, check:liveness and check:strictness-ledger are among them.
  • pnpm --filter @objectstack/spec test: 620 files, 18497 passed, 1 todo, exit 0. Run at ed7166a5e2.
  • pnpm --filter @objectstack/spec typecheck: exit 0. check:test-typecheck holds its ledger unchanged.
  • pnpm --filter @objectstack/service-automation test: 174 files, 2116 passed, exit 0. Run at 01ef8368e5.
  • pnpm --filter @objectstack/service-automation typecheck: exit 0.
  • pnpm --filter @objectstack/lint test: 120 files, 5638 passed, exit 0. Run at 01ef8368e5.
  • node scripts/pm/dispatch-gates.mjs --commands: 111 families, derived with no paths at 01ef8368e5. All were run and reconciled with --ran: 110 run, 1 NOT MEASURED, 0 unrun.
    • NOT MEASURED, reason: pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: it needs every package's dist/). It is declared to CI.
    • check:skill-examples first exited 3 because the client packages had no dist/. After building @objectstack/client and @objectstack/client-react it exited 0 (262 examples type-check).
  • ESLint, narrowed to the 4 changed TS files and run at 01ef8368e5 with --no-inline-config --format json.
    • Population: eslint.config.mjs lints **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} (the .md/.mdx files in this diff are outside it).
    • Count: 4 files, 0 errors, 0 warnings, read from the JSON output.
    • Invariance: the config never enables type-aware linting (no parserOptions.project, no projectService), so this diff cannot change any untouched file's verdict.
    • The repo-wide pnpm lint is left to CI.

Generated by Claude Code

claude added 4 commits October 7, 2026 06:01
…mpl envelope)

NotifyConfigSchema.title and .message are typed with
TemplateExpressionInputSchema, as the expression dialect table lists
notification subjects/bodies among the template slots. The notify executor
reads the parsed envelope's source, so both spellings render the same text and
a bare string renders exactly what it did before. An envelope with no
non-blank source is refused at the key: the executor renders source only.

Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 7, 2026
@github-actions github-actions Bot added 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

3 anchor(s) derived from 2 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
  • 2 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 5cfd8661c4deef4716d1895883633003a54a9db7 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5cfd8661c4deef4716d1895883633003a54a9db7

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI note from the owning domain:spec seat 2 (session_01GV6oYwgc1kWiUCb1YaprQ7) · 2026-10-07T07:07Z. ⛔ Not a review verdict.

Dogfood Regression Gate (3/3) is red on 01ef8368e5, and the cause is this PR. packages/qa/dogfood/test/expression-conformance.test.ts:414, the ADR-0060 checkLedger ratchet, reports two unclassified expression surfaces:

  • automation/io-node-config.zod.ts:NotifyConfigSchema.title
  • automation/io-node-config.zod.ts:NotifyConfigSchema.message

Typing the two keys with TemplateExpressionInputSchema makes them template surfaces, and every surface needs a row in packages/qa/dogfood/test/expression-conformance.ledger.ts, the way template-title-format covers ObjectSchemaBase.titleFormat.

Fix: one ledger row (dialect template, the notify executor as its evaluator, with its measured fail policy), added in this PR's next patch round. The claim's file surface is revised to include that file. The round waits for the at-tier contract review now running on this head, because the review's findings on the accept set and the parse output may change the shape the row records. No re-run is needed: the patch push re-runs every check.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 01ef8368e5ab72c4eba3f207e315e2cc9403cb74
Local-runs: none

Inputs: card #22054 (body; comments 6031254558, 6031783397, 6032736906), PR #22063 (body, file list, net diff against main), the head's check-runs, ruling 6020116360 on #22009. Sources were read with git show off fetched refs only — objectstack main at 5cfd8661 (merge-base with the head d5a14dd5), objectui main at d50f7241, of which the pinned a58626c8 is an ancestor. Nothing was built, run or re-run.

① Derived judgments

Check-runs on the head, read at 2026-10-07T07:24Z: 32 runs — 17 success, 2 skipped (Console Pin Gate, Packed-tarball smoke), 13 in_progress (Build Core, Dogfood Regression Gate 1–3, Lint & Repo Gates, Test Core 1–6, Type Check · consumer gates, Type Check · workspace), 0 failures. Recorded as read, not polled. For ②: Lint & Repo Gates carries check:adr-0087-registration and the LEVEL axis of check-changeset-no-major, and both read the arm the changeset DECLARES — with the arm as written (widening) neither can refuse the misdeclaration found below, so their eventual green is no evidence on ②.

Accept set at NotifyConfigSchema.title and .message — both directions.

  1. NEWLY ACCEPTED (widening — the card's pin): { dialect: 'template', source } with a non-blank source (optionally meta, or ast beside a non-blank source) — the tmpl envelope. Pinned in io-node-config.test.ts (both keys, both spellings, one parsed value) and end to end in notify-template-slots.test.ts (same delivered payload.title / payload.body). Right.
  2. NEWLY REFUSED (narrowing): a blank bare string — '' and whitespace-only — at either key, by the shared string arm (typedExpressionStringArm → NON_BLANK_STRING), as invalid_union with TYPED_EXPRESSION_SOURCE_REQUIRED.template. On main both parsed (z.string()). The flips, read off notify-node.ts on main: '' at title parsed and then met the post-interpolation guard notify: title is required (fails either way; now earlier, as a contract refusal); a whitespace-only title parsed, PASSED that guard (!title is false for ' '; stringifyForTemplate returns a string as is) and was handed to the messaging service — now refused; '' or whitespace at message was delivered as an empty or blank body — now refused. Population: zero blank values across the 31 files on main that author a notify node (my scan agrees with the dev's). objectui setAtPath deletes a leaf only for '', null or undefined — a whitespace-only Studio entry IS stored, so the PR body's "the inspector deletes a cleared key" covers '' alone. Judgment: the dev's "still a pure widening against the base" is WRONG. This is an accept-set narrowing on two authorable keys (automation/NotifyConfig:title, automation/NotifyConfig:message), and the repo has already graded this exact rule: spec 17.4.0 f81afe3 — "a typed expression slot … refuses a blank string", on the twelve typed keys — shipped as feat(spec)!, "BREAKING accept-set narrowing", minor under the launch-window convention, with an adr-0087 not-required (no-migration-prescription) marker and a census. Same rule, two more keys, same grade owed.
  3. STILL REFUSED, refusal re-worded: numbers, booleans, arrays, { source }, foreign-dialect envelopes — invalid_type "expected string" on main, now invalid_union with TYPED_EXPRESSION_DIALECT_ONLY.template. Accept set unchanged — right. The new sentence prescribes '{{record.name}}', a spelling this slot's renderer does not read and which packages/lint FLOW_DOUBLE_BRACE_INTERP refuses on every flow node value (pinned on a notify title at lint-flow-patterns.test.ts:1653) — carried to ③.
  4. STILL REFUSED, now by the notify-only rule: { dialect: 'template', ast } without source, and { dialect: 'template', source: ' ' } — custom at the key, naming source; source: '' is refused upstream by ExpressionSchema.source.min(1). Before, all were refused as non-strings. Right, and right to keep: the executor renders source only and interpolate has no ast path, so the shared envelope arm (the persistence contract, which admits an ast-only envelope) would otherwise let a title fail every run and a message go out empty.
  5. The content-path rules (template XOR title/message; templateData needs template; one content source) are unchanged and pinned. Right.

Parse output and the published types.

  1. For every accepted bare string, NotifyConfigSchema.parse(cfg).title / .message is now { dialect: 'template', source } where main returned the string. The exported NotifyConfigParsed (z.infer of the schema, published at @objectstack/spec/automation, listed in api-surface/automation.json) is re-shaped: title?: string becomes the template-envelope union ({ dialect: 'template'; source: string } or the persistence envelope with optional source / ast / meta). A consumer typed against it as string breaks at compile; a runtime reader of parse output receives an object. This is a public-surface change the widening arm does not name, and api-surface records export NAMES only, so no gate sees it. The changeset body does state it ("parse(...) now returns the envelope for both spellings") — true, filed under the wrong arm.
  2. NotifyConfig (z.input of the schema): title?: string becomes string or the envelope. A widening of the author type. Right.
  3. Consumers of a parsed notify config — every one, both repos:
    • objectstack: packages/services/service-automation/src/builtin/notify-node.ts is the ONLY reader of title / message off parse output, edited here to read .source — necessary, because interpolate walks an object key by key and stringifyForTemplate would then serialize the envelope as JSON (H1 confirmed from template.ts). packages/spec/src/automation/flow-node-config-refusals.ts runs safeParse and reads issues only, never .data; it is wired into FlowSchema (flow.zod.ts:1497), packages/lint/src/validate-expressions.ts:1679 and objectui's flow-required-keys.ts:85. For a builtin contract it forwards only issues at an ABSENT key (!absent && !whole → continue), so neither the narrowing in item 2 nor the rule in item 4 surfaces at flow parse, in lint, or in Studio's required-key marker — they surface at execute time (parseNodeConfig) and in a direct NotifyConfigSchema.parse. The same read confirms the dev's premise correction: FlowNodeSchema.config is an open record (flow.zod.ts:591), so defineFlow accepted the envelope on main and the refusal came only at execute time. Tests: io-node-form-zod-ledger.test.ts compares key sets and closed vocabularies only; flow-node-config-required.test.ts and flow-slot-refusal-codes.test.ts read issues only. No file in rest, metadata-protocol, cli or examples imports the schema or either type.
    • objectui (main d50f7241): flow-canvas-seeds.spec-parse.test.tsx:244 reads key names through liveKeys, no parse; flow-required-keys.ts reads issues through the helper above; flow-node-config.ts (the notify form: title as text, message as textarea) and FlowNodeConfigField.tsx read the RAW stored config and render String(value) — never parse output. Nothing in objectui reads title / message from parse output. The Console Pin Gate was skipped on this head, consistent with no objectui-affecting path.
  4. Descriptor (notify-node.ts configSchema): type: 'string' kept for both keys, descriptions re-worded. The form-zod ledger test compares key sets and closed vocabularies, so the descriptor now under-declares the contract un-gated (the form says string; the contract takes the envelope). Acceptable as the form's authoring surface; it is what leaves a code-authored envelope rendering as [object Object] in Studio (dev finding 2, in ③).
  5. Published JSON Schema and the generated page: the io-node-config.mdx type column is now the union — right. The rule in item 4 lives in superRefine, which refinement-projection.ts cannot read ("carries no readable function"), so the published JSON Schema declares the union but not the envelope-source rule — the same posture as the three existing notify rules; json-schema.manifest/automation.json lists names only. No artifact other than the mdx had to move. Right.
  6. The executor edit (item 8) is necessary and correct; a bare string renders byte-identically (pinned). Right.

Published text — each added sentence, true or not.

  • "sent verbatim" removed from the describes, the docblock, the conflict refusal and the descriptor — right; main already interpolated title / message.
  • "{{var}} is not a placeholder here — its inner {var} resolves and the outer braces stay" (describes, docblock, descriptor, changeset) — TRUE: interpolateString substitutes /\{([^{}]+)\}/g, which matches the inner {var} of a {{var}} and leaves the outer pair. The tmpl docblock's "Mustache-template … {{record.x}}" is the text that is wrong for this slot, not the PR's.
  • Changeset: "passed defineFlow and registration and then failed every run at the execute-time contract parse" — TRUE (item 8).
  • Changeset: "Still refused at each key: … a blank bare string ('' or whitespace), both by the shared template input" — FALSE: a blank bare string was ACCEPTED on main; it is newly refused. The sentence files the narrowing as an unchanged refusal, in the text that ships to consumers as CHANGELOG. Wrong.
  • Changeset and PR body: "A blank title used to parse and then fail every run" — true for '', false for whitespace (item 2). Wrong as written.
  • Module docblock and mdx: "its slots are string-typed or template-typed, so {token} templates pass" — true.
  • NOTIFY_KEY_GUIDANCE "with a DIFFERENT value" — TRUE: renameKey (conversions/walk.ts) keeps both keys unless deepEqualAuthored, so a bare subject beside an envelope title of the same text is kept and the strict gate refuses subject — H3 holds.
  • notifyTemplateSourceRequired consequences — true: an absent source reaches cfg.title?.source ?? '' and the run-time guard notify: title is required; a message would go out empty.
  • Descriptor descriptions — true.
  • PR body pin table — matches the schema (a string input gets SOURCE_REQUIRED, anything else DIALECT_ONLY; the union's own error map).
  • PR body: "zero blank values in the monorepo" — true (31 files scanned); "the inspector deletes a cleared key" — true for '' only.
  • Fixes #22054 — the card's pins are met: both spellings parse and render the same text (spec and executor pins); a non-string, non-envelope value is still refused (pinned at both layers).

② Semver level

Changeset: @objectstack/spec: minor, @objectstack/service-automation: patch; Clause-②: yes (widening: …); no ! in the summary, no **BREAKING** line, no adr-0087 disposition marker.

What the diff publishes: a widening (item 1), which alone takes minor — right; PLUS an accept-set narrowing on two authorable keys (item 2) and a re-shape of the published output type NotifyConfigParsed (item 6). AGENTS.md: "(narrowing) is BREAKING". The repo's own grade for this identical rule is f81afe3 (BREAKING, feat(spec)!, marker, census). The landing gate reads the DIFF, not the claim ("diff 是事实,卡片语义是预测"), so "the arm stays widening, as ruled" does not hold: the claim predicted a widening; the diff built a widening plus a narrowing.

Judgment: minor is the right number (the launch-window guard in check-changeset-no-major forbids major), but the declaration does not match the diff. FAIL on ②. What the changeset must say for this diff as built, in the house spelling of .changeset/21980-copy-overlays-shipped-names.md: Clause-②: yes (narrowing) (yes — the envelope is a new accepted shape on a published payload; narrowing — blank bare strings are newly refused and the parse output is re-shaped); a "BREAKING accept-set narrowing …, shipped as minor under the repo's launch-window convention" line; feat(spec)! in the summary; exactly one adr-0087 disposition marker in the HTML-comment form the gate reads (the precedent used not-required (no-migration-prescription) with its census — here the census is the 31-file zero count, the objectui pin, and the whitespace gap in Studio, named honestly); and the consumer-side FROM → TO for the output change (parse().title: string → { dialect: 'template', source }; read .source). The Clause-② line in the PR body and on the claim move with it.

Release placement (ruling 6020116360). The closing 17.x release's list is "fixes only", and the ruling's ⛔ reads: a narrowing breaking change does not go into the closing release of a line whose users may never upgrade; it lands after the opening, in 18.0 (said of #21898 / PR #21974, a feat(spec)!). Anything merged to main before that release rides in it. This PR, as built, is a feat(spec) carrying an accept-set narrowing and an output-type re-shape — the class the ruling names. Reading: it is kept out of the closing 17.x release; its landing waits for the v18 opening (held like #21974: pm:blocked, Blocked-by: the opening), or it is re-shaped into a pure widening (③) — and even then whether a feat minor may ride in a "fixes only" closing release is the seat's call under the ruling, not this record's.

③ Boundary flags

Dev flags (deviations), each answered:

  1. "Added one notify-only refinement … still a pure widening against the base" — the refinement is right (contract tightening over a ?? at run time; the executor has no ast renderer) and correctly placed in the superRefine (undeclared to JSON Schema like its three siblings). The sentence is wrong: the narrowing is the shared string arm's blank rule, not the refinement (①.2). Answered: the rule is accepted, the grade is refused.
  2. "DIFFERENT text → DIFFERENT value" — verified against renameKey / deepEqualAuthored. Accepted.
  3. "expression.zod.ts not edited; its {{var}} prescriptions now apply to notify" — real and now load-bearing: TYPED_EXPRESSION_SOURCE_REQUIRED.template and TYPED_EXPRESSION_DIALECT_ONLY.template are the sentences a blank or wrong-typed notify title receives, they prescribe '{{record.name}}', and the repo's lint refuses that spelling on every flow node value (FLOW_DOUBLE_BRACE_INTERP). AGENTS.md: refusal prose carries the lesson. Escalate: a follow-up card on expression.zod.ts (a renderer-neutral shared sentence, or a per-slot prescription) — outside this claim's surface; not a reason to hold this PR once ② is corrected.
  4. "Blank bare strings now refused … arm stays widening … flagged for the clause-② review" — answered in ①.2 and ②: a narrowing, BREAKING, precedent f81afe3; whitespace was delivered on main; Studio stores whitespace.
  5. "origin/main not merged" — the net diff against main was read; the PR reads mergeable: true. Accepted.
  6. Commit-trailer spelling — outside the three contract surfaces; no judgment.

open_questions: none declared.

Out-of-scope findings:

  • Finding 1 (placeholder-spelling family) — see flag 3; escalate.
  • Finding 2 (objectui FlowNodeConfigField renders a code-authored envelope as [object Object]; the static form still offers url) — verified at objectui main: text controls render String(value), and the notify form's title / message are a plain text and a textarea. A real consequence of the widening for a Studio user opening a code-authored flow; it needs an objectui card (unwrap .source for display, or a read-only envelope marker). Escalate to the objectui owner; not a reason to hold this PR.
  • Finding 3 (family: typed slots admit ast-only envelopes that no engine renders) — escalate as a family card; the notify-local rule is the right local answer.

Escalation input — the alternative shape (not chosen here). A shape that keeps the card's widening without refusing anything main accepts and without re-typing the output exists: accept the envelope at title / message and UNWRAP it at parse to the string (a z.union of z.string() and the template envelope, transformed to source), keeping z.string() semantics for bare strings. Blank strings stay accepted as on main, NotifyConfigParsed keeps title?: string, the executor edit becomes unnecessary (H1 moot), both spellings render the same text, a non-string non-envelope stays refused, and an ast-only envelope still needs the local rule (or unwraps to '' and the run-time guard owns it). Weighed on the four axes of .claude/skills/pm-dispatch/SKILL.md:

  • 实际业务需求 — measured authors: 31 in-repo notify files, all bare single-brace strings, zero blanks; the one envelope author (objectos's docs sample) has already switched to bare strings. Both shapes serve the measured need; the alternative with zero flips.
  • 项目长远合理性 (weight at least half) — triage's direction, "as TemplateExpressionInputSchema … as every other template slot does", is the uniform, contract-first form: every typed slot normalizes to the envelope and its reader takes .source. The alternative makes notify the one typed slot whose parse output is a string — a second convention, the temporary-patch cost this axis says to name. Favors the PR's shape.
  • 防 AI 犯错 — contract tightening at parse (blank refused, ast-only refused) beats the ??-at-run-time tolerance the alternative keeps. Favors the PR's shape.
  • 创业阶段不扩散 / 过渡从紧 — neither shape adds capability; the transition axis says do it once, strictly, with no dual spellings — favors the PR's shape, declared honestly as a narrowing and placed after the v18 opening per the ruling.
    Net input: the four axes favor the PR's shape as built, with the honest grade and the ruling's placement (hold for 18.0); the alternative is the shape to take only if the seat decides the card must close before the closing 17.x release. Either way the changeset as written cannot land. The seat decides.

Implemented-by: claude/issue-22054-notify-title-template-input
Reviewed-by: session_01GV6oYwgc1kWiUCb1YaprQ7

VERDICT: FAIL


Generated by Claude Code

…it ships; classify the two slots in the expression ledger

The changeset now declares what the diff publishes: a widening (the template
envelope parses at NotifyConfigSchema.title / .message) plus an accept-set
narrowing (a blank bare string is newly refused) and a parse-output change
(the parsed value is the template envelope). Clause-② yes (narrowing),
feat(spec)!, a BREAKING line and an ADR-0087 not-required marker with its
census; minor under the launch-window convention.

The ADR-0060 expression ledger gains one row, template-notify-content, for the
two template-typed notify slots, its cells measured from notify-node.ts and
template.ts.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9bfb746a3594437fa36860bba393156ae47818a7
Local-runs: none

Inputs: card #22054 (body; comments 6031254558, 6031783397 with its in-place-edited Clause-② line, 6032736906, 6032860972, 6033625039), PR #22063 (body, file list, net diff against main, comments 6032851265 and the prior record 6033083158), the head's check-runs, ruling 6020116360 on #22009. Sources were read with git show / git grep off fetched refs only: objectstack main at 5cfd8661 (merge base with the head d5a14dd5; the PR's recorded base 56c88446 is an ancestor of it), objectui main at 1bc17da0, of which the pinned a58626c8 is an ancestor. Nothing was built, run or re-run. Patch round: the delta 01ef8368e5 → 9bfb746a35 touches exactly two files — .changeset/22054-notify-title-template-input.md (re-graded) and packages/qa/dogfood/test/expression-conformance.ledger.ts (one row added); the schema, the executor, both test files and the generated page are byte-identical to the prior head. The whole net diff (7 files) is judged below.

① Derived judgments

Check-runs on the head, read at 2026-10-07T08:15Z: 40 runs — 28 success, 4 skipped (Console Pin Gate, Packed-tarball smoke, and the later Auto Label / Check PR Size runs), 0 failure, 8 in_progress (Test Core 1–6, Type Check · workspace, Lint & Repo Gates). Recorded as read, not polled. Among the greens: Dogfood Regression Gate (1/3), (2/3) and (3/3) — the ADR-0060 ratchet that was red on 01ef8368e5 (two unclassified surfaces, NotifyConfigSchema.title / .message) is green on this head; Check Changeset twice (on the push, and again after the PR-body edit); Governed Surface Queue Guard; Spec property liveness; Type Check · consumer gates; Build Core; Temporal Conformance. Still owed by CI: Lint & Repo Gates, which carries check:adr-0087-registration and the LEVEL axis of check-changeset-no-major — the dev reports both green locally on this head (6033625039), and with the arm now narrowing and one marker present, both gates have the declaration they read; Type Check · workspace carries the dogfood package's whole-package typecheck the dev declared NOT MEASURED.

Accept set at NotifyConfigSchema.title / .message — unchanged from the prior head, re-read at this one.

  1. NEWLY ACCEPTED (widening): the { dialect: 'template', source } envelope with a non-blank source — pinned in io-node-config.test.ts (both keys, both spellings, one parsed value) and end to end in notify-template-slots.test.ts. Right.
  2. NEWLY REFUSED (narrowing): a blank bare string ('', whitespace-only) at either key, by the shared string arm's NON_BLANK_STRING refine, as invalid_union with TYPED_EXPRESSION_SOURCE_REQUIRED.template. On main both parsed. The flips, re-read off notify-node.ts on main: '' at title met the guard notify: title is required (fails either way, now earlier); a whitespace-only title passed that guard (!title is false; interpolateString returns a brace-less string unchanged; stringifyForTemplate returns a string as is) and was handed to emit — now refused; '' or whitespace at message was delivered as an empty or blank body — now refused. Right, and now DECLARED as such (②).
  3. STILL REFUSED, re-worded: numbers, booleans, arrays, { source }, foreign-dialect envelopes — invalid_union with TYPED_EXPRESSION_DIALECT_ONLY.template. Accept set unchanged; right. The {{var}} prescription inside that shared sentence is carried in ③.
  4. STILL REFUSED, by the notify-only superRefine rule: { dialect: 'template', ast } and { dialect: 'template', source: ' ' } — custom at the key, naming source. Right: the executor renders source only and interpolate has no ast path.
  5. The content-path rules (template XOR title/message; templateData needs template; one content source) are unchanged and pinned. Right.

Parse output and the published types. NotifyConfigSchema.parse(cfg).title / .message: string → { dialect: 'template', source } for every accepted bare string; NotifyConfigParsed (listed by name in api-surface/automation.json) is re-shaped; NotifyConfig (the input type) widens. Readers re-enumerated at this head and at main: notify-node.ts is the only reader of parse output in either tree (resolveSource reads a Pick of sourceObject / sourceId, unaffected); flow-node-config-refusals.ts reads issues only and, for a builtin contract, forwards only issues at an ABSENT key (!absent && !whole → continue, :449), so the narrowing surfaces at execute time and in a direct parse, not at flow parse, in lint, or in Studio's required-key marker; no reader in rest, metadata-protocol, cli or examples. objectui main: the three inspector files are byte-identical to the pin (git diff --stat empty), and nothing in objectui parses NotifyConfigSchema (flow-canvas-seeds.spec-parse.test.tsx:244 names it as a key-set source only). main gained 3 commits since the merge base, with zero file overlap; the PR reads mergeable: true. Right.

Published text — the .describe() texts, the schema docblock, the module docblock, the conflict refusal, NOTIFY_KEY_GUIDANCE, notifyTemplateSourceRequired, the descriptor descriptions and the generated mdx table were re-read at this head and are the prior record's list, unchanged and true (the {{var}}-keeps-its-outer-braces sentence holds: interpolateString substitutes /\{([^{}]+)\}/g, which matches the inner {var}). The two sentences the prior record found FALSE lived in the changeset and are judged corrected in ②.

The new ledger row template-notify-content, each cell against notify-node.ts, builtin/template.ts, parse-config.ts, guard-refusal.ts, engine.ts, packages/lint, cli/src/commands/validate.ts and ADR-0058 D5 at this head:

  • id — one occurrence in the file; summary — true.
  • dialect: 'template' — both keys are typed TemplateExpressionInputSchema, and the parse normalizes to dialect: 'template'. True.
  • mode: 'interpret' — per-run interpolate at notify-node.ts:274-275; nothing compiles it (ADR-0058 D6 names only RLS, sharing and read-scope as compile surfaces). True.
  • state: 'enforced' — a runtime evaluator exists and is named; checkLedger requires enforcement on an enforced row, and it is present. True.
  • failPolicy: 'throw' — D5's flow tier ("flow → throw (author bug)"), the tier the existing cel-flow row carries. The faults the cell names do fail the run and cannot be routed: the contract parse returns refuseNode(…) (errorClass: 'guard'); a thrown FlowExpressionFunctionError is markGuardRefusal-ed in its own constructor, and engine.ts:11099 takes no fault edge for a guard refusal (isGuardRefusal(execErr) ? undefined : the fault edge); an empty rendered title returns success: false at notify-node.ts:308. The silent half — an unresolved path or a non-evaluating arithmetic token renders '' (resolveToken returns undefined, stringifyForTemplate renders it '', and template.ts holds no logger call) — is stated in the cell rather than rounded into fail-soft-log (which would claim a log that does not exist) or unevaluated (an evaluator exists). The honest nearest D5 member, with the deviation declared where the reader reads it. Right.
  • enforcement — every clause re-read: parseNodeConfig on the RAW config first (:256); bare-string normalization; stringifyForTemplate(interpolate(cfg.title?.source ?? '', …)) and the same for message (:274-275); interpolateString substitutes single-brace tokens only, so {{var}} keeps its outer braces; payload.title / payload.body through emit; the guard-refusal and un-routability reading above; notify: title is required; the no-log silent render; FlowSchema.parse / registration judge no value here (the absent-key rule above); os validate runs runAuthoringRules('validate', …) (validate.ts:522), whose lintFlowPatterns emits flow-double-brace-interpolation (FLOW_DOUBLE_BRACE_INTERP, an advisory finding with no severity: 'error') after collectTemplateStrings recurses into objects (CEL_KEYS is condition / expression / conditions, so an envelope's source is scanned), and validateFlowTemplatePaths reports an unknown head as warning outside a filter. True throughout. One reading to note: the notify: title is required fault is read off the source and the dev's runtime probe; no test file at this head pins that string (git grep finds it in notify-node.ts only) — a gap in pins, not a false cell.
  • covers — exactly the two file:Schema.field keys the seat's CI note quoted from the red ratchet; the dev's control leg (one cover deleted, the ratchet names that key) confirms the spelling matches discovery's attribution through the lazySchema / strictObject wrapper. True.
  • proof — packages/services/service-automation/src/builtin/notify-template-slots.test.ts, present in the diff; the conformance test passes no attribution option, so checkLedger checks existence only. It pins the dialect / state cells (both spellings render one text, the bare string unchanged, a non-string refused before anything is sent); the throw tier's evidence is the engine's existing pins (template-functions.test.ts for FlowExpressionFunctionError) plus the source reading above. Acceptable.
  • note — no tracker id; the renderer-not-templateEngine sentence is true (the TemplateExpressionInputSchema docblock says the registered engine and the messaging / email renderers read {{var}} alone). True.
  • Ratchet: the row classifies the two positions that were unclassified on 01ef8368e5, and Dogfood Regression Gate (3/3) is green on this head. Satisfied.

② Semver level

Changeset at this head: @objectstack/spec: minor, @objectstack/service-automation: patch; summary feat(spec)!: … and a blank bare string is now refused there; Clause-②: yes (narrowing); exactly one ADR-0087 marker, not-required (no-migration-prescription), in the HTML-comment form the gate reads; a BREAKING line; the parse-output FROM → TO. Against the prior record's prescription, sentence by sentence:

  • feat(spec)!, the summary naming the new refusal — matches the diff.
  • Clause-②: yes (narrowing) — yes (the envelope is a new accepted shape on a published payload), (narrowing) (blank bare strings newly refused; the output type re-shaped). Matches; the arm is the fixed spelling readArmToken reads.
  • The marker. no-migration-prescription is the category the ledger question admits here: no authorable key is renamed, retired or re-shaped for an author; a stored bare string still parses, so objectstack migrate meta has nothing to rewrite; a blank value has no intent to recover (the marker says so); the consumer-side change on parse output is compiler-carried (the dev measured TS2339 on cfg.title?.trim()), and runtime-interface-only / type-surface-only are closed to it (NotifyConfigParsed is a Zod projection; the change is not type-only). It is the category the precedent f81afe3 used. Its census, re-measured: at the merge base, 34 files match type: 'notify' by my spelling (CHANGELOGs included; the dev's 32 / 31 by his), and none carries a notify node with a blank title / message / subject / body — the only blank-shaped hit is a CHANGELOG.md line about $empty; at the objectui pin, 8 files declare a notify node type by my spelling (the dev's 9), and the only blank message literals in the inspectors are an end node's refusal message and an inspector roster message — zero on a notify node. The whitespace gap in Studio (setAtPath at FlowNodeInspector.tsx:146 deletes a leaf for undefined / null / '' only) and the unmeasured hosted tenants are named in the marker. Honest.
  • BREAKING accept-set narrowing at automation/NotifyConfig:title / :message, minor under the launch-window convention, "the grade … shipped with when it reached the first twelve typed keys" — f81afe3 is feat(spec)!, minor, BREAKING, the same marker category, nine cron and three template keys. True.
  • "What widens" — true (defineFlow accepted the envelope on main; the refusal was at execute time).
  • "What narrows", three sub-bullets — each true against notify-node.ts on main (① item 2). The two sentences the prior record found false ("Still refused … a blank bare string" filed as unchanged; "A blank title used to parse and then fail every run") are gone, replaced by "newly refused" and the '' / whitespace split. Corrected.
  • "Parse output" FROM → TO (string → { dialect: 'template', source }; read .source; NotifyConfigParsed changes; the executor is the one reader) — true (① readers).
  • "Still refused, with a new sentence"; "New, notify-only" ("refuses nothing that used to parse" — true, an envelope never parsed here); "Placeholder spelling"; the service-automation bullet — true.
  • Levels: minor is the only number the launch-window guard leaves for a breaking spec change, and the right one; service-automation: patch is acceptable — the package adds no export, its executor change is the consequence of the contract it imports, and the fixed group lifts it to the group's bump regardless.

PR body: Clause-②: yes (narrowing: the template envelope is newly accepted, and a blank or whitespace bare string is newly refused at title / message, with NotifyConfigParsed re-shaped; …) — the arm is the first token of the parenthetical; matches the diff. The body's blank-strings section now reads as ① item 2 does (a whitespace-only title was delivered; '' title fails either way; the inspector deletes a cleared key for '' only; graded as f81afe3; the parse-output change named; the executor the one reader) — true. Residue, not a contract surface: the "Local verification" heading still names 01ef8368e5; the round-2 runs are on the card (6033625039). Claim 6031783397's edited Clause-② line — yes (narrowing: …), naming the envelope, the blank refusal, the NotifyConfigParsed union and the minor grade — matches.

Judgment: the declaration now matches what the diff publishes. ② PASS.

Release placement (ruling 6020116360), restated for this head. The closing 17.x release's list is "fixes only", and the ruling's ⛔ names the class: a narrowing breaking change does not go into the closing release of a line whose users may never upgrade; it lands after the opening, in 18.0 (said of #21898 / PR #21974, a feat(spec)!). This head is a feat(spec)! carrying an accept-set narrowing and an output-type re-shape — that class, now declared as such. Reading: kept out of the closing 17.x release; the landing waits for the v18 opening (held as #21974 is held). Anything merged to main before that release rides in it, so the hold is on the merge, not on this review. The seat holds the landing question for the maintainer; this record does not decide it.

③ Boundary flags

Round-2 dev flags (6033625039), each answered:

  1. proof and note beyond the template-title-format row's shape — proof is the shape of the enforced cron-job-schedule row, and checkLedger admits it. Accepted.
  2. failPolicy: 'throw' by measurement, the non-throwing half written into the cell — judged in ①: the honest nearest D5 tier. Accepted as written.
  3. The dogfood whole-package typecheck NOT MEASURED, declared to CI — Type Check · workspace in progress at this read; recorded as read.
  4. The marker spelled in words in the report — the file carries the HTML comment; verified at the head. Accepted.

open_questions: none declared in either report. Round-2 out_of_scope_findings: none new.

Round-1 flags and the prior record's ③ escalations, each carried or closed:

  • Flag 4 (blank strings; "the arm stays widening") — CLOSED: the arm, the summary, the marker and the BREAKING line now say what the diff does.
  • Flag 1 (the notify-only refinement) and flag 2 (DIFFERENT value) — closed in the prior record; unchanged here.
  • Flag 5 (origin/main not merged) — re-read: 3 commits on main since the merge base, zero file overlap, mergeable. Closed.
  • Flag 6 (commit-trailer spelling) — outside the three contract surfaces; no judgment.
  • Flag 3 / escalation A — the {{var}} prescription in the shared TYPED_EXPRESSION_SOURCE_REQUIRED.template and TYPED_EXPRESSION_DIALECT_ONLY.template sentences (expression.zod.ts:295, :311) and in the tmpl docblock ("Mustache-template … {{record.x}}"): still present at this head, still the sentences a blank or wrong-typed notify slot receives, and packages/lint still refuses that spelling on every flow node value. No card naming it is among the issues filed in either repo since the prior record. CARRIED FORWARD — escalate to the seat as a follow-up card on expression.zod.ts (a renderer-neutral shared sentence, or a per-slot prescription); outside this claim's surface; not a hold on this PR.
  • Escalation B — objectui's FlowNodeConfigField.tsx renders a code-authored envelope as [object Object] (its text and textarea controls render String(value) at :444 / :465 on objectui main, byte-identical to the pin); the static notify form still offers the pre-17 url key. No objectui card filed since the prior record. CARRIED FORWARD — escalate to the objectui owner (unwrap .source for display, or a read-only envelope marker); not a hold.
  • Escalation C — the family of typed slots admitting an ast-only envelope no engine renders (TemplateExpressionInputSchema, CronExpressionInputSchema): the notify-local rule is the right local answer; no family card filed. CARRIED FORWARD as a family card.
  • The alternative shape the prior record weighed (unwrap at parse, keep z.string() output) was not taken; its four-axis reading favoured the shape as built, declared honestly and placed after the opening — which is this head. Nothing further is owed on it unless the seat decides the card must close before the closing 17.x release.

Implemented-by: claude/issue-22054-notify-title-template-input
Reviewed-by: session_01GV6oYwgc1kWiUCb1YaprQ7

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 12:38
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 12:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit d4680d2 Oct 7, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22054-notify-title-template-input branch October 7, 2026 13:17
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/m tests tooling

Projects

None yet

2 participants