Repository navigation
feat(spec): notify title/message are template slots — bare string or tmpl envelope - #22063
Conversation
…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>
Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude <noreply@anthropic.com>
…te slots Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 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
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
|
CI note from the owning
Typing the two keys with Fix: one ledger row (dialect Generated by Claude Code |
Contract reviewServed-tier: Inputs: card #22054 (body; comments ① Derived judgmentsCheck-runs on the head, read at 2026-10-07T07:24Z: 32 runs — 17 Accept set at
Parse output and the published types.
Published text — each added sentence, true or not.
② Semver levelChangeset: What the diff publishes: a widening (item 1), which alone takes Judgment: Release placement (ruling ③ Boundary flagsDev flags (deviations), each answered:
Out-of-scope findings:
Escalation input — the alternative shape (not chosen here). A shape that keeps the card's widening without refusing anything
Implemented-by: 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>
Contract reviewServed-tier: Inputs: card #22054 (body; comments ① Derived judgmentsCheck-runs on the head, read at 2026-10-07T08:15Z: 40 runs — 28 Accept set at
Parse output and the published types. Published text — the The new ledger row
② Semver levelChangeset at this head:
PR body: Judgment: the declaration now matches what the diff publishes. ② PASS. Release placement (ruling ③ Boundary flagsRound-2 dev flags (
Round-1 flags and the prior record's ③ escalations, each carried or closed:
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22054
Clause-②: yes (narrowing: the template envelope is newly accepted, and a blank or whitespace bare string is newly refused at
title/message, withNotifyConfigParsedre-shaped; a BREAKING accept-set narrowing,@objectstack/specminorunder the launch-window convention, per contract review6033083158)What changed
The expression dialect table in
packages/spec/src/shared/expression.zod.tslists notification subjects and bodies astemplateslots.NotifyConfigSchema.titleandNotifyConfigSchema.messagewerez.string(), so a notify node written withtmpl`…`was refused. This PR follows the triage direction (the first of the card's two):packages/spec/src/automation/io-node-config.zod.ts).titleandmessageare typedTemplateExpressionInputSchema.optional(), the input every othertemplateslot 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.templateagainsttitle/message), thetemplateDatarule and the "needs a content source" rule are unchanged.source. The shared input's envelope arm is the persistence contract and admits anast-only envelope or a whitespacesource. The executor renderssourceonly, so without this rule such atitlewould fail every run and such amessagewould go out empty.packages/services/service-automation/src/builtin/notify-node.ts, declared cross-lane file). The executor readscfg.title?.sourceandcfg.message?.sourceand interpolates them exactly as before. Before this change it read the slot whole:interpolatewalks an object key by key, andstringifyForTemplatethen serialized it as JSON (H1 reading below). The descriptor'stitle/messagedescriptions now state the{token}interpolation instead of "sent verbatim". The descriptor keepstype: 'string': the Studio form edits the bare-string spelling..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}.content/docs/references/automation/io-node-config.mdx(gen:docs). No other spec artifact moved.check:generatedreports all 15 up to date.Measurements
H1: what the executor did with an envelope. Measured with
interpolateandstringifyForTemplatefromtemplate.ts, withrecord = { priority: 'P1', subject: 'Server down' }:d5a14dd5)'[{record.priority}] {record.subject}'[P1] Server down[P1] Server down(unchanged)tmpl`[{record.priority}] {record.subject}`throughinterpolate+stringifyForTemplate{"dialect":"template","source":"[P1] Server down"}sourceconfig.title: Invalid input: expected string, received object[P1] Server downAblation of the executor read, with the new spec and the old read
interpolate(cfg.title ?? '', …): all three render pins innotify-template-slots.test.tsgo 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 toHEAD,git diff HEADempty).Premise check. The card says
defineFlowrefuses the envelope. Onmainatd5a14dd5it does not.FlowSchema.safeParseanddefineFlowaccept a notify node with atmpltitle, andAutomationEngine.registerFlowregisters it. The refusal comes only at execute time, fromparseNodeConfig(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, executornotify-template-slots.test.ts):title/message'[{stage}] Deal {dealName}'(bare){ dialect: 'template', source }[won] Deal Acmetmpl`[{stage}] Deal {dealName}`/{ dialect: 'template', source }[won] Deal Acme(same text)42,true,['a'],{ source },{ dialect: 'cel', source }invalid_union, message equal toTYPED_EXPRESSION_DIALECT_ONLY.template'',' 'invalid_union, message equal toTYPED_EXPRESSION_SOURCE_REQUIRED.template{ dialect: 'template', ast: … },{ dialect: 'template', source: ' ' }customat the key, message namingsourceReverse 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 newsourcerule turns exactly 1 pin red. A cross-package type check: writingcfg.title?.trim()in the executor makestscred withTS2339 … on type '{ dialect: "template"; source: string; } | …', soservice-automationreads the rebuilt.d.ts.H3: the
subjectalias conversion. No change is needed.subject: 'X'alongsidetitle: tmpl`X`are structurally different values, soflow-node-notify-config-aliaseskeeps both. The strict gate then refusessubjectwith its guidance.subjectalone, as a bare string or an envelope, still renames ontotitleand parses.titlenever parsed before this PR.H5: the expression-slot machinery. Nothing new sees these keys as expression slots.
FLOW_NODE_EXPRESSION_PATHS, so the lintvalidateExpressionwalk and the registration expression pass do not visit them.{token}path walk (validate-flow-template-paths) recurses into objects, so it reads an envelope'ssourceas it read the bare string.authorable-surface,livenessand the strictness ledger record keys, and the key set is unchanged. All three gates are green with no artifact moved.check:api-surfaceis green with no artifact change.Acceptance notes
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.tmpl`[{{record.priority}}] {{record.subject}}`) now parses and renders[{P1}] {Server down}.{{var}}as the spelling to write: the shared template refusalsTYPED_EXPRESSION_SOURCE_REQUIRED.templateandTYPED_EXPRESSION_DIALECT_ONLY.template, and thetmpldocblock. This is reported to the seat; it is not changed here.''or whitespace-only) at either key was accepted onmainand 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.titlepassed that guard and was delivered. It is now refused.messagewas delivered as an empty or blank body. It is now refused.title/messagevalues 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.f81afe3:feat(spec)!,Clause-②: yes (narrowing), an ADR-0087not-required (no-migration-prescription)marker with this census, and a BREAKING line, shipped asminorunder the launch-window convention (contract review6033083158; patch round 1,9bfb746a35).NotifyConfigSchema.parse(...).title/.messagego from a string to{ dialect: 'template', source }, andNotifyConfigParsedwith them. The notify executor, the one reader of parse output in this repo, reads.source.type: 'string'for both keys, so the Studio form authors the bare string. objectui'sFlowNodeConfigFieldrenders a text control withString(value), so a code-authored envelope would display as[object Object]there. That is outside this repo and is noted for the objectui owner.main.origin/maingained 2 commits sinced5a14dd5(packages/spec/src/ui/**andmetadata-protocol). They share no file with this diff.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/specis byte-identical fromed7166a5e2to the final commit01ef8368e5. The only later change is one line innotify-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:livenessandcheck:strictness-ledgerare among them.pnpm --filter @objectstack/spec test: 620 files, 18497 passed, 1 todo, exit 0. Run ated7166a5e2.pnpm --filter @objectstack/spec typecheck: exit 0.check:test-typecheckholds its ledger unchanged.pnpm --filter @objectstack/service-automation test: 174 files, 2116 passed, exit 0. Run at01ef8368e5.pnpm --filter @objectstack/service-automation typecheck: exit 0.pnpm --filter @objectstack/lint test: 120 files, 5638 passed, exit 0. Run at01ef8368e5.node scripts/pm/dispatch-gates.mjs --commands: 111 families, derived with no paths at01ef8368e5. All were run and reconciled with--ran: 110 run, 1 NOT MEASURED, 0 unrun.pnpm check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET: it needs every package'sdist/). It is declared to CI.check:skill-examplesfirst exited 3 because the client packages had nodist/. After building@objectstack/clientand@objectstack/client-reactit exited 0 (262 examples type-check).01ef8368e5with--no-inline-config --format json.eslint.config.mjslints**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}(the.md/.mdxfiles in this diff are outside it).parserOptions.project, noprojectService), so this diff cannot change any untouched file's verdict.pnpm lintis left to CI.Generated by Claude Code