Repository navigation
Commit 111ffff
Part of #19939
Clause-②: yes (narrowing: a {…} token is refused at notify recipients
(the value or a list entry), templateData values, sourceId, actionUrl,
sourceObject, actorId and any string of a payload value, a templateData
value that names a dialect must be a valid CEL value envelope, and a CEL
value envelope written as a payload value is refused; widening:
recipients and sourceId accept a CEL value envelope they refused, a
recipients list accepts envelope entries, and their published JSON
Schema and inferred types gain the envelope arm)
> ~~Held out of the merge queue by #22949.~~ Released 2026-10-12: #22949
closed, and `main` was merged into this branch with the net diff
unchanged. The seat queues it once the new head is green and its
remaining pre-queue items are met.
Pass 4, stage S4 of #19939 (stage plan `6096263424`, claim
`6114980625`), under ruling D on #11182 and ADR-0032 Decisions 2 and 3.
A `notify` node's computed values become value slots: `recipients` (the
value, or each entry of a list), `sourceId` and the values of
`templateData` take a CEL value envelope the executor evaluates in the
run's scope, or a literal written as it is. Its link, `actionUrl`,
becomes a text slot with `{{ }}` holes, like `title`. Its
`sourceObject`, `actorId` and `payload` are literals, read as written
(round 2, below). A single-brace `{…}` token at any of them is refused.
The family finding is fixed and pinned: a list-valued recipient entry
adds each of its recipients, where the template handed it on as one
joined recipient. #19939 stays open for stages S5 to S7, so this PR uses
`Part of`.
## Round 2: the open question answered B (seat order `6117306001`)
The round-1 report asked whether `sourceObject`, `actorId` and
`payload`, which no flow computed (zero `{…}` tokens in this repository
and in hotcrm), should be value slots or literals. The seat answered B
in comment `6117306001`, by the dispatch's own Zone 2 rule ("Do not
build a slot nobody computes", with S7's connector inputs as the model).
This round's diff, `fc5982615c..c200819`:
- **The contract.** `sourceObject` and `actorId` are `z.string()` again
and `payload` is `z.record(z.string(), z.unknown())`: their 17.x
published shapes, with no envelope arm and no marker. The contract's
`superRefine` refuses a `{…}` token in `sourceObject`, in `actorId` and
in any string of a `payload` value at any depth, and an envelope written
as a `payload` value, with the literal's remedy, for example:
"`sourceObject` is the name of the object the notification links to, a
literal the executor reads as written: a single-brace token is not
resolved here; write the value." An envelope at `sourceObject` or
`actorId` is refused by the string type, with the same lead.
- **The build doors.** Their value arm (`builtinValueJudged`) leaves a
token-carrying value alone, because only the interpolation decides what
the run sees there. At a literal, the run reads what the build reads. So
the contract marks these issues `readAsWritten`
(`READ_AS_WRITTEN_PARAM`, in the package-internal
`flow-template-token.ts`, which adds no public export;
`check:api-surface` is unchanged), and the arm reports them.
`FlowSchema.parse`, `objectstack validate`, the save door and
`registerFlow` refuse them in the contract's words, as the executor's
parse does.
- **The ledger and the executor.** The ledger drops the three rows
`payload.*`, `sourceObject` and `actorId`. The executor reads the three
as written; `resolveStringSlot` keeps `sourceId` only. The descriptor's
help strings are restated.
- **The lint.** At a notify literal, the
`flow-double-brace-interpolation` and `flow-bare-dollar-ref` hints now
say that neither brace dialect resolves there, and they name the value
itself. Before, they prescribed `{var}`, which the build now refuses
there. The three keys are a local set in `lint-flow-patterns.ts`, pinned
in step with the contract both ways.
- **The text that moves with it.** The D3 entry's surface, replacement,
reason and acceptance criteria name the literals. The changeset's FROM /
TO and "Newly accepted" lines are rewritten, it gains a "Newly refused
at the literals" line, and its `Clause-②` line (read through
`clause2-line.mjs`) drops `sourceObject` and `actorId` from the widening
arm and adds the payload envelope to the narrowing arm. The generated
reference and the dropped-refinements ledger move with it:
`automation/NotifyConfig` loses `actorId` and `sourceObject`, 713 → 711.
- **`main`** was merged through `os-regen-merge.sh` (`fc5982615c`, from
base `85b4ac634e`). It reconciled nothing, and the reference was
regenerated after the change.
## What lands
**The contract (`@objectstack/spec`).**
- `NotifyConfigSchema.recipients` takes a string (one recipient as
written), a list whose entries are strings or CEL value envelopes, or
one envelope. It is a union, like `screen.recordId`'s, so the published
JSON Schema and inferred type keep the literal arms. A value that is
none of these is refused with a sentence naming what `recipients` is,
and the entry at fault.
- `sourceId` takes a string or an envelope (the same union shape).
`templateData` takes `FlowValueSlotSchema` per key, like a screen's
`defaults`.
- A `{…}` token in any of their strings is refused by the one value-slot
judge (`valueSlotTemplateRefusals`), naming its CEL spelling. A
malformed envelope leads with `VALUE_ENVELOPE_REFUSAL`.
- `sourceObject`, `actorId` and `payload` are literals (round 2): their
17.x types, and a token or an envelope there refused with the literal's
remedy.
- `actionUrl` stays `z.string()`, and the contract's `superRefine`
refuses a single-brace token through the one text-slot judge
(`textSlotTemplateRefusal`), as for `title` / `message`.
- Every `describe()` of these keys is restated.
**The ledger.**
- `FLOW_NODE_EXPRESSION_PATHS` appends three `notify` value rows:
`recipients`, `templateData.*`, `sourceId`.
- `recipients` carries a new optional field, `eachElement`: the slot
holds one value or a list of them, and both walks
(`resolveFlowNodeExpressions`, `resolveFlowNodeValueSlots`) emit each
element of a list at its own index (`recipients[1]`). An envelope entry
is validated and evaluated where it sits, and a token in a string entry
is refused with the envelope at its place, not as a string inside a
literal.
- `NotifyConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`: the
descriptor offers these keys with no marker (the Studio form keeps its
recipients picker and text inputs), so the markers ride the spec Zod
channel, one slot one channel.
- `FLOW_NODE_TEXT_SLOTS` gains `notify.actionUrl`, so `registerFlow` and
`objectstack validate` judge it and compile its holes, as for the other
text slots.
**The judges at the doors.**
- `flowNodeValueTemplateRefusals` reaches the three positions through
the ledger with no new judge. At `recipients` (the value or an entry) a
refused whole token's remedy ends with the recipient sentence: an
envelope that evaluates to `null` or a blank string adds no recipient,
as a token that resolved to nothing did, and one that evaluates to a
list adds each of its recipients.
- The build doors' value arm (`flowNodeConfigRefusals`) judges a list
slot per element: an issue at an envelope entry is the envelope pass's,
and a list whose every entry is a string or envelope-shaped is refused
there only for a token or a malformed envelope, which their own judges
report. A literal the contract refuses (`[42]`) stays this arm's.
**The executor (`@objectstack/service-automation`).**
- `notify` resolves `recipients` through `resolveRecipients`: each
envelope (the whole value or an entry) through the shared
`resolveValueSlot` in the run's scope; a list it evaluates to adds each
recipient; `null` and a blank string add none; anything else (a map, a
number, a list holding one) fails the node, naming what it evaluated to
and its source, never `String(value)`.
- A node left with no recipient fails, naming the value expressions that
evaluated to nothing (`framework#3582`'s guard, restated for CEL).
- `sourceId` resolves through `resolveValueSlot` and must evaluate to a
string; `null` leaves the key unset; a half-specified click-through pair
is still dropped. `sourceObject`, `actorId` and `payload` are read as
written.
- `templateData` resolves through `resolveValueSlotMap`, like every
value-slot map.
- `actionUrl` renders through `renderTextSlot`. A link that renders no
text is no link, so the synthesized one still applies downstream, as an
empty one did, and an explicit one still wins.
- `notify-node.ts` no longer imports `interpolate`. The descriptor's
descriptions are restated; its `type`s are unchanged (the form's
controls).
**The lint (`@objectstack/lint`, patch).** The
`flow-double-brace-interpolation` hints name `actionUrl` among the text
slots, and the dialect comment no longer lists `recipients` as
single-brace. `objectstack validate` refuses the positions through the
shared judges and the contract, with no rule change. At a notify
literal, both wrong-syntax hints name the value itself (round 2).
**ADR-0087.** The step-18 D3 entry
`18.flow-value-slot-template-dialect-refused` is amended the S1–S3 way:
its surface widens to the notify positions, and its replacement, reason
and acceptance criteria say what a recipient evaluates to, that a list
adds each recipient and `null` none, why the list token is not
rewritten, and that a literal takes the value itself. The two text-slot
entries, `18.flow-text-slot-single-brace-refused` and
`18.flow-text-slot-unbound-dollar-root-refused`, widen their surface to
`actionUrl`, and `STEP18_RATIONALE`'s text-slot fragment
(`registry.ts.template`) names the link. `registry.ts` is generated at
build and gitignored. The changeset marker is `not-required
(already-registered …)` naming the three ids;
`check-adr-0087-registration` accepts it.
## Premises measured at the base `9fdb12752c`
Measured through the shipped `AutomationEngine` (`installBuiltinNodes`,
`registerFlow`, `execute`, a recording messaging service) with a
throwaway probe on the base tree, deleted after the run.
- **Today's contract.** `NotifyConfigSchema`
(`io-node-config.zod.ts:274`–`:353`): `recipients` was
`z.union([z.string(), z.array(z.string())])`, required; `sourceObject`,
`sourceId`, `actorId`, `actionUrl` were `z.string()`; `templateData` and
`payload` were `z.record(z.string(), z.unknown())`. `title` / `message`
were the only text slots (`templateExpressionInput`, refused a single
brace since #22110).
- **The executor's readers** (`notify-node.ts`): `interpolate` ran on
`recipients` (`:269`, then `toStringList`), the `templateData` values
(`:291`), `actionUrl` (`:298`, then `String(…)`), the `payload` values
(`:301`), `sourceObject` / `sourceId` (`:128`–`:129`, then `toStr`) and
`actorId` (`:309`). `template` (`:289`), `channels` (`:293`), `topic`
(`:294`) and `severity` (`:295`) were read raw. The PM's line numbers
held.
- **How the doors reached them.** `flowNodeConfigRefusals` parses
`NotifyConfigSchema` at `FlowSchema.parse`, `objectstack validate`,
`compile`, the save door and `registerFlow` (the contract map entry,
`flow-node-config-refusals.ts:125`). Its value arm skips any value
carrying a `{token}`, so a token at these keys passed every door; an
envelope at the four string-typed keys was refused (measured:
`registerFlow` refused `recipients: { dialect: 'cel', … }` and
`sourceId: { dialect: 'cel', … }`). No ledger row or judge read these
keys.
- **`toStringList` (`:70`)**, measured:
| `recipients` | delivered audience |
|:--|:--|
| `'u1'` | `['u1']` |
| `'u1,u2'` | `['u1,u2']`: a string is one recipient, never split |
| `['u1', 'u2']` | `['u1', 'u2']` |
| `['{owners}']`, `owners = ['u1', 'u2']` | **`['u1,u2']`, the run
succeeds**: the family finding holds |
| `'{owners}'`, the same list | `['u1', 'u2']` |
| `['{obj}']`, `obj` a record | `['[object Object]']`, the run succeeds
|
| `['{missing}', 'u1']` | `['u1']`: the unresolved token is dropped |
| `['{missing}']` | `NODE_FAILURE`, "every recipient template resolved
to nothing: {missing}", with the `expand` hint |
- **The other readers**, measured: `actionUrl: '{obj}'` (a map) sent the
link `[object Object]`; `'/x/{when}'` with a `Date` sent
`/x/"2026-10-08T09:30:00.000Z"` (JSON-quoted); `'/x/{{ id }}'` became
`/x/{42}`; `'{missing}'` sent `url: ''`, and the synthesized link
applied. `sourceId: '{obj}'` sent the id `[object Object]`, and
`'{missing}'` dropped the pair. `templateData: { a: '{missing}' }`
dropped the key, and `templateData: { a: { dialect: 'cel', … } }` handed
the template the envelope object.
- **CEL, the same inputs** (`evaluateValueEnvelope`): an absent variable
throws `Unknown variable: owner`; `record.owner.manager` on a scalar
`owner` throws `No such key: manager`; its step-by-step `has(vars.…)`
guard answers `null`; `'team:' + owner` with `owner` `null` throws `no
such overload`.
- **The D2 question: D3 only.** No spelling maps losslessly. `{x}` to
`x` at a recipient: an absent `x` was dropped by the template and fails
the run under CEL; a list token written as a list entry reached nobody
and now reaches each recipient; a record token became `[object Object]`
and now fails the node. At `sourceId` an absent value dropped the link
and fails the run under CEL. At a `templateData` / `payload` key an
absent value dropped the key, and the guarded form writes `null`. At
`actionUrl`, `{x}` to `{{ x }}` renders a `Date` and a whole-token
object differently (the #22110 measurement, repeated here). So the three
step-18 D3 entries are amended and no conversion is registered.
- **`#22875`** (the `template` describe) landed during this stage;
`main` was merged through `os-regen-merge.sh` and the one generated path
it rewrote was regenerated (below). `template` is untouched here.
## The classification
One row per key the executor read through `interpolate`, with the token
census in shipped metadata and hotcrm (the reach below).
| key | S4 | census of `{…}` tokens | why |
|:--|:--|:--|:--|
| `recipients` | value slot, one value or a list; a list-valued entry is
**flattened** | showcase 16, todo 2, docs 6, dogfood 1; hotcrm 19 nodes;
the objectui picker writes `{record.FIELD}` | the highest-pull computed
value |
| `templateData.*` | value slot per key | docs 1; hotcrm 35 values in 19
maps | a template's per-run data |
| `sourceId` | value slot (a string) | showcase 7, todo 2, dogfood 2 |
the record the link opens |
| `sourceObject` | **literal** (round 2), read as written | 0 (every
site writes a literal object name) | no flow computes it |
| `actorId` | **literal** (round 2), read as written | 0 | no flow
computes it |
| `payload.*` | **literal** (round 2), read as written | 0 | no flow
computes it |
| `actionUrl` | text slot, `{{ }}` | showcase 4 links with holes; hotcrm
19 | the plan's carry line: a link is text |
**Flattened, not refused.** An entry that evaluates to a list adds each
of its recipients. The list is what the author meant (a multi-user
field, a team's members, a list variable), the whole-value form already
meant a list, and the designer's one-row-per-field picker has no other
way to address a multi-user field; refusing it would force every such
flow into one hand-written CEL list concatenation. Nested lists, maps
and numbers are refused at the node, so a flattened entry can never be a
phantom recipient. Pinned (`notify-value-slots.test.ts` 3).
**The three zero-census keys are literals** (round 2). Round 1 built
them as value slots and reported the choice as an open question. The
seat answered B (`6117306001`) by the dispatch's own rule: no slot is
built that nobody computes. Each of them resolved a token in 17.x, so a
`{…}` there is refused with the literal's remedy, not forwarded
verbatim.
## The reach
Re-derived at HEAD with a census of every token-bearing string at the
seven positions, in `examples/**`, `packages/platform-objects`,
`packages/qa/dogfood` and `content/docs/**` (references and releases
excluded). Positive control: the base read 41 token strings in 7 files
(`recipients` 25, `sourceId` 10, `actionUrl` 4, `templateData` 1,
counted by hand for the one map the scan's object regex cuts), and HEAD
reads 0.
- **Migrated in this PR:**
- `examples/app-showcase` (`automation/flows/index.ts`): 16 recipient
strings, 7 `sourceId`s and 4 links. A recipient field that may be unset
(`assignee`, `owner`, `watcher`, all optional text fields) is guarded,
`has(record.assignee) ? record.assignee : null`, so an unset one is
dropped as the template dropped it and the node fails as before;
`ownerId` (a subflow input) and the ids are plain. The urgent-task
alert's two entries are guarded each, and its comment now says what it
does: both are notified, and the node fails only when both are absent.
The seed's comment and the stale `{record.project.owner}` /
`{record.account.owner}` comments follow.
- `examples/app-todo` (`flows/task.flow.ts`): 2 recipients (guarded) and
2 `sourceId`s.
- Docs: `automation/flows.mdx` (the value-slot list, a new paragraph on
recipients, the text-slot list, the dialect table, three samples),
`automation/email-templates.mdx` (the sample and the `templateData`
sentence), `getting-started/common-patterns.mdx` (two samples). The
service-automation README's dialect table.
- Dogfood: `seed-ownership-claim-dispatch.dogfood.test.ts` and
`fixtures/schedule-organization-fixture.ts`.
- `packages/platform-objects` writes no `notify` node.
- **Test fixtures:** the notify tests in spec, lint and
service-automation, the showcase's
`approval-resume-relation-expand.test.ts` (its rolled-back shape is the
CEL hop now; see Acceptance notes) and the translation-face and
text-slot-list pins.
- **hotcrm** (read-only clone at `1d7148bf2d`, deleted after): 19 notify
nodes in 15 flow files, each with one `{…}` recipient in a list (2 of
them `{$User.Id}`), 35 `{…}` `templateData` values in 19 maps, and 19
links with holes (`/crm_lead/{record.id}`); no `sourceObject`,
`sourceId`, `actorId` or `payload`. It is on `@objectstack/spec` 17.7.0,
so nothing there moves until it upgrades.
- **objectui at the pin `4997995022`** (read-only), the designer's
notify pickers, re-read at the pin (the stage-0 lines `:777`, `:856`,
`:1103` are the S1/S2 `inputs`, `defaults` and `input` rows there now):
- `flow-node-config.ts:1118`: `recipients` renders
`FlowRecipientsField`, which writes a record-field row as
`{record.FIELD}` (`FlowRecipientsField.tsx:106`) and reads a stored
value with `readRecipients` (`:115`–`:118`), which keeps strings only —
so a stored envelope entry, or a whole envelope, is dropped from the
rows and lost on the next save.
- `:1132` `sourceObject`, `:1133` `sourceId`, `:1139` `actionUrl`:
`text` fields, whose default `refMode` is `template`, so the `{x}`
picker inserts a single-brace token (`VariableTextInput.tsx`,
`formatToken`). `:1120` `message`'s placeholder still reads "Supports
{var} template references".
- The descriptor publishes `recipients` untyped and the other keys with
their `type`s, unchanged here, so `mergeServerFlowFields` keeps the
hand-written picker and the pinned console still builds. No non-test
objectui source imports `NotifyConfig`.
- Carried to the objectui companion (objectstack-ai/objectui#12101) by
the seat.
## Pins, and the ablations that turn them red
- `notify-value-slots.test.ts` (service-automation, 32 engine pins):
each position refused at `registerFlow`, located and naming the
envelope, and by the executor's contract parse as a guard; the recipient
sentence; `actionUrl` refused with its hole spelling at both doors; each
envelope evaluated, each literal handed on, a recipient string not
split; the family finding (an entry evaluating to a list adds each
recipient; never `'u1,u2'`); an absent variable fails with
`EXECUTION_ERROR` naming `recipients[1]`; a guarded `null` is dropped; a
node left with none fails naming the expressions; a map, and a list
holding a number, fail the node naming them; `sourceId` `null` drops the
pair and a map fails; `actionUrl` renders a hole, a map as JSON, and a
link rendering nothing yields to the synthesized one.
- `notify-node.test.ts` re-judged in place: the interpolation fixtures
move to envelopes and holes; the `framework#3582` trio now pins the
guarded hop's `null` dropped, the node failing with the expression
named, and the unguarded hop on a scalar id failing with `No such key:
manager`.
- Spec: contract pins (`io-node-config.test.ts`: every position takes an
envelope and a literal and refuses a token at its own path and a
malformed envelope; a list mixes strings and envelopes and names the
entry at fault; the build doors' value arm judges a list per element;
the JSON projection carries the markers and keeps the literal arms; the
describes); ledger pins (`flow-node-expression-paths.test.ts`: the six
rows, `eachElement` on `recipients` only, element-by-element emission;
the census row list); judge pins (`flow-value-slot-template.test.ts`:
located per position, the recipient sentence and where it is absent);
the text-slot list (`flow-text-slot-template.test.ts`); the translation
face classifies `notify.actionUrl` (`flows-translation-face.test.ts`).
- The ratchet (`config-expression-ledger.test.ts`): notify declares its
three slots on the spec Zod channel alone; `notify.recipients` joins the
channel-required non-predicate slots refused absent.
- Lint: `validate-expressions.text-slot.test.ts` (envelopes pass, a
token at the link is refused at `error`); `lint-flow-patterns.test.ts`
(the double-brace control moves from `actionUrl`, a text slot now, to
`sourceId`, a value slot).
- **Round 2, the literals.** The engine (`notify-value-slots.test.ts`,
section 6, which replaces those keys' rows in sections 1 and 2): a
literal is handed on as written, nested `payload` data included; a `{…}`
token and an envelope are refused at `registerFlow` (located at the key,
in the contract's words, with the literal remedy) and by the executor's
contract parse as a guard. The contract (`io-node-config.test.ts`): a
literal parses; a token is refused at its own path with the literal
remedy, never the value slots' envelope; an envelope is refused; a token
at any depth of a `payload` value is located at that string, and a
deeper envelope is data; the build doors (`flowNodeConfigRefusals`,
`FlowSchema.safeParse`) refuse a token and an envelope in the contract's
words; the published JSON Schema keeps the 17.x shapes, with no marker
and no envelope arm. `objectstack validate`
(`validate-expressions.test.ts`) refuses both at `config.KEY` with the
literal remedy, and a literal is clean. The lint hints
(`lint-flow-patterns.test.ts`): the contract's literal keys, asked of
the contract, are exactly the keys the hints read as literals, and a
control key keeps its hint. The ledger, judge and ratchet pins count
three notify slots.
- **Ablations**, at `384da59997`, each through
`scripts/ablation-replace.mjs` in WRAP mode under an outer `trap … EXIT
INT TERM`: the anchor hit once, the blob changed, and the restore was
proven with the blob equal to HEAD and `git diff HEAD` empty (the outer
check found nothing to restore).
1. The executor's `recipients` reading restored to the 17.x
`toStringList(interpolate(…))`: 8 of the 32 engine pins went red — every
evaluation pin for `recipients`. The refusal pins stayed green, as they
must: the contract parse refuses a token before the executor reads
anything, so restoring the interpolator cannot reach them. (The dispatch
predicted the refusal pins; the measured direction is the evaluation
pins.)
2. The ledger's `eachElement` deleted: 5 spec pins went red in 3 files
(the element-wise emission, the located recipient refusal and its
sentence, and the build doors' per-element value arm).
3. The contract's token refusal for a list entry removed: 1 contract pin
went red.
- Not ablated at dist level: the service-automation and lint pins read
`@objectstack/spec`'s `dist`; ablations 2 and 3 turned the same
functions red at source.
- **Round-2 ablations**, at `c200819d1e`, the same way (anchor hit once,
blob changed, restore proven blob-equal to `HEAD`, `git diff HEAD`
empty):
4. The value arm's read-as-written line deleted
(`flow-node-config-refusals.ts`): 3 of 73 pins in
`io-node-config.test.ts` went red, exactly the three build-door pins
(`sourceObject`, `actorId`, `payload`); the contract's own refusal pins
stayed green.
5. The contract's literal refusal removed (its `addIssue` replaced by
`void refusal`): 8 of 73 went red: the three token refusals, the payload
envelope, the payload depth case and the three build-door pins. The
`sourceObject` / `actorId` envelope pins stayed green, as they must: the
string type refuses those, not the refinement.
## Measurements
**Round 2, at `c200819d1e`** (the head; each suite took the verify lock
once, on a shared box, so no wall-clock figure is an idle-box reading):
| run | result |
|:--|:--|
| the dependency closure build of service-automation and lint, spec
included (`turbo run build --filter='…^...'`) | 29 tasks, exit 0 (after
the dropped-refinements line moved: the first build refused, naming
`automation/NotifyConfig` without `actorId` and `sourceObject`) |
| `@objectstack/service-automation`: the `notify-*` set (all six files)
plus `config-expression-ledger` and `config-parse` | 8 files, 122 passed
|
| `@objectstack/lint`: `lint-flow-patterns`, `validate-expressions`,
`validate-expressions.text-slot` | 3 files, 640 passed |
| `@objectstack/spec` `vitest run --project local` (full) | 648 files,
19437 passed, 1 todo |
| `@objectstack/service-automation` `vitest run` (full) | 188 files,
2459 passed |
| `@objectstack/lint` `vitest run` (full) | 137 files, 6537 passed |
| `@objectstack/spec` / `service-automation` / `lint` `typecheck` | exit
0 each; the test layers OK against their debt ledgers (spec 52 / 246 /
135 held, service-automation 0, lint 2 / 6 / 2 held) |
| `check:generated`, `check:api-surface` ("public API surface + factory
signatures unchanged"), `check:authorable-surface`,
`check-adr-0087-registration.mjs --base origin/main` (`not-required
(already-registered)`) | exit 0 each |
- **The gate union** at `c200819d1e`: `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` with no paths derived 116 commands, and 115
were run. 113 exited 0 in the battery. `check:skill-examples` and
`check:i18n-coverage` refused with exit 3 (PREREQUISITE NOT MET:
`client-react` and the CLI and example closures were unbuilt in the
re-created worktree). After the one closure build each names, both
exited 0, and so did a re-run of `check:dts-closure`. Reconciled with
`--ran`: "116 derived, 115 run, 0 NOT-MEASURED, 1 UNRUN"; the unrun one
is `pnpm check:dual-build-cjs-loads`, **NOT MEASURED** by the dispatch's
rule.
- **⛔ crossed, reported:** the battery ran the derived `pnpm
check:type-check-debt` (exit 0, "1 ledger entr(ies) re-measured … none
above its recorded number"). That npm script is `--re-measure`, which
the dispatch rules out, and it first runs the whole-workspace build
`turbo run build --filter=./packages/* --filter=./packages/*/*`, outside
the verify lock. That build rewrote `dist/` while my prerequisite build
ran, so its first `client-react` declaration build failed (TS7016) and a
`check:dts-closure` taken then named `verify`. Both were re-run green
once it ended. Round 1's batteries ran the same script. The derivation
tree was 8 commits behind `origin/main` (`51b43a8dc4`); 8 gate-input
files changed there (workflows and ADR anchors), none of them in this
diff.
- **ESLint, narrowed and proven**: `eslint --no-inline-config --format
json` over the 42 changed `.ts` files at `c200819d1e`: exit 0, 42 files
in the JSON report, 0 errors, 0 warnings, none reported ignored. The
config enables no type-aware linting (every `parserOptions` is `{
ecmaVersion: 'latest', sourceType: 'module' }`, no `project` /
`projectService`; `eslint.config.mjs:327`–`:328`), so this diff cannot
move a verdict on an untouched file. The whole-tree `pnpm lint` is CI's.
- **The three PR-context guards** are run wired to this PR after this
body is written; their readings are in the round-2 `os-dev-report`
comment on #19939.
- **Not re-run in round 2**: the showcase, todo and dogfood suites and
the CLI integration test (round 1, below). This round changes no site:
the census found no token at the three keys, so no shipped flow writes
one. `origin/main` moved again after the merge (to `51b43a8dc4`, 8
commits, none touching a file in this diff).
- **Not measured locally**: `dual-build-cjs-loads` (the dispatch rules
out the whole-workspace build), and the CI-owned whole-tree lint.
**Round 1** (the heads named; the round-1 diff, before the literals):
| run | at | result |
|:--|:--|:--|
| `@objectstack/spec` build (the dropped-refinements ledger and the
generated references included) | `384da59997` | exit 0 |
| `@objectstack/spec` `vitest run --project local` (full) | `4c8cf1558f`
| 648 files, 19435 passed, 1 todo |
| `@objectstack/spec` `vitest run --project repo` (`test:repo`) |
`4c8cf1558f` | 55 files, 971 passed |
| `@objectstack/spec` `typecheck` (tsc, scripts, test layer) |
`4c8cf1558f` | exit 0; the test layer OK against its debt ledger (52
files / 246 errors / 135 signatures held) |
| `@objectstack/service-automation` `vitest run` (full, against the
rebuilt spec) | `384da59997` | 188 files, 2455 passed |
| `@objectstack/service-automation` `typecheck` | `4c8cf1558f` | exit 0;
test layer 0 errors |
| `@objectstack/lint` `vitest run` (full) | `384da59997` | 137 files,
6508 passed |
| `@objectstack/lint` `typecheck` | `4c8cf1558f` | exit 0; the test
layer OK against its debt ledger (2 files / 6 errors / 2 signatures
held) |
| CLI closure build, then `vitest run --project integration
test/migrate-meta-engine-guidance.test.ts` | `384da59997` | 3 passed |
| `@objectstack/example-showcase` `vitest run` (full) | `d8485a8a50` |
33 files, 409 passed |
| `@objectstack/example-todo` `vitest run` (full) | `4c8cf1558f` | 7
files, 238 passed |
| `@objectstack/dogfood` (closure build, then the three files this diff
feeds: `seed-ownership-claim-dispatch`, `schedule-acting-organization`,
`schedule-sweep-organization-scope`) | `4c8cf1558f` | 3 files, 28 passed
|
- **The first full showcase run went red, and is fixed here.** At
`4c8cf1558f` one test failed: `inert-wirings.test.ts`, "derives
recipient fields from the flows (guard is not vacuous)". The #7746 guard
derived recipient fields with a `{record.FIELD}` regex, so the migrated
envelopes left it blind. It now reads the trigger record's direct fields
from the envelope sources; at HEAD it derives `showcase_task.assignee`,
`showcase_invoice.owner` and `showcase_project.owner` (the base's
fields: `{record.assignee}` and `{record.owner}` under the same three
start objects), where the old regex derives none. The rolled-back hop's
pin in `approval-resume-relation-expand.test.ts` was also re-judged (it
asserted the node's id, which a thrown evaluation fault does not carry).
- **The gate union**, `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` with no paths, at `d8485a8a50`: 116
commands derived, 115 run, every one exit 0. Reconciled with `--ran`:
"116 derived, 115 run, 0 NOT-MEASURED, 1 UNRUN"; the one unrun is the
next sentence's. `pnpm check:dual-build-cjs-loads` is **NOT MEASURED**:
the dispatch rules out the whole-workspace build it needs.
**Correction:** round 1 said here that `check:type-check-debt` ran
without `--re-measure`. That was wrong. The derived command `pnpm
check:type-check-debt` IS `check-type-check-coverage.mjs --re-measure`
(root `package.json:195`), which also runs the whole-workspace closure
build first. So both ⛔s were crossed, in every battery of both rounds,
unlocked. See the round-2 measurements.
- **The artifact rosters** the no-path run prints (50): 47 run at
`d8485a8a50`, every one exit 0 (14 of them are checker-health
`--self-test` runs, not PR verdicts), including the five whose roster
sits in a directory this diff touches (`check-changeset-fixed`,
`check:meta-url-spelling`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`). The three
PR-context guards (`check-closing-target-claim`,
`check-partof-closing-keyword`, `check-single-claim-paths`) are run
wired to this PR once it is open; their readings are in the
`os-dev-report` comment on #19939.
- **The consumer gates the dispatch names**, inside the union:
`check-adr-0087-registration.mjs --base origin/main` (exit 0, "1
declared-breaking changeset(s), each carrying an ADR-0087 disposition",
`not-required (already-registered)`), `check:generated` (exit 0),
`check:nul-bytes` (exit 0), `check:doc-authoring` (exit 0).
`check:migration-registry` no longer exists (removed by #22554; see the
notes).
- **ESLint, narrowed and proven**: `eslint --no-inline-config --format
json` over the 40 changed `.ts` files at `d8485a8a50`: exit 0, 40 files
in the JSON report, 0 errors, 0 warnings, none reported ignored. The
population is the changed source files the config's `files` globs match.
The narrowing excludes nothing that could move: `eslint.config.mjs`
enables no type-aware linting (every `parserOptions` is `{ ecmaVersion:
'latest', sourceType: 'module' }`, no `project` / `projectService`, no
typed rules; the config says so at `:327`–`:328`), so this diff cannot
move a verdict on an untouched file. The whole-tree `pnpm lint` is CI's.
- **The tree the union was derived from** is 5 commits behind
`origin/main` (`85b4ac634e`) at the time of the run; none of them
touches a file in this diff, and the one gate file among them,
`scripts/check-verify-stand-in-erasure.mjs`, adds two allowlist entries
for exports #22907 added to `packages/verify`, which this diff does not
touch.
- **Not measured locally**: `dual-build-cjs-loads` (above), and the
CI-owned whole-tree lint. Turbo's `--affected` set was not used to
narrow anything here.
## Out of the claim's file surface, and why
The claim's file surface (`6114980625`) was held, with these files
beside it; each one is named here with why.
- `spec/automation/flow-node-config-refusals.ts` (18 lines): the build
doors' value arm judges a list slot per element when the ledger row
carries `eachElement`. Without it, the arm reads a list as one value
that is not an envelope, so a list holding a malformed envelope entry or
a token is refused a second time there, as the contract's union refusing
the whole list, beside the envelope pass's or the judge's located
refusal (pinned in `io-node-config.test.ts`, "the build doors").
- `spec/automation/schemaless-node-config.zod.ts` (11 lines):
`LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` gains `notify`, the channel that
carries its `xExpression` markers to the reconciliation ratchet, since
the descriptor carries none (its form keeps the recipients picker).
- `spec/automation/flow-text-slot-template.ts`: the claim scoped it to a
notify remedy sentence; the change is one row in `FLOW_NODE_TEXT_SLOTS`
(`notify.actionUrl`), the list that makes a key a text slot at every
door, and its docblocks.
- The two text-slot D3 entries,
`18.flow-text-slot-single-brace-refused.ts` and
`18.flow-text-slot-unbound-dollar-root-refused.ts`: the claim names only
the value-slot entry; their surface lists the text slots, and
`actionUrl` joins it (the plan's "each stage amends the D3 entries").
- `spec/dropped-refinements.baseline.json`: the spec build refused until
the `automation/NotifyConfig` row named its five new projection-dropped
refinements (`actorId`, `recipients`, `sourceId`, `sourceObject`,
`templateData.valueType`; header 708 to 713).
- Comments and docs only: `spec/automation/builtin-node-config.zod.ts`
(two docblocks listing the value-slot positions), service-automation
`engine.ts` (the value-slot docblock), `builtin/template.ts` (a comment
that listed `recipients` as a single-brace position) and `README.md`
(the dialect table).
- Spec test fixtures outside the notify tests that wrote a notify token:
`api/zod-issues-to-fields.test.ts`,
`flow-builtin-node-config-keys.test.ts`,
`flow-node-config-required.test.ts`,
`flow-region-pause-and-end.test.ts`; and the two list pins,
`builtin-node-config.test.ts` (the ledger-declared keys) and
`system/flows-translation-face.test.ts` (`notify.actionUrl` classified
as a text slot with no reader).
- Docs beyond `flows.mdx`, as the census found them:
`automation/email-templates.mdx` and
`getting-started/common-patterns.mdx`; the generated
`references/automation/io-node-config.mdx`.
- Round 2: `spec/automation/flow-template-token.ts` (the
package-internal home of `READ_AS_WRITTEN_PARAM`, which is not a public
export, beside the token grammar the notify contract now reads),
`flow-node-config-refusals.ts` again (the value arm reads the marker),
`lint/src/lint-flow-patterns.ts` (the literal hints) and
`lint/src/validate-expressions.test.ts` (the `objectstack validate`
pins).
- Not touched: `template`, `channels`, `topic`, `severity`; S5 to S7;
`skills/**`; objectui; hotcrm.
## Acceptance notes
- **The open question is answered B** (seat order `6117306001`, in the
non-escalated class, with the maintainer's veto window open):
`sourceObject`, `actorId` and `payload` are literals, and the round
above implements it.
- **The relation-hop hint.** Under the template a hop on a scalar id
(`record.account.owner` without the start node's `expand`) resolved to
nothing, and the empty-recipients guard named it with the `expand`
remedy. As a CEL value the hop fails the evaluation itself:
`recipients[0]: value expression failed to evaluate as CEL: No such key:
owner`, with its source. Neither that message nor the restated
empty-recipients guard (which now names the expressions that evaluated
to nothing, and says `null`, a blank string and an empty list add none)
carries the base's `expand` remedy: the fault names the key and the
source, and the hint to hydrate the relation is gone, as it is at every
value slot since S1, where the fault is the engine's one message.
Restoring it is a polish of that shared message, noted here, not filed.
The showcase's `approval-resume-relation-expand.test.ts` pins the
rolled-back shape on the new message. An evaluation fault is thrown, as
at every value slot since S1, so the run's message does not carry the
node's id (the step and `$error.nodeId` do); the returned
empty-recipients failure does, as before.
- **The designer, until the objectui companion lands**
(objectstack-ai/objectui#12101, carried by the seat): the recipients
picker writes `{record.FIELD}`, which `registerFlow`, the save door and
`objectstack validate` now refuse, naming its envelope; and
`readRecipients` keeps strings only, so a stored envelope entry is
dropped from the rows and lost on the next save. The `sourceObject`,
`sourceId` and `actionUrl` text inputs insert single-brace tokens from
the `{x}` picker.
- **The skill text, until #22585 lands** (carried by the seat), measured
on `main` at `eab7236ff7`: `skills/objectstack-automation/SKILL.md:107`
writes `recipients: '{record.assignee}'` and `:114` `sourceId:
'{record.id}'`, both refused after this lands; `:115`'s link is a text
slot now; `:240` lists `recipients` among the single-brace slots.
`:107`'s comment also says a recipient may be a CSV, and at the base a
CSV string is one recipient: `'u1,u2'` was delivered as the one audience
entry `'u1,u2'`, which the messaging service's recipient resolver reads
as a bare user id (`recipient-resolver.ts:143`).
- **The ablation's direction** differs from the dispatch's prediction,
as measured above: restoring the interpolator reddens the evaluation
pins; the refusal pins are the contract's and cannot be reached from the
executor.
- **The lint's three literal keys** are a local set in
`lint-flow-patterns.ts`, because the spec publishes no list of them and
a new export would widen the API surface for a hint. A pin asks the
contract which keys refuse a token with the literal lead, and holds the
set to exactly those.
- **The designer's `sourceObject` text input** (objectui pin `:1132`)
inserts a single-brace token from its `{x}` picker. That token is
refused at save now, with the literal remedy. This is carried to
objectstack-ai/objectui#12101 with the other pickers.
- **The shared resolver's literal arm.** `resolveValueSlot` hands a
non-envelope value to `interpolate` (S1's shape); a literal reaching it
carries no token, since the contract parse refused one, so it comes back
as written. `notify-node.ts` itself no longer imports `interpolate`.
- **`check:migration-registry`**, named in the dispatch's consumer list,
was removed by #22554 (the registry is generated at build):
`gen:migration-registry` ran inside each spec build, test and typecheck
above, and `src/migrations` is green.
- **Base**: round 1 merged `main` at `19d5a3e6d9`, and round 2 at
`fc5982615c` (base `85b4ac634e`); `main` has moved since (to
`51b43a8dc4`), touching no file in this diff.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 4e6b270 commit 111ffff
50 files changed
Lines changed: 1985 additions & 350 deletions
File tree
- .changeset
- content/docs
- automation
- getting-started
- references/automation
- examples
- app-showcase
- src
- automation/flows
- data/seed
- test
- app-todo/src/flows
- packages
- lint/src
- qa/dogfood/test
- fixtures
- services/service-automation
- src
- builtin
- spec
- src
- api
- automation
- migrations
- entries/semantic
- system
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
160 | 160 | | |
161 | 161 | | |
162 | 162 | | |
163 | | - | |
| 163 | + | |
164 | 164 | | |
165 | | - | |
| 165 | + | |
166 | 166 | | |
167 | 167 | | |
168 | 168 | | |
| |||
184 | 184 | | |
185 | 185 | | |
186 | 186 | | |
187 | | - | |
188 | | - | |
189 | | - | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
190 | 193 | | |
191 | 194 | | |
192 | 195 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | | - | |
237 | | - | |
238 | | - | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
239 | 242 | | |
240 | 243 | | |
241 | 244 | | |
| |||
332 | 335 | | |
333 | 336 | | |
334 | 337 | | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
335 | 358 | | |
336 | 359 | | |
337 | 360 | | |
338 | | - | |
| 361 | + | |
| 362 | + | |
339 | 363 | | |
340 | 364 | | |
341 | 365 | | |
| |||
355 | 379 | | |
356 | 380 | | |
357 | 381 | | |
358 | | - | |
359 | | - | |
360 | | - | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
361 | 386 | | |
362 | 387 | | |
363 | 388 | | |
364 | 389 | | |
365 | 390 | | |
366 | 391 | | |
367 | 392 | | |
368 | | - | |
| 393 | + | |
369 | 394 | | |
370 | 395 | | |
| 396 | + | |
371 | 397 | | |
372 | 398 | | |
373 | 399 | | |
| |||
843 | 869 | | |
844 | 870 | | |
845 | 871 | | |
846 | | - | |
| 872 | + | |
847 | 873 | | |
848 | 874 | | |
849 | 875 | | |
| |||
2064 | 2090 | | |
2065 | 2091 | | |
2066 | 2092 | | |
2067 | | - | |
2068 | | - | |
2069 | | - | |
2070 | | - | |
2071 | | - | |
| 2093 | + | |
| 2094 | + | |
| 2095 | + | |
| 2096 | + | |
| 2097 | + | |
2072 | 2098 | | |
2073 | 2099 | | |
2074 | 2100 | | |
| |||
2077 | 2103 | | |
2078 | 2104 | | |
2079 | 2105 | | |
2080 | | - | |
| 2106 | + | |
2081 | 2107 | | |
2082 | 2108 | | |
2083 | 2109 | | |
| |||
2359 | 2385 | | |
2360 | 2386 | | |
2361 | 2387 | | |
2362 | | - | |
| 2388 | + | |
2363 | 2389 | | |
2364 | 2390 | | |
2365 | 2391 | | |
| |||
0 commit comments