Repository navigation
Commit 15ec50e
fix(spec): a notify title / message refusal prescribes the single-brace
Fixes #22081
Clause-②: no
A notify flow node's `title` / `message` refusal 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's `flow-double-brace-interpolation` rule
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 grade `6039475060`, as amended by `6042027032` and
`6042955917`. Rework round 1 (`6046879490`) applied:
`@objectstack/spec`'s public face does not move.
## What changed
- **Per-slot prescription, not a both-conventions sentence.** The
refusing site can know the slot. The notify `title` / `message` are
declared in `NotifyConfigSchema`
(`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`, which
`FlowSchema`, `registerFlow` and `os validate` share) quotes that same
message.
- **A package-internal constructor**,
`packages/spec/src/shared/typed-expression-input.ts`. `shared/index.ts`
and the root barrel do not re-export it; `refinement-projection.ts` is
the precedent. It holds `cronExpressionInput(ExpressionSchema,
refusals)` and `templateExpressionInput(ExpressionSchema, refusals)`
over one private union builder. The accept set is fixed by the dialect,
and the refusal sentences are an argument. `CronExpressionInputSchema`
and `TemplateExpressionInputSchema` are built from it with their shared
sentences. `TYPED_EXPRESSION_SOURCE_REQUIRED` and
`TYPED_EXPRESSION_DIALECT_ONLY` are byte-unchanged. The notify slots are
built from it with their own sentences.
- **Cycle avoidance.** `expression.zod.ts` imports the module at
runtime, so the module must not import `expression.zod.ts` back. It
imports `ExpressionSchema` and `TypedExpressionDialect` as TYPES only
(`import type`, erased), and the caller passes `ExpressionSchema` in.
Each dialect's envelope arm is still spelled once, inside the module.
- **Why two constructors, not one generic.** A single generic
`typedExpressionInput(expression, dialect, …)` was measured first. Its
`expression.safeExtend({ dialect: z.literal(dialect) })` fails
type-checking, because a `ZodLiteral` over a generic dialect `D` is not
provably assignable to the `ExpressionDialect` key it narrows (TS2322).
So each constructor narrows with a concrete literal.
- **Public face measured unchanged.** The declarations tsc emits for
`CronExpressionInputSchema`, `TemplateExpressionInputSchema` and their
input types are byte-identical at the merge base and at HEAD (26 lines
each, `tsc` exit 0 both). `expression.zod.d.ts` exports the same 31
names, and neither constructor appears in it.
- **The string arm carries the slot's sentence too, not only the
union.** `formatZodIssue` and the API error mapper expand an
`invalid_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.
- **The notify sentences** (`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.
- **Docblocks.** The `tmpl` docblock 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's `title` / `message`, rendered by the flow interpolator; either
for `titleFormat`. The `TemplateExpressionInputSchema` docblock gains
the notify bullet. Its closing advice changes from "write `{{var}}`
unless the renderer normalizes" to "write the spelling the slot's
renderer reads".
- **Regenerated:** `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/**` and
`packages/spec/export-origins/**` are byte-identical to the merge base:
`git diff --stat 54ace18 HEAD -- packages/spec/api-surface
packages/spec/export-origins` prints nothing.
## The one place the notify prescription lives
`NOTIFY_TEMPLATE_PLACEHOLDER = '{record.name}'` in
`packages/spec/src/automation/io-node-config.zod.ts`. Both notify
refusal sentences read it, and so does the existing blank-envelope
`source` refusal (`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 amendment `6042955917`, this card does not wait on #22110.
## Measurements
Before (`a543e244f`, read from `packages/spec/src` with `tsx`;
reproduces the filer's readings on `d4680d2820`):
- `NotifyConfigSchema` `title` = `' '` / `''`: `invalid_union`,
`TYPED_EXPRESSION_SOURCE_REQUIRED.template`, ending `Write
'{{record.name}}' or { dialect: 'template', source: '{{record.name}}'
}`. `title` = `42` or a `cel` envelope: `invalid_union`,
`TYPED_EXPRESSION_DIALECT_ONLY.template`, same ending. `message` gives
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 one
`flow-double-brace-interpolation` each on a notify `title`. 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 answer
`invalid_union` with 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`: refusal `code`
plus 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 what `TemplateExpressionInputSchema` accepts 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 existing `objects.0.titleFormat` pin stays. Keeping the
constructor off the public face is held by `check:api-surface`, which
fails on any added export.
- `packages/spec/src/shared/expression-dialect-docs.pin.test.ts`: the
`tmpl` docblock has a `{{record.x}}` bullet naming messaging and email
(not notify), and a `{record.x}` bullet naming notify and
`flow-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 through `NotifyConfigSchema`
and `FlowSchema` and draws no `flow-double-brace-interpolation`.
Control: the shared sentence's two prescriptions each draw the finding.
- `packages/qa/dogfood/test/expression-conformance.test.ts`:
`cronExpressionInput` and `templateExpressionInput` join the census
roster. Without them, `NotifyConfigSchema.title` / `.message` would drop
out of discovery and `template-notify-content` would go STALE. Both
positions stay `head`-discovered. The constructors' own definitions live
in a plain `.ts` module that the `.zod.ts` scan never reads.
Ablation (one-time, at `681d77223`; `scripts/ablation-replace.mjs` wrap
mode under a restore trap; spec tests resolve `src` through relative
imports, so no rebuild was needed):
- Leg 1: `NOTIFY_TEMPLATE_PLACEHOLDER` set back to `'{{record.name}}'`
(anchor 1 → 0, blob `9f53bb6cfb5c` → `05ffd58adbd3`).
`io-node-config.test.ts`: 3 failed / 33 passed. Restored: blob == HEAD,
`git diff HEAD` empty.
- Leg 2: the internal module's string arm carries a doubled-brace
sentence instead of `refusals.sourceRequired` (blob `6222e3e7f5be` →
`0b6ae37053ed`). 2 failed / 73 passed, both "no doubled brace in the
tree" pins. Restored: blob == HEAD, `git diff HEAD` empty. The trap
re-verified both blobs.
- The lint round trip was not ablated by rebuilding `@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
answered `Failed 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 date` after `gen:api-surface`, `gen:export-origins`
and `gen: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 json` over the 8 touched TS files:
8 files, 0 errors, 0 warnings, exit 0. Every file resolves a config
(`--print-config`). `eslint.config.mjs` sets no `parserOptions.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-wide `pnpm lint` is CI's.
Session: `session_01RPo7FUd6bSnAfkWMAKi848` (PM dispatch, claim
`6044705682`).
Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 109 commands at `681d77223`, 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-loads` and
`check:lean-entry-closure`. They were re-run once
`check:type-check-debt`'s re-measure had built the workspace, and each
exited 0. `--ran` with 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 assert `refused 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-wide `pnpm lint`.
`origin/main` (`54ace18c6`) is merged in with a merge commit via
`scripts/pm/os-regen-merge.sh`. Main moved one `merge=os-regen` path,
`scripts/platform-object-tenancy-census.json`, which this branch never
edited. The merge brought no other overlap with this diff.
## Acceptance notes
- Triage's control pin names "an email or messaging template slot". No
spec slot of either kind is typed with the template input:
`EmailTemplateDefinition.subject` / `bodyHtml` are `z.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}}`) and `Object.titleFormat` (renderers take either).
- `packages/lint/scripts/check-doc-formula-expressions.mjs`,
`EXPRESSION_SLOT_TYPES` tripwire, re-read after the move: still dormant.
The tripwire fires only on a property assignment that carries `@example`
JSDoc tags and whose initializer names one of the four public schema
names. `NotifyConfigSchema` carries zero `@example` tags, and its
`title` / `message` initializers
(`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.ts` has a comment that
still calls the two slots `TemplateExpressionInputSchema`. The input is
identical; only the constructor's sentences differ. Comment only,
outside `domain:spec`, noted.
---------
Co-authored-by: Claude <noreply@anthropic.com>{record.name} its renderer reads (#22124)1 parent cdeabec commit 15ec50e
10 files changed
Lines changed: 563 additions & 87 deletions
File tree
- .changeset
- packages
- lint/src
- qa/dogfood/test
- spec/src
- automation
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
36 | | - | |
37 | | - | |
38 | | - | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
39 | 42 | | |
40 | 43 | | |
41 | 44 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
| 5 | + | |
5 | 6 | | |
6 | 7 | | |
7 | 8 | | |
| |||
2417 | 2418 | | |
2418 | 2419 | | |
2419 | 2420 | | |
| 2421 | + | |
| 2422 | + | |
| 2423 | + | |
| 2424 | + | |
| 2425 | + | |
| 2426 | + | |
| 2427 | + | |
| 2428 | + | |
| 2429 | + | |
| 2430 | + | |
| 2431 | + | |
| 2432 | + | |
| 2433 | + | |
| 2434 | + | |
| 2435 | + | |
| 2436 | + | |
| 2437 | + | |
| 2438 | + | |
| 2439 | + | |
| 2440 | + | |
| 2441 | + | |
| 2442 | + | |
| 2443 | + | |
| 2444 | + | |
| 2445 | + | |
| 2446 | + | |
| 2447 | + | |
| 2448 | + | |
| 2449 | + | |
| 2450 | + | |
| 2451 | + | |
| 2452 | + | |
| 2453 | + | |
| 2454 | + | |
| 2455 | + | |
| 2456 | + | |
| 2457 | + | |
| 2458 | + | |
| 2459 | + | |
| 2460 | + | |
| 2461 | + | |
| 2462 | + | |
| 2463 | + | |
| 2464 | + | |
| 2465 | + | |
| 2466 | + | |
| 2467 | + | |
| 2468 | + | |
| 2469 | + | |
| 2470 | + | |
| 2471 | + | |
| 2472 | + | |
| 2473 | + | |
| 2474 | + | |
| 2475 | + | |
| 2476 | + | |
| 2477 | + | |
| 2478 | + | |
| 2479 | + | |
| 2480 | + | |
| 2481 | + | |
| 2482 | + | |
| 2483 | + | |
| 2484 | + | |
| 2485 | + | |
| 2486 | + | |
| 2487 | + | |
| 2488 | + | |
2420 | 2489 | | |
2421 | 2490 | | |
2422 | 2491 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
105 | 105 | | |
106 | 106 | | |
107 | 107 | | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
108 | 120 | | |
109 | 121 | | |
110 | 122 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| 21 | + | |
21 | 22 | | |
22 | 23 | | |
23 | 24 | | |
| |||
408 | 409 | | |
409 | 410 | | |
410 | 411 | | |
411 | | - | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
412 | 432 | | |
413 | | - | |
414 | | - | |
415 | | - | |
416 | | - | |
417 | | - | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
418 | 464 | | |
419 | 465 | | |
420 | 466 | | |
421 | 467 | | |
422 | | - | |
| 468 | + | |
423 | 469 | | |
424 | | - | |
425 | | - | |
426 | | - | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
427 | 476 | | |
428 | 477 | | |
429 | 478 | | |
430 | 479 | | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
431 | 494 | | |
432 | 495 | | |
433 | 496 | | |
| |||
0 commit comments