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 |
…xecutor contract refuses, with its location (objectstack-ai#21974) Fixes objectstack-ai#21898 Clause-②: yes (narrowing) **Merge gate (the ruling's timing):** this PR merges only once `.objectui-sha` on `main` is at or after `5ba255538a` (objectui#11670, the Studio designer storing a screen field's Min / Max as numbers). At this base the pin is `0abd4f9f87`, which does not carry it. This PR does not move the pin. **At landing:** both gates are met. - The pin: `.objectui-sha` reads `a58626c88d`, which contains `5ba255538a`. - The v18 line is open: objectstack-ai#15193 has closed. `main` came in by two pure merges, `42ce99cf91` and `00bf19bdb5`. Each tree equals its `git merge-tree`. One test commit, `c5545a54a6`, adds a 16th file: `packages/services/service-automation/src/builtin/notify-template-slots.test.ts`. - Its `title: 42` case came with objectstack-ai#22063. It now asserts the registration refusal, then the executor's refusal past the doors (claim revision `6040112148`). - At-tier PASS `6041691539` on `c5545a54a6`. **Draft.** Patch round 1 re-judges the cross-lane fixtures that the claim revision `6012822762` declared (objectstack-ai#6021 comment `6012831917`; objectstack-ai#6367 comment `6012842570`). See "Cross-lane fixtures re-judged" below. No source line moved in any of those packages. The build doors now refuse a **value** that a builtin node's executor contract refuses, with its location, at `FlowSchema.parse`, `objectstack validate` and `objectstack compile`. Ruling `6010677104` (A) gives the pin, the measured pair: `create_record` with `config.outputVariable: 42`, and a screen field with a string `min`. Each is refused at save with its location. Until now both passed every build door and registered, and then every run that reached the node failed at the executor's contract parse. ## What changes `packages/spec/src/automation/flow-node-config-refusals.ts`: the builtin executor-contract arm of `flowNodeConfigRefusals` is no longer presence-only. A contract issue at a key the author **wrote** is now refused with the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, anchored at the key. It is the same code, and the same message builder, the approval contract uses. **No new code** joins `FLOW_SLOT_REFUSAL_CODES`. The refusal is kept only where the build knows what the run will parse (`builtinValueJudged`). Each carve-out sits where another judge owns the finding, or where the run may parse something other than what was authored: - **Key membership is not judged.** That covers an undeclared key (`unrecognized_keys`) and a tombstoned one (a `retiredKey()`, `invalid_type` expecting `never`). Registration refuses an undeclared key against the descriptor, with its own prescriptions. The lint names the retired script keys. The D2 layer rewrites retired spellings first at the two doors that convert. - **A region slot**, the slot itself included (`try: 5`, a one-branch `parallel`). Region shape belongs to `validateControlFlow`; region nodes are judged as graphs of their own. - **A `predicate` or `value` ledger slot**, at or inside it: a screen field's `visibleWhen`, and a CRUD `fields` value. `predicateSlotRefusal` judges the first (its non-strings are left to `registerFlow` and `validate` by ruling, per the `flow.zod.ts` note). The value-envelope pass judges the second. - **`http.signingSecret`.** A secret held in the credential channel replaces the literal before the parse. - ⛔ **A value carrying a `{token}` anywhere inside it.** It is never refused for its pre-interpolation type. The pattern is the interpolator's own, which also matches the double-brace and dollar-brace spellings. `getBuiltinNodeConfigContracts()` keeps its export, its shape and its 13 entries, and the lazy-build cycle note stands. The approval arm is unchanged and still judged whole. The docblocks that said "presence-only" moved: the module header, the judge's docblock, `absentAt`, two blocks in `flow.zod.ts`, and the approval test's control title and header. ## Built-ins not judged whole, each with its reason - **`http`**: its executor parses after interpolating the whole config. So a value is judged only when nothing inside it carries a token (interpolation is then the identity). A rule is judged only when the whole config carries none. `signingSecret` is never judged. - **`loop`**: its executor parses only when there is a `body` (`parsedWhen`). A legacy flat-graph loop is judged for nothing. A loop with a body is judged on `collection`, `iteratorVariable`, `indexVariable` and `maxIterations`. - **The region containers**, `loop` (`body`), `parallel` (`branches`) and `try_catch` (`try`, `catch`): they hold regions, which are judged as graphs of their own. Each container is judged on the keys beside its regions, such as `try_catch`'s `errorVariable` and `retry`. `parallel` has only its region slot, so it is judged for presence alone. Every other builtin is judged on every present value: `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `screen`, `script`, `subflow` and `map`. ## Census (round 1, report `6006631317`) The census took every builtin node `config` at `833d57c9cf`. For each it listed what the planned arm refuses, using the arm as its predicate. A lit control (planted file) found all 6 planted refusals and exempted both planted tokens. - **This repository:** 96 real-writer nodes in examples, docs, skills and `packages/qa`. None is refused. 190 template strings are counted separately, all in string-typed slots. - **hotcrm** `4054ec2680`: 138 nodes, none refused. - **objectui** at the pin, static: 94 nodes, none refused outside tests. - **The one real writer:** objectui's designer stored a screen field's Min / Max as strings. objectui#11670 is its fix, hence the merge gate. After this change I re-ran the census with the implemented judge in place of the predicate. On every statically evaluable node it agrees with the predicate. The only differences are values the walker cannot evaluate. **Census, extended in patch round 1** to helper calls, same-file consts and property assignments. The walker now also reads three shapes: - a call `f(…, 'TYPE', …, { … })`, taking the first object after the type as the config; - an identifier that resolves to a same-file object literal; - an assignment into `….config` or `….config.KEY`. A lit control found a helper-call refusal, a const-resolved refusal and a rule-array assignment, and passed a token. - **This repository at `d1c7d8d392`:** 1065 configs (839 literal nodes, 224 helper calls, 2 JSON), plus 70 assignment sites. The judge refuses static values in 27 rows. Each is one of four things: - a pin of this change; - a door half added in this round; - a test that never reaches the judge (`lintFlowCredentialLiterals`, `validateFlowNodeWrites`, `lintFlowPatterns`, `resolveFlowNodeExpressions`), green; - one of the fixtures re-judged below. - **Assignments:** none writes a refused value into a builtin config, by hand-reading all 53 literal assignments. - **hotcrm `4054ec2680`:** 138 configs, 0 refused. - **What the walker still cannot evaluate:** - 64 configs that hold a value from an import, a function call, a spread of a non-local object or a template literal with substitutions; - configs built inside a function body (`configFor(type, …)`) or a loop over a sweep; - the node type of an assignment target, so assignments are triaged by hand; - JSON or YAML nodes without an `id` or `label`; - prose in docs. ## Reproduction, before and after (a scratch copy of `examples/app-showcase`, removed afterwards) | variant | `833d57c9cf` (base) | this branch | |:--|:--|:--| | control, unedited | validate 0 · compile 0 | validate 0 · compile 0 | | `create_task` `outputVariable: 42` | validate 0 (`✓ Validation passed`) · compile 0, artifact carries `42` | validate 1 · compile 2, `custom` at `nodes.1.config.outputVariable`, no artifact | | a screen field `min: '1'` | (engine: registers, run fails) | validate 1 · compile 2, `custom` at `nodes.1.config.fields.0.min` | | token control, `get_record` `limit: '{inquiry_cap}'` | — | validate 0 · compile 0, artifact carries the token | Every edit was proved on disk with `grep -c`, anchor 1→0 and injected 0→1. The validate door now reads: "This `create_record` node's config is refused at `outputVariable` by the create_record contract: Invalid input: expected string, received number. Its executor parses the config against that contract before it does anything else and refuses the node on any finding, …". At base, at the engine (built `service-automation`): `outputVariable: 42` registers, then runs 1 and 2 each fail with `create_record 'mk': config does not satisfy the create_record contract — config.outputVariable: …` and 0 inserts. The designer-shaped screen node registers, then its run fails at `config.fields[0].min`. ## Pins (`flow-builtin-node-config-values.test.ts`, 44 tests) - **The measured pair**, at `FlowSchema` (`custom` at `nodes.1.config.outputVariable` and at `nodes.1.config.fields.0.min`), with the judge's code, params and path. It is also refused inside a `loop` body, at `nodes.1.config.body.nodes.0.config.outputVariable`. - **One refusal per judged builtin at a typed key.** 24 probes cover every type with a value key: `limit`, `multi`, `severity`, a `notify` rule, `timeoutMs`, `durable`, `headers.X-Kind`, `mode`, `fields[0].required`, an empty `function` / `flowName`, `collection`, `maxIterations`, `errorVariable`, `retry.maxRetries` and others. - **Controls.** A valid node of each of the 13 types parses. Tokens in typed slots are never refused, in single-brace, double-brace and dollar-brace spellings, and for each one the contract itself is shown to refuse the string. A token-free sibling slot is still judged. Each carve-out above holds back while the contract refuses. A legacy loop is not judged. A missing key keeps its presence code. - **Doors.** `defineStack` (`STACK_SCHEMA_INVALID` / 422 at `flows.1.nodes.1.config.outputVariable`), `ObjectStackDefinitionSchema` (both pins), the registered `flow` type schema the save door uses (the screen pin) and the artifact parse. The CLI doors are in the table above. **Ablation (reverse verification).** I committed first, then used `scripts/ablation-replace.mjs` to restore the presence-only line (`if (!absent && !whole) continue;`): anchor 1→0, blob `01e47e2d7e35`→`2d67f51da4c4`. The subject resolves through `src` by relative import, so no build was needed. - Predicted: 40 red (36 in the new file, 1 in `flow-node-config-required.test.ts`, 3 in `flow-write-node-stored-metadata-target.test.ts`). - Observed: `Tests 40 failed | 129 passed (169)`, 36 / 1 / 3 as predicted. - Restored: blob equal to HEAD and `git diff HEAD` empty. A second ablation deleted the token exemption line. Predicted 1 red (the token control); observed `Tests 1 failed | 70 passed (71)`, then restored the same way. ## The ADR-0087 kit - **D3 entry:** `entries/semantic/18.flow-builtin-node-config-values-refused.ts`. Its `registry.ts` region was regenerated by `gen:migration-registry`. - **Step-18 rationale:** a fragment at **order 85**. I re-read `origin/main` at `230e4944b0` just before opening: the highest order there is 84, and this id is absent. - **No tombstone, no D2 conversion.** No key is removed, and the platform cannot know the value the author meant. - **Changeset:** one BREAKING `minor` for `@objectstack/spec`, with the `registered` marker and the `Clause-②` line. `check-adr-0087-registration` passes. - **Regeneration:** `check:generated` reports all 15 artifacts up to date. The public exports did not change. ## Fixtures re-judged in this PR - `spec/.../flow-node-config-required.test.ts`: a control pinned "a present wrong-typed value is not refused". That is exactly the branch removed, so the control is replaced. It now asserts the value arm's code, and that this rule still reports absence only. - `spec/.../flow-write-node-stored-metadata-target.test.ts`: the "dynamic objectName" control included an expression envelope in `objectName`. The CRUD contract declares `objectName` a string, so the run refused that envelope too. The template cases keep their control. The envelope now asserts exactly the value arm's refusal, and still none from the write-target arm. - `spec/.../flow-approval-node-config-contract.test.ts`: only the builtin control's title and header wording; the assertion is unchanged. - `lint/src/validate-expressions.test.ts:2360`, declared on objectstack-ai#6023 in comment `6011223490`: the screen `fields = 'nope'` fixture now expects exactly the screen contract's refusal at `config.fields`. No `packages/lint` source line moved. ## Cross-lane fixtures re-judged (patch round 1, declared on their lanes) Each of these fixtures registered or saved a flow carrying a value its contract refuses, so every one of those flows also failed at its first run. The round-1 census missed them because each config arrives through a helper argument or a property assignment. The step-7 suites caught them. - **`service-automation`, `config-parse.test.ts`** (the tests formerly at `:118`, `:129`, `:184`, `:193` and `:317`) **and `notify-node.test.ts`** (formerly `:274`). Each test keeps the subject its title names: the executor's execute-time parse. - Each drives that parse past the doors the way the suite already does for a key left out. It registers a value the contract accepts, then writes the refused value into the stored flow before the run. That is the new `runPatched` beside the existing `runStripped`; in `notify-node`, it is the stored-flow edit its "no recipient" test already uses. - Each also gains the door half: registration refuses the value at its key: refused at `limit`, `timeoutMs`, `mode`, `flowName` and `template`. - No test was converted to assert only the registration refusal. No `service-automation` source line moved. - **`metadata-protocol` `protocol-publish-drafts-advisories.test.ts:205`, and `objectql` `publish-meta-response-conformance.test.ts:413` and `save-meta-response-conformance.test.ts:298`.** The "clean" flow's `delete_record` `filter` moves from the rule array to the record form the contract declares: `{ created_at: { $lt: '2020-01-01' } }`. - The bounded-write advisory (`flow-multi-write-unfiltered`, `lint-flow-patterns.ts` `filterCarriesNoCondition`) stays silent. Measured: `reduceFilterVerdict` answers `'clause'` for the record form, so a condition is written. - The rule array was silent for a different reason: that rule skips a non-object `filter`, which the contract refuses outright. - Each test still asserts what its title says. - **Precondition, checked read-only first:** no real writer saves a CRUD `filter` as a rule array. - objectui at `0abd4f9f87`: the designer maps the slot (descriptor `type: 'object', additionalProperties: true`) to its `keyValue` widget, which commits a record. It keeps an array only when an array is already stored, and then in its `{ variable, value }` form; it never writes a rule array. - The round-1 corpora (examples, docs, skills, `packages/qa`) and hotcrm `4054ec2680`: 0 CRUD-node array filters, by an AST scan with a lit control. The 70 array filters found are page, view and API filters. ## Verification (at `22f54b0738`, after merging `origin/main` `c9761cd2fb`) **Tests** - `@objectstack/spec`: full suite 673 files / 19431 passed / 1 todo, exit 0. `typecheck` exit 0 (`check:test-typecheck` debt held). `check:generated`: all 15 up to date. - **Cross-lane:** - `@objectstack/service-automation`: 173 files / 2112 passed, `typecheck` 0. The ledger trio (`node-config-contract-ledger`, `config-expression-ledger`, `node-config-required-keys`) is green inside it. - `@objectstack/metadata-protocol`: 218 passed / 3 skipped files, 27996 passed / 19 skipped tests; `typecheck` 0. - `@objectstack/objectql`: 378 files / 7507 passed; `typecheck` 0. - `@objectstack/lint`: 119 files / 5629 passed; `typecheck` 0. - `@objectstack/cli`, `--project unit`: 259 files / 3786 passed. - A full turbo build ran first (`--filter='!@objectstack/docs'`, VERDICT 0), so every suite reads a current `dist/`. **Gates** - `dispatch-gates --ran` at `22f54b0738`: 96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN. - `check-engine-split-ratio --days 90` first refused on the shallow clone (exit 2). I deepened with `git fetch --shallow-since=2026-07-01`; it then exits 0, with the ratio at 98.4% and the oldest visible commit at 2026-07-01, before the window. **ESLint**, narrowed to this PR's own changed files, at `22f54b0738`: 1. Population: ESLint's own `isPathIgnored` reports all 14 changed `.ts` files as linted. 2. Count: `--format json` reports 14 files, 0 errors and 0 warnings. 3. Untouched files: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot change the result for any file it does not touch. **Declared to CI:** the full `pnpm lint`, the dogfood suite, the cli integration tier, and every package not named above. ## Acceptance notes (not filed) - **The schemaless KEY half.** A `script` (and by reading, `subflow`) node with an undeclared config key passes `FlowSchema` and `validateStackExpressions`, then registers, then fails every run. `registerFlow`'s key check skips schemaless types, and this arm judges no key membership. It belongs to this card's family, key half; it was measured at the functions the doors call, at `833d57c9cf`. Carrier: none. - **A token in a non-string slot stays run-only, on purpose.** A non-http builtin parses its raw config, so `get_record` `limit: '{n}'` or a screen field `min: '{m}'` is still refused at every run. The ⛔ above keeps it out of the build doors. Carrier: none. - **Two spec-side tables have no reconciler.** `PARSED_AFTER_INTERPOLATION` (`http`) and `RUN_RESOLVED_KEYS` (`http.signingSecret`) are new private tables in the judge. `service-automation`'s ledger reconciles the contract map against the executors' `parseNodeConfig` calls but reads neither table. A new after-interpolation executor or credential slot would have to be added here by hand. Carrier: none. - **CLI output (existing behaviour, unchanged).** When `defineFlow` throws while the CLI loads its config, the CLI prints the raw ZodError JSON, with the path relative to the flow and no flow name or file. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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