Repository navigation
fix(spec): a notify title / message refusal prescribes the single-brace {record.name} its renderer reads - #22124
Conversation
… spelling its renderer reads
The shared template input's refusals prescribe '{{record.name}}', which the
notify executor's flow interpolator leaves inside a stray pair of braces and
the build's flow-double-brace-interpolation rule flags. The notify slots are
now built with templateExpressionInput and refuse with sentences prescribing
'{record.name}', kept in one constant; every other template slot keeps its
sentence. The tmpl and TemplateExpressionInputSchema docblocks say which
renderers read which braces.
Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…int round trip and the shared control Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…n reference page Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check16 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
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 5f5bc5041aab2a1fa257c1f39f1d958b6a33bca0 && git checkout 5f5bc5041aab2a1fa257c1f39f1d958b6a33bca0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 54ace18c669a776c7e849708039c7876ac534cee 681d77223e45405c820fbbf022b865986db8abc1 && git checkout -B drift-repro 54ace18c669a776c7e849708039c7876ac534cee && git merge --no-ff 681d77223e45405c820fbbf022b865986db8abc1
node scripts/docs-audit/affected-docs.mjs --json 54ace18c669a776c7e849708039c7876ac534cee |
…tify-refusal-brace-prescription
The constructor the notify title/message share with TemplateExpressionInputSchema moves out of expression.zod.ts (which shared/index.ts re-exports) into shared/typed-expression-input.ts, which no barrel re-exports, so the public face does not widen. ExpressionSchema is passed in rather than imported, which keeps the module free of a runtime cycle. The changeset drops its export bullet. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…n reference page api-surface/ and export-origins/ return to the merge base byte for byte; the reference page no longer names the internal constructor. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read, and nothing else: card #22081 (body and all 8 comments: triage grade ① Derived judgmentsAccept set (every input that parsed before parses after; every input refused before is refused after):
Public face (judged on the
Docs and instruments the diff implies:
② Semver level
③ Boundary flags
Check-runs on the head as read in this act: 46 runs — 39 Implemented-by: VERDICT: PASS |
Fixes #22081
Clause-②: no
A notify flow node's
title/messagerefusal now prescribes'{record.name}', the single-brace spelling the notify executor reads. It used to answer with the shared template sentence, which prescribes'{{record.name}}'. The build'sflow-double-brace-interpolationrule then flagged that spelling on the same node, and the notify renderer sent it inside a stray pair of braces. Every other template slot keeps its sentence. Triage grade6039475060, as amended by6042027032and6042955917. Rework round 1 (6046879490) applied:@objectstack/spec's public face does not move.What changed
title/messageare declared inNotifyConfigSchema(packages/spec/src/automation/io-node-config.zod.ts). That declaration can carry its own sentences, so the ruling's both-conventions fallback is not needed. The union's message comes from the schema built at the slot. The build's flow judge (flowNodeConfigRefusals, whichFlowSchema,registerFlowandos validateshare) quotes that same message.packages/spec/src/shared/typed-expression-input.ts.shared/index.tsand the root barrel do not re-export it;refinement-projection.tsis the precedent. It holdscronExpressionInput(ExpressionSchema, refusals)andtemplateExpressionInput(ExpressionSchema, refusals)over one private union builder. The accept set is fixed by the dialect, and the refusal sentences are an argument.CronExpressionInputSchemaandTemplateExpressionInputSchemaare built from it with their shared sentences.TYPED_EXPRESSION_SOURCE_REQUIREDandTYPED_EXPRESSION_DIALECT_ONLYare byte-unchanged. The notify slots are built from it with their own sentences.expression.zod.tsimports the module at runtime, so the module must not importexpression.zod.tsback. It importsExpressionSchemaandTypedExpressionDialectas TYPES only (import type, erased), and the caller passesExpressionSchemain. Each dialect's envelope arm is still spelled once, inside the module.typedExpressionInput(expression, dialect, …)was measured first. Itsexpression.safeExtend({ dialect: z.literal(dialect) })fails type-checking, because aZodLiteralover a generic dialectDis not provably assignable to theExpressionDialectkey it narrows (TS2322). So each constructor narrows with a concrete literal.CronExpressionInputSchema,TemplateExpressionInputSchemaand their input types are byte-identical at the merge base and at HEAD (26 lines each,tscexit 0 both).expression.zod.d.tsexports the same 31 names, and neither constructor appears in it.formatZodIssueand the API error mapper expand aninvalid_union's branches beneath its own line. A branch that still named the shared sentence would print'{{record.name}}'under the right prescription. The leg-2 ablation below measures this.notifyTemplateRefusals(key),io-node-config.zod.ts) name the key and prescribe'{record.name}'or{ dialect: 'template', source: '{record.name}' }. They also say why: the notify executor interpolates single-brace{token}placeholders, and a doubled brace keeps its outer braces. They are spelled with no doubled brace at all.tmpldocblock no longer says "Mustache-template" or shows only{{record.x}}. It now has one bullet per spelling, naming the renderer behind each:{{record.x}}for the formula template engine and the messaging, email and i18n renderers;{record.x}for a notify node'stitle/message, rendered by the flow interpolator; either fortitleFormat. TheTemplateExpressionInputSchemadocblock gains the notify bullet. Its closing advice changes from "write{{var}}unless the renderer normalizes" to "write the spelling the slot's renderer reads".content/docs/references/shared/expression.mdx. The module header now says a typed slot may carry its own refusal sentences, and it does not name the constructor.packages/spec/api-surface/**andpackages/spec/export-origins/**are byte-identical to the merge base:git diff --stat 54ace18c6 HEAD -- packages/spec/api-surface packages/spec/export-originsprints nothing.The one place the notify prescription lives
NOTIFY_TEMPLATE_PLACEHOLDER = '{record.name}'inpackages/spec/src/automation/io-node-config.zod.ts. Both notify refusal sentences read it, and so does the existing blank-envelopesourcerefusal (notifyTemplateSourceRequired, byte-identical output). When #22110 flips the notify convention on the v18 line, this constant is the prescription that flips with it. The slot.describe()prose explains the renderer and is rewritten by that card in any case. Per amendment6042955917, this card does not wait on #22110.Measurements
Before (
a543e244f, read frompackages/spec/srcwithtsx; reproduces the filer's readings ond4680d2820):NotifyConfigSchematitle=' '/'':invalid_union,TYPED_EXPRESSION_SOURCE_REQUIRED.template, endingWrite '{{record.name}}' or { dialect: 'template', source: '{{record.name}}' }.title=42or acelenvelope:invalid_union,TYPED_EXPRESSION_DIALECT_ONLY.template, same ending.messagegives the same.flowNodeConfigRefusals('notify', …)quotes it (node-config-refused-by-contract), and a doubled brace appears in every refusal tree.lintFlowPatterns: both shared prescriptions draw oneflow-double-brace-interpolationeach on a notifytitle. This is now the control leg of the lint pin below, and it is green on this branch.After (HEAD
681d77223): the same eight notify cases answerinvalid_unionwith the notify sentence.'{record.name}'is prescribed, and no doubled brace appears in the union message, in any branch issue, or in the flow judge's message. The shared schema's two sentences are unchanged. The probe output is byte-identical to round 0's, so moving the constructor changed no refusal.Pins
packages/spec/src/automation/io-node-config.test.ts: refusalcodeplus prescribed spelling plus named key, for blank values and for non-template values on both keys; no doubled brace anywhere in the issue tree; the flow judge quotes the prescription; control: the shared sentences still prescribe'{{record.name}}'.packages/spec/src/shared/typed-expression-envelope-dialect.test.ts:templateExpressionInput, imported from the internal module, accepts exactly whatTemplateExpressionInputSchemaaccepts and parses to the same value; it refuses with the given sentence by kind, and those sentences are the only refusal text; control:PromptTemplate.user, which keeps the shared input, still refuses with the'{{record.name}}'sentences. The existingobjects.0.titleFormatpin stays. Keeping the constructor off the public face is held bycheck:api-surface, which fails on any added export.packages/spec/src/shared/expression-dialect-docs.pin.test.ts: thetmpldocblock has a{{record.x}}bullet naming messaging and email (not notify), and a{record.x}bullet naming notify andflow-double-brace-interpolation(not messaging or email). The pin checks which renderer goes with which spelling, not the wording.packages/lint/src/lint-flow-patterns.test.ts: lint round trip. Each spelling the notify refusal prescribes is read out of the refusal text, not re-typed in the test. Each one parses throughNotifyConfigSchemaandFlowSchemaand draws noflow-double-brace-interpolation. Control: the shared sentence's two prescriptions each draw the finding.packages/qa/dogfood/test/expression-conformance.test.ts:cronExpressionInputandtemplateExpressionInputjoin the census roster. Without them,NotifyConfigSchema.title/.messagewould drop out of discovery andtemplate-notify-contentwould go STALE. Both positions stayhead-discovered. The constructors' own definitions live in a plain.tsmodule that the.zod.tsscan never reads.Ablation (one-time, at
681d77223;scripts/ablation-replace.mjswrap mode under a restore trap; spec tests resolvesrcthrough relative imports, so no rebuild was needed):NOTIFY_TEMPLATE_PLACEHOLDERset back to'{{record.name}}'(anchor 1 → 0, blob9f53bb6cfb5c→05ffd58adbd3).io-node-config.test.ts: 3 failed / 33 passed. Restored: blob == HEAD,git diff HEADempty.refusals.sourceRequired(blob6222e3e7f5be→0b6ae37053ed). 2 failed / 73 passed, both "no doubled brace in the tree" pins. Restored: blob == HEAD,git diff HEADempty. The trap re-verified both blobs.@objectstack/spec. Its control leg shows the rule flags the shared prescriptions on the same flow shape.Local verification (HEAD
681d77223)Each run went through the shared verify lock; the verdict line is quoted.
pnpm --filter '@objectstack/lint...' build: dependency closure plus lint,VERDICT command-exit 0.pnpm --filter @objectstack/spec test: 622 files, 18587 passed, 1 todo.pnpm --filter @objectstack/spec typecheck: exit 0. The test layer holds its debt ledger: 52 files, 246 errors, 135 pinned signatures, no new ones.pnpm --filter @objectstack/lint test: 123 files, 5680 passed. The round-trip pin alone (-t "notify slot refusal"): 3 passed.pnpm --filter @objectstack/lint typecheck: exit 0. Test layer: 2 files, 6 errors held.pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/expression-conformance.test.ts: 7 passed. The first attempt answeredFailed to resolve entry for package "@objectstack/verify", an unbuilt prerequisite, so it was NOT MEASURED. It was re-run after the gate pass had built the workspace.pnpm --filter @objectstack/spec check:generated:All 15 generated artifacts are up to dateaftergen:api-surface,gen:export-originsandgen:docs. Those three were the only artifacts it proved stale. The first two now regenerate to the merge base's bytes.eslint --no-inline-config --format jsonover the 8 touched TS files: 8 files, 0 errors, 0 warnings, exit 0. Every file resolves a config (--print-config).eslint.config.mjssets noparserOptions.project/projectService, so the linting is not type-aware and a verdict on an untouched file cannot move with this diff. This is a narrowing; the repo-widepnpm lintis CI's.Session:
session_01RPo7FUd6bSnAfkWMAKi848(PM dispatch, claim6044705682).Gates:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 109 commands at681d77223, run one at a time with each exit code captured before any pipe and reconciled with--ran: 109 of 109 exit 0. Three of them first answered exit 3, PREREQUISITE NOT MET, because their packages were unbuilt:check:skill-examples,check:dual-build-cjs-loadsandcheck:lean-entry-closure. They were re-run oncecheck:type-check-debt's re-measure had built the workspace, and each exited 0.--ranwith the final coded record:109 derived famil(ies) accounted for — 109 run, 0 NOT-MEASURED (a DERIVED zero — all 109 recorded an exit code and none of them is 3).Not measured locally (declared to CI):
@objectstack/service-automation. Its notify tests assertrefused at \title`and the executor's contract wording, never the template sentence. Also the whole-workspace type-check lanes, the path-scheduled CI jobs, and the repo-widepnpm lint`.origin/main(54ace18c6) is merged in with a merge commit viascripts/pm/os-regen-merge.sh. Main moved onemerge=os-regenpath,scripts/platform-object-tenancy-census.json, which this branch never edited. The merge brought no other overlap with this diff.Acceptance notes
EmailTemplateDefinition.subject/bodyHtmlarez.string(), and messaging declares none. The control is taken at the two slot families that keep the shared input,PromptTemplate.system/user(described as{{var}}) andObject.titleFormat(renderers take either).packages/lint/scripts/check-doc-formula-expressions.mjs,EXPRESSION_SLOT_TYPEStripwire, re-read after the move: still dormant. The tripwire fires only on a property assignment that carries@exampleJSDoc tags and whose initializer names one of the four public schema names.NotifyConfigSchemacarries zero@exampletags, and itstitle/messageinitializers (templateExpressionInput(ExpressionSchema, …)) name none of the four. So it is blind to those two slots as before the move, and nothing is there for it to see. Noted, not filed.service-automation/src/builtin/notify-node.tshas a comment that still calls the two slotsTemplateExpressionInputSchema. The input is identical; only the constructor's sentences differ. Comment only, outsidedomain:spec, noted.