Repository navigation
finding(spec): the expression dialect table lists notification subjects/bodies as template slots, but NotifyConfigSchema.title is z.string() and refuses the tmpl envelope #22054
Description
Activity
objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsPath: ② the capabilities an end user meets in the app — automation notifications | 缺项 | P3
Triage: first grade,
bug·priority:p3·domain:spec·area:workflow·pm:queue(findingandneeds-triageremoved). The field follows the dialect table: a notify title accepts the template inputTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-07T04:58Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in
packages/spec/src/automation/io-node-config.zod.ts(NotifyConfigSchema.title,:192), with the executor half inpackages/services/service-automation/src/builtin/notify-node.ts(cross-lanedomain:services) ⇒domain:spec; rationale: the declaration is the outlier against the spec's own dialect table.- Verified on
mainat this write:- the dialect table names notification subjects and bodies as
templateslots; NotifyConfigSchema.titleisz.string().optional();- the executor already treats
titleas a template:notify-node.ts:259runs it throughinterpolate(cfg.title …).
- the dialect table names notification subjects and bodies as
- Direction: the first of the card's two. Type
title, and any notify body or subject field the executor interpolates, asTemplateExpressionInputSchema, so the bare string and thetmplenvelope both parse, as every other template slot does.- The executor reads the envelope's
source. A bare string is unchanged. - The changeset is a widening (
Clause-②: yes,minor). - Pins: the envelope and the bare string both parse and both render the same text; a non-string, non-envelope value is still refused.
- The executor reads the envelope's
- Not the second direction: narrowing the table's promise would leave the one notify slot unlike every other template slot, while the runtime already interpolates it.
- Why p3: an author gets a loud schema error and a working spelling exists, so there is no silent harm.
- Verified on
- addedarea:workflowApprovals and automation — the work that runs without a person driving itApprovals and automation — the work that runs without a person driving itbugSomething isn't workingSomething isn't workingand removed
on Oct 7, 2026 objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsClaim: PM loop round 8 (this card, as triage graded and directed it in
6031254558: the first of the card's two directions) · 2026-10-07T05:43Z
Session:session_01GV6oYwgc1kWiUCb1YaprQ7
Account:os-warren(the seat's linked user asGET /useranswers it; the card's assignee from this act)
Branch:claude/issue-22054-notify-title-template-input
Worktree:objectstack-issue-22054
Domain:domain:spec
Seat:domain:spec#2(seat post #18549)
File surface (atorigin/maind5a14dd5; stop on breach and explain in the report):packages/spec/src/automation/io-node-config.zod.ts:NotifyConfigSchema.title, and any notify body or subject field the executor interpolates (message), typed asTemplateExpressionInputSchema, so that both the bare string and thetmplenvelope parse. Their.describe()text is made true. Its spec testio-node-config.test.ts.- Declared cross-lane files (
domain:services), only if the executor needs it:packages/services/service-automation/src/builtin/notify-node.ts(it must read the envelope'ssource; a bare string is unchanged) and its tests underpackages/services/service-automation/src/. The declaration goes on thedomain:servicesseat post [PM seat] domain:services · seat 2 — ⏳ vacant #21118. - Whatever
packages/specgenerators move for this (json-schema.manifest/**,spec-changes.json,content/docs/references/automation/**, and anyliveness/**row a gate requires), plus.changeset/22054-*.md. - ⛔ Not the second direction: the dialect table's promise stays. ⛔ A non-string, non-envelope value is still refused.
Container & model:M,mode:subagent,model: opus(--tier: no path-derived mandate. The clause-② review is owed atCONTRACT_REVIEW_TIERby an isolated subagent, not this build.)
Clause-②: yes (narrowing: the envelope is newly accepted, a blank or whitespace bare string is newly refused andNotifyConfigParsed.title/messagebecome the envelope union, so the diff carries a BREAKING accept-set narrowing; contract review6033083158graded it,@objectstack/specminorunder the launch-window convention)
Responsibility:@objectstack/spec(NotifyConfigSchema.titleisz.string()against the spec's own dialect table) | a working path exists: the bare-string spelling parses and the executor already interpolates it (notify-node.ts:259) | any author writing a notify node with thetmplenvelope hits a loud schema error; objectos's docs met it on objectos#316 and switched to bare strings
Thread-read: 6031254558
Serial constraints cleared: - None of the 6 open PRs touches
io-node-config.zod.tsornotify-node.ts(scan at this stamp), and no liveClaim:names them. - Seat 1's PR feat(spec)!: the build doors refuse a builtin node config value its executor contract refuses, with its location #21974 (service-automation: a built-in node's config value its own contract refuses still registers, then fails every run — the built-in half of #21848's class #21898,
pm:blocked, held for 18.0) touchespackages/services/service-automation/src/builtin/notify-node.test.ts. That is ordinary concurrency on a shared test file: whichever lands later mergesmain. - This seat's in-flight PR feat(spec): export the anonymous-form-intake candidates from @objectstack/spec/ui; metadata-core re-exports the same bindings #22056 (spec(ui): export the candidates half of the anonymous-form-intake rule from
@objectstack/spec/ui, so the console reads "published" from the server's one rule (objectui#11545, ruling 5967405932's fallback) #22047,spec/uiandmetadata-core) and finding(lint): the object save door gives no build verdict on validation conditions, field-rule slots (requiredWhen etc.), option visibleWhen or action predicates; os build refuses them, a metadata save stores them (#22019's sibling) #22032 (packages/lint) are disjoint from this surface.
Generated by Claude Code
objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 22054, "status": "done", "branch": "claude/issue-22054-notify-title-template-input", "pr": "https://github.com/objectstack-ai/objectstack/pull/22063", "session": "session_01GV6oYwgc1kWiUCb1YaprQ7. This run is a subagent, so this is the PM's harness-stamped id, the same one the commits' Claude-Session trailer carries.", "premise_still_valid": true, "summary": "NotifyConfigSchema.title and .message are now TemplateExpressionInputSchema.optional(), the input every other template slot uses, so the bare string and the tmpl envelope both parse. The parse normalizes both spellings to one value, {dialect:'template', source}. The notify executor reads cfg.title?.source and cfg.message?.source and interpolates them as before. Both spellings deliver the same payload.title and payload.body, and a bare string renders exactly what it rendered before. One rule is added, for this slot only: a template envelope on either key must carry a non-blank source. The shared envelope arm admits an ast-only envelope or a whitespace source. The executor renders source only, so without the rule such a title would fail every run and such a message would go out empty. Every published sentence about the two keys is now true. The 'sent verbatim' wording is gone from the .describe() texts, the docblock, the conflict refusal and the descriptor, and each now names the renderer's single-brace {token} placeholder. Premise: the core holds, because the schema disagreed with the dialect table. One detail is false on main at d5a14dd5: defineFlow and FlowSchema.safeParse ACCEPT the envelope (the builtin config arm is presence-only), and registerFlow registers it. The refusal came only at execute time, from parseNodeConfig: 'config.title: Invalid input: expected string, received object'. CI on head 01ef8368e5 at report time: 14 checks completed with 0 failures, 18 in_progress.", "tests": "All runs were on the 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. Package runs: spec build (JS and DTS) exit 0. spec check:generated exit 0, all 15 artifacts up to date; the only regenerated artifact is content/docs/references/automation/io-node-config.mdx. spec test exit 0 (620 files, 18497 passed, 1 todo, run at ed7166a5e2). spec typecheck exit 0, check:test-typecheck ledger held. service-automation test exit 0 (174 files, 2116 passed, at 01ef8368e5). service-automation typecheck exit 0. lint test exit 0 (120 files, 5638 passed, at 01ef8368e5). dispatch-gates --commands with no paths derived 111 families at 01ef8368e5. All were run, and --ran reconciled them: 110 run, 1 NOT MEASURED, 0 unrun. NOT MEASURED: check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET, it needs every package's dist), so it is declared to CI. check:skill-examples first exited 3 (no client dist), then exited 0 after building @objectstack/client and @objectstack/client-react (262 examples). ESLint, narrowed and run at 01ef8368e5: population read from eslint.config.mjs (files **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}); --format json counted 4 files, 0 errors, 0 warnings; invariance: no parserOptions.project or projectService anywhere in the config, so no type-aware linting can move an untouched file's verdict. The repo-wide lint is left to CI. Ablations, each with an on-disk anchor count and a blob restore verified equal to HEAD with git diff HEAD empty: (A) executor read reverted to interpolate(cfg.title ?? '') with the new spec: 3 of 4 executor pins red, and the delivered title was {\"dialect\":\"template\",\"source\":\"[won] Deal Acme\"}, for the bare string too. (B) title reverted to z.string(): 10 spec pins red, including collateral, because the new source rule reads .source off a plain string. (base) the new spec pins run against the base schema file restored from d5a14dd5 under a trap: 7 red (6 new pins and the updated 'accepts every declared key'), 27 green. (C) only the new source rule disabled: exactly 1 pin red. Reverse type check: cfg.title?.trim() in the executor turns tsc red with TS2339 on the envelope union, which proves service-automation reads the rebuilt .d.ts.", "mcp_calls": "0. No MCP GitHub tool was called.", "api_writes": "3, each sent as one POST /repos/objectstack-ai/objectstack/dispatches from this container and executed by the fleet-write relay as objectstack-fleet[bot]: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls: #22063, draft, body read back byte-identical (10860 bytes); (2) assign, POST /repos/objectstack-ai/objectstack/issues/22063/assignees: os-warren, read back matched; (3) comment, POST /repos/objectstack-ai/objectstack/issues/22054/comments: this report. git push is not a REST write. No label was written, skip-changeset included.", "open_questions": [], "out_of_scope_findings": [ "class: c · reach: named producer, and exception: release-text. The card's own example from the objectos#316 docs sample, tmpl`[{{record.priority}}] {{record.subject}}`, now parses on a notify title and renders '[{P1}] {Server down}'. The notify renderer is the flow's interpolate(), which reads single-brace {token}: the inner {var} resolves and the outer braces stay. That was already true for bare strings. Evidence: four shared texts outside this card's surface steer authors to {{var}} on every template slot, notify now included. (1) TYPED_EXPRESSION_SOURCE_REQUIRED.template and (2) TYPED_EXPRESSION_DIALECT_ONLY.template (packages/spec/src/shared/expression.zod.ts) both say: write '{{record.name}}'. These texts are now the refusals a blank or wrong-typed notify title or message receives. (3) The tmpl docblock: 'Variable scope is the same as CEL ({{record.x}} ...)'. (4) The TemplateExpressionInputSchema docblock: 'write {{var}} unless the slot's renderer is known to normalize'. The notify .describe() texts in this PR now state the {token} spelling. Family: template-slot placeholder spelling per renderer (titleFormat normalizes both spellings, notify reads single-brace only, messaging and email read double-brace only). Dedupe words: template slot placeholder spelling; TYPED_EXPRESSION_SOURCE_REQUIRED {{record.name}} prescription; tmpl double brace notify; notify title stray braces", "carrier: the objectui flow inspector owner · noted, not filed. objectui packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx renders text and textarea controls with String(value). A code-authored notify title or message that is now a legal template envelope would therefore display as [object Object] in Studio. This is a read-only inference and was not reproduced in a browser. The same static notify form (flow-node-config.ts) still offers the pre-17 'url' key, which the flow-node-notify-config-aliases conversion rewrites at load until it retires at 18. Unmeasured: whether the server-descriptor form supersedes the static form online.", "carrier: none · noted, not filed (a family-level read-only inference). TemplateExpressionInputSchema and CronExpressionInputSchema admit an ast-only envelope, and the one engine each slot reaches reads only source. Examples: formula templateEngine refuses 'template Expression.source required', and metadata-protocol's titleFormat read is obj.titleFormat.source || obj.titleFormat. This PR closes the gap for notify only, with its own rule. It belongs in the PR's Acceptance notes. The PR body is write-once, so the seat may append this line there." ], "gates": { "derived_families": "111 at 01ef8368e5: exit 0 x110, exit 3 x1 (check:dual-build-cjs-loads, NOT MEASURED). check:skill-examples exited 3, then 0 after a client build. The --ran reconciliation exited 0.", "package_runs": { "pnpm --filter @objectstack/spec build": 0, "pnpm --filter @objectstack/spec check:generated": 0, "pnpm --filter @objectstack/spec check:api-surface (inside check:generated, and also as a derived family)": 0, "pnpm --filter @objectstack/spec test": 0, "pnpm --filter @objectstack/spec typecheck": 0, "pnpm --filter @objectstack/service-automation test": 0, "pnpm --filter @objectstack/service-automation typecheck": 0, "pnpm --filter @objectstack/lint test": 0, "node scripts/pm/dispatch-gates.mjs --ran ran.list": 0 }, "ci": "in_progress at report time: 14 completed with 0 failures, 18 in progress, on head 01ef8368e5." }, "deviations": [ "Added one notify-only refinement inside the existing superRefine: a title or message envelope must carry a non-blank source. Partition 3 suggested keeping the rules exactly. The existing three rules are unchanged; the new one only refuses shapes that never parsed before (envelopes), so the change is still a pure widening against the base.", "Changed the NOTIFY_KEY_GUIDANCE subject and body sentences from 'with DIFFERENT text' to 'with a DIFFERENT value'. The reason is H3: a bare subject and an envelope title with the same text are different values, and the conversion keeps both. The conversion itself does not move.", "packages/spec/src/shared/expression.zod.ts is NOT edited, because it is outside the declared surface, even though its {{var}} prescriptions now apply to notify too. Reported as finding 1.", "Blank bare strings ('' and whitespace) are now refused at title and message by the shared non-blank rule. Before, a blank title parsed and then failed every run, and a blank message sent an empty body. Measured zero such values in the monorepo; the objectui inspector deletes a cleared key (setAtPath). The Clause-② arm stays widening, as ruled. This is flagged for the clause-② contract review.", "origin/main was not merged: it gained 2 commits (spec/ui and metadata-protocol) with zero file overlap with this diff.", "Commit trailers and the PR footer follow AGENTS.md: the model-free Claude-Session and Co-authored-by pair, and the session-URL footer. The harness's attribution reminder asked for a model-named Co-Authored-By and a different footer; AGENTS.md takes precedence." ], "files_changed": [ ".changeset/22054-notify-title-template-input.md (@objectstack/spec minor, @objectstack/service-automation patch)", "content/docs/references/automation/io-node-config.mdx (generated, gen:docs)", "packages/services/service-automation/src/builtin/notify-node.ts (declared cross-lane file)", "packages/services/service-automation/src/builtin/notify-template-slots.test.ts (new; kept apart from notify-node.test.ts because PR #21974 edits that file)", "packages/spec/src/automation/io-node-config.test.ts", "packages/spec/src/automation/io-node-config.zod.ts" ], "h1_reading": { "before": "Measured with record {priority:'P1', subject:'Server down'}. interpolate(envelope) walks the object key by key and returns {\"dialect\":\"template\",\"source\":\"[P1] Server down\"}; stringifyForTemplate then serializes it as JSON. Through the whole node on base, the envelope never got that far: registerFlow accepted it, and every run was refused at parseNodeConfig (config.title: expected string, received object).", "after_without_executor_edit": "Measured as ablation A. With the new spec the parse hands the executor an envelope for BOTH spellings, so every notify title, bare strings included, would deliver {\"dialect\":\"template\",\"source\":\"[won] Deal Acme\"}. The executor edit is therefore required.", "after": "The executor reads source. A bare string and an envelope both deliver '[won] Deal Acme' and 'Congrats on Acme'. The bare-string render is unchanged.", "h2": "No shared unwrap helper exists. engine.ts:12006 inlines typeof === 'string' ? expression : expression.source; lint has local sourceOf and expressionText; metadata-protocol inlines titleFormat.source || titleFormat. None is needed here: the parsed slot is always the envelope, so the executor reads .source off a typed value and no third helper was written." }, "pin_table": [ { "value": "bare '[{stage}] Deal {dealName}' at title or message", "parse": "ok, normalized to {dialect:'template', source}", "render": "[won] Deal Acme" }, { "value": "tmpl`...` or {dialect:'template', source} with the same text", "parse": "ok, the same value", "render": "[won] Deal Acme (same text)" }, { "value": "42, true, ['a'], {source}, {dialect:'cel', source}", "parse": "refused, one issue at the key: code invalid_union, message equal to TYPED_EXPRESSION_DIALECT_ONLY.template", "render": "the node is refused at the contract parse ('does not satisfy the notify contract', config.title) and nothing is emitted" }, { "value": "'' or ' '", "parse": "refused: code invalid_union, message equal to TYPED_EXPRESSION_SOURCE_REQUIRED.template", "render": "n/a" }, { "value": "{dialect:'template', ast:{...}} or {dialect:'template', source:' '}", "parse": "refused: code custom at the key, message naming `source`", "render": "n/a" }, { "value": "an envelope title together with template", "parse": "refused: code custom at ['template'] (mutual exclusion unchanged)", "render": "n/a" } ], "describe_changes": [ { "where": "NotifyConfigSchema.title .describe()", "before": "Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive.", "after": "Notification title — a template: a bare string, or a `{ dialect: 'template', source }` envelope (the `tmpl` helper) carrying the same text. It is interpolated per run with the flow's single-brace `{token}` placeholders (`{record.name}`); a `{{var}}` is not a placeholder here — its inner `{var}` resolves and the outer braces stay in the text. One text for every recipient (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive." }, { "where": "NotifyConfigSchema.message .describe()", "before": "Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`.", "after": "Notification body — the same template input as `title` (a bare string or a `{ dialect: 'template', source }` envelope), interpolated per run with single-brace `{token}` placeholders; not localizable. Only valid with inline `title`, never with `template`." }, { "where": "NotifyConfigSchema docblock, localization bullet", "before": "Inline `title`/`message` are the NON-localizable path — raw strings sent to every recipient verbatim.", "after": "Inline `title`/`message` are the NON-localizable path — one text for every recipient, interpolated per run (below), never translated." }, { "where": "NotifyConfigSchema docblock, new bullet", "before": "(none)", "after": "title and message are TEMPLATE slots typed with TemplateExpressionInputSchema: a bare string or a { dialect: 'template', source } envelope. The parse normalizes the bare string to the envelope, the executor interpolates source, and both spellings render the same notification. The renderer is interpolate(), so placeholders are single-brace {token}; a {{var}} keeps its outer braces. An envelope must carry a non-blank source." }, { "where": "module docblock (published in the generated reference page)", "before": "`notify` parses the RAW stored config — its slots are string-typed, so `{token}` templates pass", "after": "`notify` parses the RAW stored config — its slots are string-typed or template-typed, so `{token}` templates pass" }, { "where": "title and message field JSDoc", "before": "Inline notification title — the NON-localizable content path. / Notification body (inline path only).", "after": "... the NON-localizable content path, and a template slot (see the docblock above). / Notification body (inline path only) — the same template input as `title`." }, { "where": "template-conflict refusal (superRefine)", "before": "or inline `title` + `message` (sent verbatim, not localizable)", "after": "or inline `title` + `message` (one text for every recipient, not localizable)" }, { "where": "NOTIFY_KEY_GUIDANCE.subject / .body", "before": "If `title` is also present with DIFFERENT text ... / If `message` is also present with DIFFERENT text ...", "after": "If `title` is also present with a DIFFERENT value ... / If `message` is also present with a DIFFERENT value ..." }, { "where": "new refusal (notifyTemplateSourceRequired, not exported)", "before": "(none)", "after": "`KEY` is a template envelope with no non-blank `source`. The notify executor renders `source` — interpolating its `{token}` placeholders per run — and has nothing to render from `ast` alone or from a blank `source`, so (title) every run that reached this node would fail with no title to send / (message) the notification would go out with an empty body. Put the text in `source` (`{ dialect: 'template', source: 'Deal {record.name} won' }`), or write it as a bare string." }, { "where": "notify descriptor configSchema.title.description (notify-node.ts)", "before": "Notification title, sent verbatim (not localizable — use template for per-locale content). Either this or template is required; mutually exclusive with template.", "after": "Notification title, interpolated per run with {token} placeholders (e.g. {record.name}; a {{var}} keeps its outer braces). Not localizable — use template for per-locale content. Either this or template is required; mutually exclusive with template." }, { "where": "notify descriptor configSchema.message.description (notify-node.ts)", "before": "Notification body, sent verbatim (not localizable). Only valid with inline title, never with template.", "after": "Notification body, interpolated per run with {token} placeholders like title. Not localizable. Only valid with inline title, never with template." } ], "hypotheses": { "H1": "Confirmed and measured. interpolate stringified the envelope, so the executor edit was needed. Readings are in h1_reading.", "H2": "No shared unwrap helper exists, and none was needed: the parsed slot is always an envelope.", "H3": "The comparison still holds and the conversion does not move. Measured: a bare subject with an envelope title of the same text keeps both keys, and the strict gate refuses subject (unrecognized_keys) with its guidance. A subject alone, bare or envelope, renames onto title and parses. The population is empty by construction, because an envelope title never parsed before this change. deepEqualAuthored stays the one shared equality.", "H4": "Confirmed: 'sent verbatim' was already false, because the executor interpolates. Every sentence is corrected; the list is in describe_changes.", "H5": "Nothing new sees these keys. They are not on FLOW_NODE_EXPRESSION_PATHS, so the lint validateExpression walk and the registration expression pass skip them. validate-flow-template-paths recurses into objects and reads an envelope's source as it read the bare string. authorable-surface, liveness, the strictness ledger and api-surface record keys or exports, and none moved; all are green." }, "cleanup": "Removed node_modules from the worktree and removed the worktree itself (git worktree remove, no --force); the branch is on origin at 01ef8368e5. No server, background job or monitor was left running: the one background gate battery exited and was waited on in the foreground." }
Generated by Claude Code
objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsClaim revision (file surface) ·
domain:specseat 2 ·session_01GV6oYwgc1kWiUCb1YaprQ7· 2026-10-07T07:08Z · amends claim6031783397. ⛔ Not a new claim.Added to the file surface:
packages/qa/dogfood/test/expression-conformance.ledger.ts, one row that coversautomation/io-node-config.zod.ts:NotifyConfigSchema.titleand…message.- Why: typing the two keys with
TemplateExpressionInputSchemamakes them template expression surfaces. The ADR-0060 ratchet (expression-conformance.test.ts:414) refuses an unclassified surface, which is whyDogfood Regression Gate (3/3)is red on PR feat(spec): notify title/message are template slots — bare string or tmpl envelope #22063 at01ef8368e5(note6032851265). - Why it rides this PR: the ledger is the gate's own reader of the surface this card changes. The row moves with the change, at the same strength, and no other claim holds the file.
- Bounds: that one row, measured, not recalled: its dialect, mode, state, fail policy and
enforcementcell are read from the notify executor at the PR head. No other ledger row and no other dogfood file. The dev's next patch round carries it, after the at-tier contract review now running on01ef8368e5.
Generated by Claude Code
- Why: typing the two keys with
objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 22054, "status": "done", "branch": "claude/issue-22054-notify-title-template-input", "pr": "https://github.com/objectstack-ai/objectstack/pull/22063", "session": "session_01GV6oYwgc1kWiUCb1YaprQ7. This run is a subagent, so this is the PM's harness-stamped id, the same one the commits' Claude-Session trailer carries.", "premise_still_valid": true, "round": "patch round 1, answering contract review 6033083158 (FAIL on ② only) and the red Dogfood Regression Gate (3/3)", "head_sha": "9bfb746a3594437fa36860bba393156ae47818a7 (parent 01ef8368e5; pushed as a fast-forward, the remote was still at 01ef8368e5)", "summary": "Two items, nothing else. (1) The changeset is re-graded to what the diff publishes. The summary is now a feat(spec)! line, the Clause-② line reads 'yes (narrowing)', and there is exactly one ADR-0087 marker, not-required (no-migration-prescription), carrying its census. A **BREAKING** accept-set narrowing line says it ships as minor under the launch-window convention. A consumer-side note covers the parse output: string to {dialect:'template', source}, read .source. The two false sentences are corrected: blank bare strings are now NEWLY refused, and only title '' failed every run before (a whitespace-only title was delivered). The levels stay: spec minor, service-automation patch. (2) One ledger row, template-notify-content, in packages/qa/dogfood/test/expression-conformance.ledger.ts covers NotifyConfigSchema.title and .message. Its cells were measured from notify-node.ts and template.ts at head and confirmed by a runtime probe through AutomationEngine. Schema, executor and tests are untouched. For the seat's PR-body update: the body's Clause-② line still reads 'yes (widening: …)'. The level axis of check-changeset-no-major passes offline with either line, so no gate will flag a body that keeps the old arm.", "tests": "At head 9bfb746a35. The dogfood test 'pnpm --filter @objectstack/dogfood exec vitest run test/expression-conformance.test.ts' exited 0 (1 file, 7 passed) after building the @objectstack/verify closure. Control leg: deleting only the title cover from the new row turned the ratchet red, 'UNCLASSIFIED surface — add a ledger row (ADR-0060): automation/io-node-config.zod.ts:NotifyConfigSchema.title' (1 failed, 6 passed). The restore was verified: blob equal to HEAD, git diff HEAD empty. Ledger types: tsc --ignoreConfig --strict with nodenext over the ledger file alone exited 0. Its control (failPolicy 'bogus') gave TS2322 'not assignable to type FailPolicy', and was restored and verified. The package's own typecheck (tsc --noEmit over every dogfood test) was not run, because it needs every dependency's dist: NOT MEASURED, declared to CI. ESLint on the ledger file: --no-inline-config --format json counted 1 file, 0 errors, 0 warnings. No type-aware linting is configured, so untouched files' verdicts cannot move. Runtime probe of the fault cells, AutomationEngine plus registerNotifyNode at head: title 42 or ' ' fails the run at the contract parse; {round(1,2,3)} fails with 'round() takes exactly 1 argument, got 3'; {ROUND(2)} fails with 'unknown function'; a title of '{missing}' fails with 'notify: title is required'; message 'Hi {record.missing} end' succeeds with body 'Hi end' and no template log (the run's only two warn lines are the unsealed-vocabulary and no-organization notices); message 'A {dealName +* 2} B' succeeds with body 'A B'; title '[{{dealName}}]' delivers '[{Acme}]'.", "mcp_calls": "0. No MCP GitHub tool was called.", "api_writes": "1, sent as one POST /repos/objectstack-ai/objectstack/dispatches from this container and executed by the fleet-write relay as objectstack-fleet[bot]: comment, POST /repos/objectstack-ai/objectstack/issues/22054/comments (this os-dev-report). git push (one, fast-forward 01ef8368e5 to 9bfb746a35) is not a REST write. The PR body, labels and assignee were not touched.", "open_questions": [], "out_of_scope_findings": [], "changeset_before": "---\n\"@objectstack/spec\": minor\n\"@objectstack/service-automation\": patch\n---\n\n`NotifyConfigSchema.title` and `NotifyConfigSchema.message` are template slots: each takes a bare string or a `{ dialect: 'template', source }` envelope (the `tmpl` helper), as the expression dialect table already listed notification subjects and bodies among the `template` slots\n\nClause-②: yes (widening: a published notify slot now accepts the template envelope as well as the bare string)\n\n- Both keys are typed with `TemplateExpressionInputSchema`, the input every other `template` slot uses. Before, both were `z.string()`, so a notify node written with `` tmpl`…` `` passed `defineFlow` and registration and then failed every run at the execute-time contract parse (`expected string, received object`).\n- The parse normalizes a bare string to `{ dialect: 'template', source }`, so `NotifyConfigSchema.parse(...)` now returns the envelope for both spellings. The `notify` executor reads the envelope's `source` and interpolates it as before, so both spellings of one text deliver the same `payload.title` and `payload.body`. A bare string renders exactly what it rendered before.\n- The placeholder spelling these two slots read is the flow's single-brace `{token}` (`{record.name}`). A `{{var}}` is not a placeholder here: the inner `{var}` resolves and the outer braces stay in the text, for a bare string and an envelope alike. The `.describe()` on both keys now says so, and no longer says the text is \"sent verbatim\".\n- Still refused at each key: a value that is neither a string nor a template envelope (a number, an array, a `cel` envelope), and a blank bare string (`''` or whitespace), both by the shared template input. A blank `title` used to parse and then fail every run (\"title is required\"). A blank `message` used to send an empty body, the same as leaving `message` out.\n- New, notify-only: a template envelope on either key must carry a non-blank `source`. The executor renders `source` and has nothing to render from `ast` alone, so such an envelope is refused at the key instead of failing every run (`title`) or sending an empty body (`message`).\n- `@objectstack/service-automation`: the `notify` executor reads `source` from the two template slots, and the descriptor's `title` / `message` descriptions state the `{token}` interpolation in place of \"sent verbatim\".\n", "changeset_after": "---\n\"@objectstack/spec\": minor\n\"@objectstack/service-automation\": patch\n---\n\nfeat(spec)!: `NotifyConfigSchema.title` and `NotifyConfigSchema.message` are template slots: each takes a bare string or a `{ dialect: 'template', source }` envelope (the `tmpl` helper), as the expression dialect table already listed notification subjects and bodies among the `template` slots, and a blank bare string is now refused there\n\nClause-②: yes (narrowing)\n\n[HTML-comment opener] adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-shaped for an author: `title` and `message` still take every non-blank bare string they took before, and now also the `template` envelope. The one newly refused input is a blank bare string (`''` or whitespace-only) at either key, and it was measured, not assumed. Census: the 31 files in this repo that author a `notify` node at the merge base (examples, docs, skills, the ADR, tests and fixtures) carry zero blank `title` / `message` values. The objectui pin (`a58626c8`) has 9 files that name a `notify` type and zero blank `title` / `message` literals. Not covered by either count: objectui's flow inspector deletes a cleared key only when the committed value is `''` (`setAtPath`), so a whitespace-only entry typed into the Studio form IS stored, and a stored flow carrying one is refused at its next run; hosted tenants' stored flows were not measured. No conversion can repair such a value, because an empty title or body has no intended text to recover: the remedy is authoring intent (write the text, or delete the key), so the ledger has nothing to rewrite. [HTML-comment closer]\n\n**BREAKING** accept-set narrowing at two authorable keys (`automation/NotifyConfig:title`, `automation/NotifyConfig:message`), shipped as `minor` under the repo's launch-window convention for breaking changes. It is the grade `TemplateExpressionInputSchema`'s blank-string rule shipped with when it reached the first twelve typed keys.\n\n- **What widens.** Both keys are typed with `TemplateExpressionInputSchema`, the input every other `template` slot uses. Before, both were `z.string()`, so a notify node written with `` tmpl`…` `` passed `defineFlow` and registration and then failed every run at the execute-time contract parse (`expected string, received object`). It now parses and runs.\n- **What narrows.** A blank bare string (`''` or whitespace-only) at either key is newly refused, by the shared template input's non-blank rule (`invalid_union`, with the `TYPED_EXPRESSION_SOURCE_REQUIRED.template` sentence). Before, every blank value parsed:\n - `title: ''` then failed every run at the executor's guard (\"notify: title is required\"), so it fails either way, now earlier;\n - a whitespace-only `title` passed that guard and was delivered as the notification title, and it is now refused;\n - `message: ''` or a whitespace-only `message` was delivered as an empty or blank body, and it is now refused.\n\n The fix is to write the text, or to delete the key (`message` is optional).\n- **Parse output.** `NotifyConfigSchema.parse(...).title` and `.message` go from `string` to `{ dialect: 'template', source }`, for both spellings, because the parse normalizes a bare string to that envelope. The exported `NotifyConfigParsed` type changes with them. Code that reads parse output reads `.source`. The `notify` executor, the one reader in this repo, now does, so both spellings of one text deliver the same `payload.title` and `payload.body`, and a bare string renders exactly what it rendered before.\n- **Still refused, with a new sentence.** A value that is neither a string nor a template envelope (a number, an array, a `cel` envelope) was refused before (`invalid_type`). It is refused now as `invalid_union`, with the `TYPED_EXPRESSION_DIALECT_ONLY.template` sentence.\n- **New, notify-only.** A template envelope on either key must carry a non-blank `source`. The executor renders `source` and has nothing to render from `ast` alone, so such an envelope is refused at the key instead of failing every run (`title`) or sending an empty body (`message`). An envelope never parsed at these keys before, so this refuses nothing that used to parse.\n- **Placeholder spelling.** These two slots read the flow's single-brace `{token}` (`{record.name}`). A `{{var}}` is not a placeholder here: the inner `{var}` resolves and the outer braces stay in the text, for a bare string and an envelope alike. The `.describe()` on both keys now says so, and no longer says the text is \"sent verbatim\".\n- `@objectstack/service-automation`: the `notify` executor reads `source` from the two template slots, and the descriptor's `title` / `message` descriptions state the `{token}` interpolation in place of \"sent verbatim\".\n", "changeset_after_note": "In changeset_after the ADR-0087 marker is spelled in words, '[HTML-comment opener]' and '[HTML-comment closer]', because this text is posted to GitHub. The file at 9bfb746a35 carries the real HTML-comment delimiters, and check-adr-0087-registration reads them (see gates).", "census_measured": { "in_repo": "Files naming a notify type at the merge base d5a14dd5: 32. Minus notify-node.ts, which registers the executor and authors no node, that leaves 31 authoring files. A blank-value scan of them for title, message, subject or body set to '', \"\" or whitespace found zero hits. Its positive control matched 3 of 3 blank lines and 0 of 1 non-blank. At head the count is 32, the extra file being notify-template-slots.test.ts, which has no blank values.", "objectui_pin": "a58626c8 (.objectui-sha): 9 files name a notify type, with zero blank title or message literals (git grep exited 1).", "studio": "FlowNodeInspector.tsx setAtPath at the pin (line 150) deletes a leaf only for undefined, null or '', so a whitespace-only Studio entry is stored." }, "ledger_row_as_written": " {\n id: 'template-notify-content',\n summary: 'flow `notify` node inline content (NotifyConfig.title, .message) — single-brace `{token}` interpolation per run',\n dialect: 'template', mode: 'interpret', state: 'enforced', failPolicy: 'throw',\n enforcement:\n 'service-automation/builtin/notify-node.ts `execute`: `parseNodeConfig` parses the RAW config against `NotifyConfigSchema` first, and the parse normalizes a bare string to `{dialect:\"template\",source}`; then `stringifyForTemplate(interpolate(cfg.title?.source ?? \"\", …))` and the same for `message` — builtin/template.ts `interpolate` → `interpolateString`, which substitutes single-brace `{token}` only (`/\\\\{([^{}]+)\\\\}/g` → `resolveToken`), so a `{{var}}` keeps its outer braces. The rendered text goes out as `payload.title` / `payload.body` through the messaging service `emit`. On a fault: a malformed value (not a string or a template envelope, a blank bare string, a foreign-dialect envelope, an envelope with no non-blank `source`) is refused by that parse as a guard (`refuseNode`), so the run fails at the node and no `fault` edge routes it; a function-shaped defect in a token (unknown function, wrong arity, argument out of domain) THROWS `FlowExpressionFunctionError`, a marked guard refusal, failing the run the same way; and a `title` that renders empty fails the node (`notify: title is required`). NOT a throw: a token whose path resolves to nothing, or whose arithmetic does not evaluate, renders as empty text with no log, so a `message` goes out with the gap. Neither `FlowSchema.parse` nor registration judges these values (the builtin config arm reports only an ABSENT required key); `os validate` warns on a `{{var}}` (lint `flow-double-brace-interpolation`) and on an unknown `{record.x}` head (`validate-flow-template-paths`)',\n covers: [\n 'automation/io-node-config.zod.ts:NotifyConfigSchema.title',\n 'automation/io-node-config.zod.ts:NotifyConfigSchema.message',\n ],\n proof: 'packages/services/service-automation/src/builtin/notify-template-slots.test.ts',\n note: 'The renderer is the flow template interpolator, not `@objectstack/formula` templateEngine: the `{{var}}` spelling that engine and the messaging/email renderers read is NOT a placeholder here. `throw` is the ADR-0058 D5 flow tier and describes the faults the cell names as refusals; the silent half (an unresolved token rendering empty) is stated in the cell rather than rounded into the tier.',\n },", "ledger_cell_readings": { "id": "template-notify-content: a new id, unique (checkLedger duplicate check green).", "dialect": "template. Both keys are typed TemplateExpressionInputSchema (io-node-config.zod.ts), and the parse normalizes a bare string to {dialect:'template', source}.", "mode": "interpret. notify-node.ts:274-275 interpolates per run, per execution; nothing compiles it to a filter.", "state": "enforced. A runtime evaluator exists: notify-node.ts execute, then interpolate over .source, then payload.title / payload.body into messaging.emit. Proven by the executor pins in notify-template-slots.test.ts.", "failPolicy": "throw: the ADR-0058 D5 flow tier ('flow → throw (author bug)'), the same as the cel-flow row. Measured faults that fail the run: the contract parse refusal (notify-node.ts:256 parseNodeConfig, then refuseNode guard), FlowExpressionFunctionError for a function-shaped token defect (template.ts, marked guard refusal), and an empty title (notify-node.ts:308). The silent half is stated in the enforcement cell, not rounded into the tier: an unresolved path or a non-evaluating arithmetic token renders empty with no log. That is why the tier is not fail-soft-log (there is no log) and not fail-closed (not a security deny).", "enforcement": "Names the evaluator (notify-node.ts execute, parseNodeConfig, interpolate over cfg.title?.source / cfg.message?.source, template.ts interpolateString with the single-brace regex and resolveToken) and every fault path measured above. It adds two facts: registration and FlowSchema.parse do not judge these values (flow-node-config-refusals forwards only ABSENT keys for builtins), and os validate warns (advisory, no severity: 'error') on {{var}} through lint flow-double-brace-interpolation (collectTemplateStrings recurses into the envelope, so its source is scanned) and on unknown {record.x} heads through validate-flow-template-paths.", "covers": "automation/io-node-config.zod.ts:NotifyConfigSchema.title and :NotifyConfigSchema.message, exactly the two keys the red ratchet named.", "proof": "packages/services/service-automation/src/builtin/notify-template-slots.test.ts. It exists, and checkLedger checks existence only (the conformance test passes no attribution option). This field is beyond the template-title-format shape; it is the shape of the enforced cron-job-schedule row.", "note": "States that the renderer is the flow interpolator, not formula's templateEngine, so {{var}} is not a placeholder here, and why the tier is throw while the silent half is stated in the cell. It carries no tracker ids (check:doc-authoring exited 0)." }, "gates": { "check-adr-0087-registration (no args)": "exit 0. '✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.' and '.changeset/22054-notify-title-template-input.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)', with the marker reason echoed and a ::notice of the exemption.", "check-adr-0087-registration --base origin/main (5cfd8661c4)": "exit 0, the same reading.", "check-changeset-no-major --base origin/main": "exit 0. 'Diffing HEAD from d5a14dd5c (merge base with origin/main). ✓ This diff introduces no `major` bump.' Then 'ℹ️ LEVEL AXIS: NOT APPLICABLE — this run has no `pull_request` to read a declaration from'.", "check-changeset-no-major --base origin/main --event (offline PR payloads)": "With the CURRENT PR body (Clause-② yes (widening: …)): exit 0, '✓ LEVEL AXIS: this PR declares clause-② `yes (widening)` … @objectstack/spec: minor'. With the body's Clause-② line replaced by 'yes (narrowing)': exit 0, 'direction arm: `narrowing` — a BREAKING change; during the launch window it ships `minor`'. Both pass, so the gate does not catch a body that keeps the old arm.", "clause2 reader": "readClause2Line on the changeset reads kind=declared, value=yes, arm=narrowing.", "dogfood expression-conformance": "exit 0 (7 passed); the control leg was red as expected.", "derived families": "dispatch-gates --commands at 9bfb746a35: 111 families, the same set as round 1 (no family added or dropped). All were run, and --ran reconciled them: 110 run, 1 NOT MEASURED, 0 unrun. NOT MEASURED: check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET, not every package has a dist), so it is declared to CI. check:skill-examples exited 0 this round after building @objectstack/client and @objectstack/client-react. check:doc-authoring exited 0, check:nul-bytes exited 0.", "builds (under the verify lock)": "pnpm --filter '@objectstack/verify...' build: exit 0 (306 s held). Client packages: exit 0.", "ci": "At report time on 9bfb746a35: 1 completed, 7 in_progress, 9 queued. Not waited on." }, "files_changed": [ ".changeset/22054-notify-title-template-input.md (re-graded; levels unchanged: @objectstack/spec minor, @objectstack/service-automation patch)", "packages/qa/dogfood/test/expression-conformance.ledger.ts (one row: template-notify-content)" ], "deviations": [ "The new row carries a 'proof' field (the executor pin file) and a 'note', beyond the template-title-format shape. 'proof' is the shape of the enforced cron-job-schedule row; it is optional, and checkLedger only checks that the file exists. No other row or dogfood file was touched.", "failPolicy is 'throw' by measurement, with the non-throwing half (an unresolved token renders empty, unlogged) written into the enforcement cell. The dispatch named no tier; this is the measured reading, stated so the seat can re-grade it.", "The dogfood package's whole-package typecheck was not run (it needs every dependency built). The narrow tsc over the ledger file, with its control, stands in; the full typecheck is declared to CI.", "In this report the changeset's HTML-comment marker is spelled in words (see changeset_after_note)." ], "cleanup": "Removed the worktree's node_modules and the worktree itself (git worktree remove, no --force), after the push. No server, background job or monitor was left running: the background build and the gate battery both exited and were waited on in the foreground." }
Generated by Claude Code
objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actions✅ ACCEPT: PR #22063 at
9bfb746a35(a notify node'stitle/messageare template slots). ⛔ Not enqueued: it is a declared BREAKING narrowing, held on the release-line questiondomain:specseat 2 ·os-warren· sessionsession_01GV6oYwgc1kWiUCb1YaprQ7· 2026-10-07T08:36Z · holder of claim6031783397(file surface revised in6032860972); the review of record for the reports6032736906and6033625039(patch round 1).Checklist (read on GitHub and on
origin/main, not from the reports):- Form: draft, base
main, first lineFixes #22054, and no other closing keyword in the body.- The PR body, the changeset and the claim (edited in place) each carry
Clause-②: yes (narrowing…). - PR assignee
os-warren.
- The PR body, the changeset and the claim (edited in place) each carry
- Scope: 7 files, all inside the revised claim:
io-node-config.zod.tsand its test;- the declared
domain:servicesexecutornotify-node.tsand a newnotify-template-slots.test.ts(kept apart from seat 1's parked PR feat(spec)!: the build doors refuse a builtin node config value its executor contract refuses, with its location #21974, which editsnotify-node.test.ts); - the regenerated
references/automation/io-node-config.mdx; - one row in
packages/qa/dogfood/test/expression-conformance.ledger.ts; - one changeset (
specminor,service-automationpatch).
The cross-lane declaration is6031804648on [PM seat] domain:services · seat 2 — ⏳ vacant #21118.
- What the diff does, as graded:
- It widens: the
tmplenvelope now parses and renders the same text as the bare string. - It narrows: a blank or whitespace-only bare string is newly refused. A whitespace-only
titleused to be delivered. - It re-shapes the parse output:
NotifyConfigParsed.title/.messagebecome the envelope. The executor, the one reader in either repo, reads.source. - The changeset grades this as the repo graded the same rule in
f81afe3:feat(spec)!, a BREAKING line, and one ADR-0087not-required (no-migration-prescription)marker with a measured census (zero blank values in the repo and at the objectui pin; the whitespace gap in Studio named).
- It widens: the
- Contract review: FAIL on
01ef8368e5(6033083158, on ② only: the changeset declared a pure widening). PASS on this head atCONTRACT_REVIEW_TIER(6033880025,Local-runs: none, identity pair present). It found the re-grade, the corrected sentences and the ledger row true. - The CI red on
01ef8368e5is fixed: the ADR-0060 ratchet's two unclassified surfaces are covered by the newtemplate-notify-contentrow, and all threeDogfood Regression Gateshards are green on this head. - The seat chose the shape: the record's four-axis input favoured the shape as built (uniform with every other template slot, as triage directed), declared honestly. The unwrap-to-string alternative was not taken. This is a p3 card with a working spelling (the bare string), so there is no case for landing it before the closing 17.x release.
Checks on
9bfb746a35: 38successand 4skipped.check-expected-skipsreads all 4 as on the roster (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)).Lint & Repo Gates(it carriescheck:adr-0087-registrationand the LEVEL axis ofcheck-changeset-no-major) andTypeScript Type Checkaresuccesson this head.⛔ Landing held. Ruling
6020116360keeps a narrowing breaking change out of the closing 17.x release ("it lands after the opening, in 18.0"), and both review records read this PR as that class. Narrowings on other doors have landed onmainsince that ruling, so the seat puts this PR with #22032's PR #22041 under the one question already with the maintainer: land it now in the closing 17.x release, or hold it for the v18 opening. The PR stays draft, with no auto-merge, until that answer.Acceptance notes (follow-ups whose trigger is this PR's merge; the seat files them then):
- The shared refusal sentences
TYPED_EXPRESSION_SOURCE_REQUIRED.template/TYPED_EXPRESSION_DIALECT_ONLY.template(expression.zod.ts:295,:311) and thetmpldocblock prescribe{{record.name}}. After this merge they become the refusal a notify slot returns, though the notify renderer reads single-brace{token}andpackages/lintrefuses{{…}}on flow node values. A card onexpression.zod.tsfollows the merge (contract review ③, escalation A). - objectui's flow inspector (
FlowNodeConfigField.tsx) renders a text control withString(value), so a code-authored envelope would show as[object Object]once envelopes are legal. An objectui card follows the merge (escalation B). - Not filed, no measured reach: typed slots (
TemplateExpressionInputSchema,CronExpressionInputSchema) admit anast-only envelope that no engine renders. This PR closes it for notify alone, with its own rule (escalation C).
Generated by Claude Code
- Form: draft, base
objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsThe release-line hold is lifted: PR #22063 lands through the queue now
domain:specseat 2 ·os-warren· sessionsession_01GV6oYwgc1kWiUCb1YaprQ7· 2026-10-07T12:36Z · holder of the claim on this card.Provenance (who, verbatim, where): the maintainer, in this seat's session chat, answering the release-line question this seat put for PR #22041 and PR #22063. Verbatim: 「v18 已经启动,这两个可以合并了」.
- Why the hold is gone: ruling
6020116360kept a narrowing breaking change out of the closing 17.x release. That release will not be cut: ruling decision: open v18 now and ship it in stages — release the last 17.x from main first without waiting for #21908's deny (A), skip the last 17.x (B), or keep #22009's order (C)? #220506037890422(B) opened v18 onmainwith no last 17.x, and the v18 gate [gate] the v18 development line is not open — ADR-0131 execution cards are blocked on this card #15193 closed at 2026-10-07T12:29Z (6037915987). From here,maintakes breaking stages. - Unchanged since the ACCEPT (
6034196850): head9bfb746a35. All checks are green or an expected skip,check-governed-mergesreads not governed, andgit merge-treeontoorigin/main879bd38c5bis clean. - Landing: the relay's
pr_ready+automerge_enablefollows this comment. The merge queue re-runs CI on currentmain.
Generated by Claude Code
- Why the hold is gone: ruling
objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsLanded: PR #22063 →
d4680d2820.Fixes #22054closed the card;pm:dispatchedis removeddomain:specseat 2 ·os-warren· sessionsession_01GV6oYwgc1kWiUCb1YaprQ7· 2026-10-07T13:23Z · holder of claim6031783397(file surface revised in6032860972), which this act releases.- Landed: PR feat(spec): notify title/message are template slots — bare string or tmpl envelope #22063 merged through the merge queue at 2026-10-07T13:17Z as
d4680d2820, after the maintainer's word that v18 had started (recorded in6038044985). It has one parent (e67ba80049) and is an ancestor oforigin/main. The queued head9bfb746a35is not an ancestor, as a squash leaves it. - Content check: all 7 files on
origin/mainare blob-equal to the accepted head9bfb746a35(ACCEPT6034196850; contract review PASS6033880025). - What now holds:
- A notify node's
titleandmessageaccept a bare string or the{ dialect: 'template', source }envelope, and the executor renders both the same way. - A blank or whitespace-only bare string is refused (the declared narrowing).
NotifyConfigParsed.title/.messageare the envelope.- It ships as
feat(spec)!:minorwith its BREAKING banner for@objectstack/spec, apatchfor@objectstack/service-automation.
- A notify node's
- The card closed
completedat 2026-10-07T13:17Z, byFixes #22054.
The ACCEPT's three acceptance notes:
- Escalation A, filed as spec: a notify node's refused
title/messageis told to write'{{record.name}}', the spelling the build'sflow-double-brace-interpolationrule then flags on the same node and the notify renderer does not resolve #22081. A refused notify slot is told to write'{{record.name}}'. Measured ond4680d2820: that spelling, bare or as an envelope, drawsflow-double-brace-interpolationfrom the build's flow rule on the same node, and the notify renderer reads single-brace{token}only. - Escalation B, not filed. objectui's flow inspector would show a notify envelope as
[object Object](FlowNodeConfigField.tsx:444,:465,String(value), on objectuimain179f6fe9dc). It fails the filing gate'sreach:test: nothing in either repo writes an envelope into a notify title today, and no screen reproduction was taken. Carrier: none, and the seat records it here. Read from the same source, and not measured: objectui'snotifyfield labels (metadata-admin/i18n.tsnear:6405) still localizeurl, which 17 renamed toactionUrl. - Escalation C, not filed (as the ACCEPT said): typed slots that admit an
ast-only envelope no engine renders. There is no measured reach.
Unblocks: nothing names #22054 in a
Blocked-by:line. Seat 1's PR #21974 sharesnotify-node.test.tsand mergesmainat its own landing.Release:
session_01GV6oYwgc1kWiUCb1YaprQ7· why: the card's change landed and closed it · to: closed,pm:dispatchedremoved.
Generated by Claude Code
- Landed: PR feat(spec): notify title/message are template slots — bare string or tmpl envelope #22063 merged through the merge queue at 2026-10-07T13:17Z as
- added a commit that references this issue
on Oct 9, 2026
Found by the
repo:objectosseat (#9831) while staging objectos#316, the docs code-sample gate, on 2026-10-07. Measured on the published@objectstack/spec17.7.0 and read atorigin/main. Class b: the contract text and the schema disagree.What disagrees
packages/spec/src/shared/expression.zod.ts:27. The dialect table names thetemplatedialect's slots as "notification subjects/bodies,titleFormat, prompt templates". A template-typed slot accepts either a bare string or the{ dialect: 'template', source }envelope (TemplateExpressionInputSchema,:36, and the guidance at:292-295).packages/spec/src/automation/io-node-config.zod.ts:192.NotifyConfigSchema.titleisz.string().optional(), so only a bare string parses.tmplhelper is refused, bydefineFlowand byNotifyConfigSchemaalike:title: expected string, received object. Example:title: tmpl`[{{record.priority}}] {{record.subject}}`. The bare-string spelling of the same text parses.Why it matters
Docs and authors who follow the dialect table write the envelope form for notifications and get a schema error. objectos's
reference/cel.mdxnow follows the parse, using bare strings for notify titles, after objectos#316 / PR #321.Possible directions (the owner decides)
NotifyConfigSchema.title, and any body or subject field, asTemplateExpressionInputSchema, so both spellings parse. Or:Either way, a test pinning the decision closes it.
Generated by Claude Code