Skip to content

feat(spec,service-automation,lint)!: a notify node's computed values are value slots and its link a text slot — a CEL envelope or a literal, the {…} token refused (#19939 pass 4, S4) - #22926

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-19939-p4-s4-notify
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-19939-p4-s4-notify

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

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..c200819d1e:

  • 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 types 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 [v18] flow text slots: read ADR-0032 §3's {{ }} delimiter instead of single-brace {token} (notify title/message and the other flow string slots), converting only what renders the same #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 [v18] flow text slots: read ADR-0032 §3's {{ }} delimiter instead of single-brace {token} (notify title/message and the other flow string slots), converting only what renders the same #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 sourceIds 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 sourceIds.
    • 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 types, 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 (v18: Studio's subflow input and script inputs fields author a CEL value envelope, not a {var} token (the objectui half of objectstack-ai/objectstack#19939 pass 4, S1) 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 [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #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 finding: showcase seed assigns tasks to non-user emails — a stock reassignment lands an inbox row nobody can read #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 [v18] retire the {var} template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #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 [Decision] the migration registry registry.ts is the last committed generated aggregate on the ADR-0087 D3 path: keep it and finish B′, or generate it at build #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 feat(verify): the reference-value shape family, runnable as os verify --reference-shapes (#15953 slice 1) #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 (v18: Studio's subflow input and script inputs fields author a CEL value envelope, not a {var} token (the objectui half of objectstack-ai/objectstack#19939 pass 4, S1) 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 skills(objectstack-automation): SKILL.md still teaches {$User.<path>} as a kept value-slot spelling and the text-slot run-user remedy through it, which #19939 pass 2 (PR #22563) refuses #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 v18: Studio's subflow input and script inputs fields author a CEL value envelope, not a {var} token (the objectui half of objectstack-ai/objectstack#19939 pass 4, S1) 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 [Decision] the migration registry registry.ts is the last committed generated aggregate on the ADR-0087 D3 path: keep it and finish B′, or generate it at build #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

claude added 10 commits October 11, 2026 23:40
…ledger (#19939 pass 4, S4)

Part of the S4 stage; work in progress.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
Work in progress.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
…s 4, S4)

Work in progress.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
… text slot (#19939 pass 4, S4)

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
…9939 pass 4, S4)

The os-regen driver kept one side of the generated reference in the merge
of origin/main 7de0265; gen:schema then gen:docs re-derive it, carrying
both this branch's notify value slots and #22875's template describe.

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
… CEL fault (#19939 pass 4, S4)

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
…fy recipient holds (#19939 pass 4, S4)

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/service-automation, @objectstack/spec, touching 61 documentable anchor(s). ⚠️ 7 changed file(s) yielded no anchor (packages/lint/src/validate-flow-template-paths.ts, packages/services/service-automation/README.md, packages/services/service-automation/src/builtin/template.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6a40a09ed952f83b1f765d47cd76aee4a41f6672.

⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 7 changed file(s) yielded no anchor (packages/lint/src/validate-flow-template-paths.ts, packages/services/service-automation/README.md, packages/services/service-automation/src/builtin/template.ts, …) — pages documenting those are invisible to this run
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6a40a09ed952f83b1f765d47cd76aee4a41f6672 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b520c42a2725bdc721d081a4b986aac6bf3273a9 — the merge of head ef02901d26f2d65d1ce553e99c9cfa42cd9ef871 into base 6a40a09ed952f83b1f765d47cd76aee4a41f6672, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b520c42a2725bdc721d081a4b986aac6bf3273a9 && git checkout b520c42a2725bdc721d081a4b986aac6bf3273a9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6a40a09ed952f83b1f765d47cd76aee4a41f6672 ef02901d26f2d65d1ce553e99c9cfa42cd9ef871 && git checkout -B drift-repro 6a40a09ed952f83b1f765d47cd76aee4a41f6672 && git merge --no-ff ef02901d26f2d65d1ce553e99c9cfa42cd9ef871

node scripts/docs-audit/affected-docs.mjs --json 6a40a09ed952f83b1f765d47cd76aee4a41f6672

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6a40a09ed952f83b1f765d47cd76aee4a41f6672 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… payload are literals, read as written (#19939 pass 4, S4 round 2)

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c200819d1eca2b4cd7df7dcb13f978793da8495a
Local-runs: none

PR #22926 (Part of #19939, pass 4 stage S4; draft; head repo = base repo; 50 files, +1985 / −350). Read for this record: card #19939's body and all 56 comments — the ruling 6063191653, the stage plan 6096263424, the S4 claim 6114980625, the round-1 report 6117280980, the seat order 6117306001, the round-2 report 6118066430, the ACCEPT 6118088551, the release 6118101711 and the take-over claim 6118509947 among them — the PR body, its file list, the net diff against main, the check-runs on this head, and base files on origin/main. Nothing was built, run or re-run.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged:

  1. recipients — right. Base z.union([z.string(), z.array(z.string())]) becomes a string, a list whose entries are strings or CEL value envelopes, or one envelope (NotifyRecipientsSlotSchema, module-local, not exported), with a superRefine that refuses a {…} token in any string through the one value-slot judge (valueSlotTemplateRefusals). Widening: the envelope arms, in the published JSON Schema (anyOf string / array / object, pinned) and the inferred NotifyConfig type. Narrowing: the token. This is ADR-0032 Decision 2 applied to the highest-pull computed value (census 25 tokens) in S2 and S3's ruled shape. The family finding is fixed by flattening, which the stage plan allowed ("flattened or refused, and pinned"): an entry that evaluates to a list adds each recipient (section 3 of notify-value-slots.test.ts); a map, a number or a nested list fails the node naming it, never String(value); null and a blank string add none, which keeps the 17.x drop for a token that resolved to nothing, and a node left with none fails naming the sources.
  2. sourceId — right. z.string() becomes string or envelope (NotifySourceIdSlotSchema, module-local), token refused; the executor requires a string result, null leaves the click-through pair unset, anything else fails the node naming what it evaluated to. Widening (the envelope arm, JSON Schema and type) plus narrowing (the token). Census 10.
  3. templateData — right. z.record(z.string(), z.unknown()) becomes z.record(z.string(), FlowValueSlotSchema): narrowing only — a {…} token in any string at any depth is refused, and an object naming a dialect must be a valid CEL envelope. No accept-set widening (an envelope-shaped object was already accepted, as data; it is evaluated now, which the changeset's "Newly accepted" says). The JSON Schema's additionalProperties gains the xExpression: 'value' marker (pinned).
  4. actionUrl — right. Stays z.string(); the contract's superRefine refuses a single-brace token through the one text-slot judge, FLOW_NODE_TEXT_SLOTS gains notify.actionUrl (so registerFlow, objectstack validate and the executor's renderTextSlot all read it), and the two D3 text-slot entries widen their surface to it. Narrowing only. The stage plan's carry line made the link a text slot; a link that renders no text yields to the synthesized one as an empty one did, and an explicit one still wins (pinned).
  5. sourceObject, actorId — right. Types unchanged (z.string()); a {…} token is refused by the contract with the literal remedy and the readAsWritten param; an envelope was already refused by the type and now gets the literal's sentence. Narrowing only; no envelope arm, no marker (pinned in the JSON projection). This is the seat order's B (6117306001) under the dispatch's Zone 2 item 2, on a zero census here and in hotcrm — and refusing the token rather than forwarding it (the report's option C) is Prime Directive 12's reject-at-authoring, so a picker-written {record.x} can never reach the messaging service with its braces.
  6. payload — right. Type unchanged (z.record(z.string(), z.unknown())); a token in any string at any depth of a value is refused at that string, an envelope written as a value is refused, a deeper envelope stays data. Narrowing only. (The map itself being envelope-shaped is a literal map, read as written — unchanged from 17.x, where nothing evaluated the map either; consistent with "only a slot's top-level value is an expression".)
  7. The build doors' value arm (flow-node-config-refusals.ts) — right. (a) eachElement: an issue at a list index is skipped where that element is an envelope (the envelope pass's) or carries a token (the judge's, through the existing token skip), and reported where it is anything else (42, null, a nested list); an issue at the list itself is skipped only when every element is a string or envelope-shaped, so the union's whole refusal is never reported a second time beside its own judge. Pinned ("the build doors") and ablated (eachElement deleted: 5 red). (b) The READ_AS_WRITTEN_PARAM short-circuit sits after the region, ledger and run-resolved skips (RUN_RESOLVED_KEYS holds http.signingSecret only) and before the token skip, so the literal refusals reach FlowSchema.parse, objectstack validate, the save door, registerFlow and the executor's parse — pinned at FlowSchema.safeParse and flowNodeConfigRefusals, ablated (3 red). READ_AS_WRITTEN_PARAM lives in flow-template-token.ts, which automation/index.ts does not re-export: no new public export, as claimed.
  8. The ledger and the channel — right, with one public type widening to name. Three notify rows are appended at the end of FLOW_NODE_EXPRESSION_PATHS (every older row byte-identical, pinned); recipients carries the new optional eachElement field on the exported FlowNodeExpressionPath interface (additive, published through automation/index.ts); LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains notify (its keyof widens); the descriptor keeps recipients untyped and the other keys unmarked, so one slot has one channel (the ratchet pin) and the Studio form keeps its picker. These additive widenings are covered by the Clause-②: yes and the major; the arm's prose names only the accept-set widenings — a completeness remark, not a defect.
  9. The executor — right. notify-node.ts no longer imports interpolate; recipients resolves through resolveRecipients over the shared resolveValueSlot, templateData through resolveValueSlotMap, sourceId through resolveStringSlot, actionUrl through renderTextSlot, and the literals are read raw. No ?? fallback tolerates an off-spec shape (Prime Directive 12); a token never reaches the executor because the contract parse refuses it first (ablation 1's measured direction). The empty-recipients guard is restated for CEL, naming the sources that evaluated to nothing.
  10. The lint — right, patch. The flow-double-brace-interpolation hints name actionUrl among the text slots; at a notify literal both wrong-syntax hints name the value itself instead of prescribing {var}, which the build now refuses there; NOTIFY_LITERAL_KEYS is a local set held equal to the contract by a pin that asks the contract which keys refuse with the literal lead (an export for a hint would widen the spec API). The double-brace control moved from actionUrl (a text slot now) to sourceId.
  11. ADR-0087 — right. D3 only: the value-slot entry flow-value-slot-template-dialect-refused is amended the S1–S3 way (surface, replacement, reason and acceptance criteria say what a recipient evaluates to, why the list token is not rewritten, and that a literal takes the value itself); the two text-slot entries widen their surface to actionUrl; the STEP18_RATIONALE fragment names the link. No D2: no spelling maps losslessly (an absent variable dropped a recipient and now faults; a list entry reached nobody and now reaches each; a Date in a link renders differently; a record at a recipient became [object Object] and now fails the node). No key is removed, so no tombstone.
  12. Sites and generated artifacts — right. The 41 token strings at the seven positions in the showcase, todo, three docs pages, the README and two dogfood fixtures read 0 at the head; optional recipient fields are guarded (has(record.assignee) ? record.assignee : null) and {$User.Id} is current_user != null ? current_user.id : null (ruling 6063191653, Q1 A). The showcase inert-wirings guard now derives the same three fields from envelope sources (not vacuous). io-node-config.mdx is regenerated; dropped-refinements.baseline.json moves 708 → 711 with the three new projection-dropped sites. The three private packages touched (example-showcase, example-todo, dogfood) publish nothing.

② Semver level

  • Changeset .changeset/19939-flow-notify-value-slots.md: @objectstack/spec major, @objectstack/service-automation major, @objectstack/lint patch; .changeset/pre.json is mode: pre, tag next, so the release is 18.0.0-next.*. Right: spec narrows published accept sets on an authoring surface (BREAKING) and widens two; service-automation stops interpolating six positions and evaluates three (a stored flow carrying a token is skipped at boot with a warn); lint changes finding text only, no API. No skip-changeset. The FROM → TO table and the one-line fix are present; the title carries !.
  • Clause-②: yes (narrowing: …; widening: …) — the value yes (the envelope arms are newly accepted at recipients and sourceId), the arm narrowing (the token and envelope refusals): S2's ruled shape (6109208049), byte-identical on both carriers (the PR body and the changeset, 533 characters). The arm's prose is accurate for every accept-set change in ①; it does not name the additive type widenings of ① item 8, which the value and the major already cover.
  • ADR-0087 disposition: exactly one marker, not-required (already-registered …) naming the three step-18 ids the diff amends; Check Changeset is green on this head.

③ Boundary flags

  • Round-1 open_questions (the three zero-census keys: value slots or literals) — answered B by the seat order 6117306001 under the dispatch's Zone 2 item 2, in the non-escalated class with the maintainer's veto window open; round 2 implements all five items of the order (ledger rows out; literal types with the token refusal and remedy; the executor reads raw; the pins; the text that moves — changeset, D3 entry, descriptor, reference, PR body). Round 2 reports open_questions: []. Judged right (① items 5 and 6).
  • Dev deviation, check:type-check-debt crossed (--re-measure plus an unlocked whole-workspace build, both rounds): a process ⛔, reported by the dev and recorded in the ACCEPT for the seat's stand-down brief; exit 0, no ledger moved; nothing in the diff depends on it. Noted; not a contract matter.
  • Dev deviation, the lint change beyond the order's text list: judged right — without it the hints would prescribe a spelling the build refuses; patch holds.
  • Dev deviation, origin/main moved after the round-2 merge: none of those commits touches a file in this diff; GitHub reads mergeable: true; the queue leg merges main again.
  • Dev deviation (round 1), the PR assignee written by label-write --assign where os-dev.md says pr_create carries it: a conflict between two seat texts for the seat to settle; no contract effect.
  • Out-of-scope findings, each with a carrier: the skill lines SKILL.md:107/:114/:115/:240 and the class-c CSV-recipient finding → skills(objectstack-automation): SKILL.md still teaches {$User.<path>} as a kept value-slot spelling and the text-slot run-user remedy through it, which #19939 pass 2 (PR #22563) refuses #22585 (the seat); the designer's notify pickers and FlowRecipientsField, now including the :1132 sourceObject picker → v18: Studio's subflow input and script inputs fields author a CEL value envelope, not a {var} token (the objectui half of objectstack-ai/objectstack#19939 pass 4, S1) objectui#12101 (the seat); migrations/entries/README.md:138 naming the removed check:migration-registry → an Acceptance note (the seat); the lint's local literal-key set → noted, held by a pin. Each is a note under Prime Directive 10; none buries a defect.
  • Acceptance note, the relation-hop expand remedy: under the template a hop on a scalar id resolved to nothing and the guard named config.expand; as a CEL value the hop faults with the key and the source (recipients[0]: value expression failed to evaluate as CEL: No such key: owner) and no expand hint — the engine's shared value-slot fault since S1, not S4's. Accepted as noted. Recommended for the seat, non-blocking: a pointer on S7 (the engine's canon fixes) or skills(objectstack-automation): SKILL.md still teaches {$User.<path>} as a kept value-slot spelling and the text-slot run-user remedy through it, which #19939 pass 2 (PR #22563) refuses #22585, so the expand lesson stays teachable.
  • Acceptance note, dual-build-cjs-loads not measured locally and the whole-tree lint CI-owned: Lint & Repo Gates is green on this head.
  • Governed surfaces: none among the 50 paths (no .claude/**, skills/**, docs/adr/**, AGENTS.md, CLAUDE.md); this review is owed by the dispatch's tier rule, not Prime Directive 14.

Check-runs on this head, read at 2026-10-12T05:49Z

42 check-runs: 37 success, 4 skipped, 1 failure.

The verdict below is on the diff's contract; the seat separately requires every check green before the queue, which this head does not yet have (the ceiling red).

Implemented-by: claude/issue-19939-p4-s4-notify
Reviewed-by: session_019G5ZnRrTMiwcHG6iZM19zf

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ef02901d26f2d65d1ce553e99c9cfa42cd9ef871
Local-runs: none

PR #22926 (Part of #19939, pass 4 stage S4; draft; head repo = base repo; 50 files, +1985 / −350, 2,335 changed lines; no governed path; no api-surface/, export-origins/, declaration-map/, json-schema.manifest/ or authorable-surface/ file moves). This is the second record on this PR: the head moved from c200819d1e to ef02901d26 by the relay's pr_update_branch merge of main, and this round re-judges the diff on the new head rather than carrying 6118681762. Read for this record: card #19939's body and all 58 comments (the ruling 6063191653, the stage plan 6096263424, the S4 claim 6114980625, the round-1 report 6117280980, the seat order 6117306001, the round-2 report 6118066430, the ACCEPT 6118088551, the release 6118101711, the take-over claim 6118509947, the blocked note 6118694535 and the unlock 6120946872 among them), the PR body, its file list, the net diff against main, the two PR comments (6117245196, 6118681762), and the check-runs on this head, read twice. Nothing was built, run or re-run; the only computation was over fetched text: sums over the file list, a stable patch-id of two fetched diffs, and a byte comparison of two lines.

The hop, measured from the fetched diffs, not from the seat's note. The net diff of c200819d1e against main (compare/main...c200819d1e, diff media type) and the net diff of this head (pulls/22926, diff media type) are 3,805 lines each, share the stable patch-id 9645cec2e9d0, and are textually identical once index lines are dropped. So the bytes judged below are the bytes the earlier record judged; every judgment is re-derived from the diff here, and where the two records agree that is two readings of the same bytes, not one adopted.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged:

  1. recipients — right. Base z.union([z.string(), z.array(z.string())]) becomes NotifyRecipientsSlotSchema (module-local, not exported): a string, a list whose entries are strings or CEL value envelopes (AssignmentExpressionValueSchema), or one envelope, with a superRefine that runs the one value-slot judge (valueSlotTemplateRefusals) over the string and over each string entry at its own index. Widening: the envelope arms, in the published JSON Schema (anyOf string / array / object, pinned in both projections) and the inferred NotifyConfig['recipients']. Narrowing: the {…} token. The union's own error names the entry at fault with the recipients rule. The family finding is fixed by flattening, which the stage plan allowed ("flattened or refused, and pinned"): an entry that evaluates to a list adds each of its recipients; a map, a number or a nested-list element fails the node naming the kind and the source, never String(value); null and a blank string add none; a node left with none fails naming the envelope sources that produced nothing (empty is tracked per envelope, so a blank literal falls to the bare "at least one recipient is required", as at the base). recipients stays required, unchanged, and config-expression-ledger.test.ts now counts it among the channel-required slots refused absent.
  2. sourceId — right. z.string() becomes a string-or-envelope union (notifyStringSlotSchema, module-local) with the token refusal; the executor's resolveStringSlot lets null or a blank string leave the click-through pair unset and fails the node, naming it, on anything that is not a string. Widening (the envelope arm, JSON Schema and type) plus narrowing (the token). Census 10 at the base.
  3. templateData — right. z.record(z.string(), z.unknown()) becomes z.record(z.string(), FlowValueSlotSchema): narrowing only — a {…} token in any string of a value is refused, and an object naming a dialect must be a valid CEL envelope. No shape is newly accepted: an envelope-shaped object was accepted as data and is evaluated now, which the changeset records under "Newly accepted", the right place for a semantics change. The projection's additionalProperties carries xExpression: 'value' (pinned).
  4. actionUrl — right. Stays z.string(); the contract's superRefine applies textSlotTemplateRefusal, FLOW_NODE_TEXT_SLOTS gains notify.actionUrl (so registerFlow, objectstack validate and the lint's text-slot stripping all read it as text), and the executor renders it through renderTextSlot. Narrowing only. Two readings to state precisely: (a) joining the text-slot list brings the second text-slot rule with it — a double-brace hole over an unbound dollar root is refused at actionUrl too; the diff carries that in the amended surface of 18.flow-text-slot-unbound-dollar-root-refused and the changeset's kit line names both text-slot entries, while the Clause-② parenthetical names only the single-brace token at actionUrl. The value and the arm are unaffected (a second narrowing under the same arm), so this is a completeness remark on the prose, not a defect. (b) A link that renders to nothing is now absent from the payload (renderTextSlot answers undefined) where the 17.x executor sent url: ''; downstream the synthesized link applied in both cases (pinned: payload.url undefined, source present). An executor output detail, not an accept-set change, recorded so the next reader does not look for it in the changeset.
  5. sourceObject, actorId — right. The seat order's B (6117306001) under the dispatch's Zone 2 item 2, on a zero census here and in hotcrm. Type unchanged: notifyLiteralStringSchema(key) is z.string() with an error map that gives the literal lead for an envelope-shaped input; the token refusal is the contract's superRefine, with params: { readAsWritten: true }. Narrowing only; the JSON projection pins type: string, no anyOf, no xExpression. Refusing rather than forwarding (the report's option C) is Prime Directive 12's reject-at-authoring: a picker-written {record.x} never reaches sys_notification with its braces.
  6. payload — right. Type unchanged (z.record(z.string(), z.unknown())); the superRefine walks each value (cycle-safe), refuses a token in any string at any depth at that string's own path, and refuses an envelope-shaped top-level value; a deeper envelope stays data (pinned), consistent with the guide's "only a slot's top-level value is an expression". Narrowing only.
  7. The build doors' value arm (flow-node-config-refusals.ts) — right. (a) atOrInsideJudgedLedgerSlot on an eachElement row: an issue at index i is skipped iff element i is envelope-shaped (the envelope pass's); an issue at the list itself is skipped iff every element is a string or envelope-shaped (only a token or a malformed envelope can have refused such a list, and each has its own judge); 42, null or a nested list as an element stays this arm's. Traced on the diff: ['u1', 42] gives a union issue at recipients with an element that is neither, so it is reported (pinned); ['{record.owner}'] gives a superRefine issue at index 0 whose element is not an envelope, which falls through to the token skip, so it is not reported here and the value-slot judge reports it (pinned); [{ dialect: 'template', … }] gives a union issue at recipients with every element envelope-shaped, so it is skipped and the envelope pass reports it at recipients[0] (pinned). (b) READ_AS_WRITTEN_PARAM: ContractIssue gains params?, and builtinValueJudged returns true for a custom issue carrying it, placed after the region, ledger and run-resolved skips (RUN_RESOLVED_KEYS still holds http.signingSecret only) and before the token skip, so a literal's token refusal reaches FlowSchema.parse, objectstack validate, the save door, registerFlow and the executor's parse (pinned at flowNodeConfigRefusals and FlowSchema.safeParse; ablation 4 reds exactly those three pins). An envelope at sourceObject / actorId is an invalid_type issue, unmarked, and is reported because the authored value carries no token (pinned). READ_AS_WRITTEN_PARAM lives in flow-template-token.ts, and packages/spec/src/automation/index.ts at this head re-exports nothing from that module (read at the head): no new public export, as claimed.
  8. The ledger and the channel — right, with the public-type widenings named. Three notify rows are appended at the end of FLOW_NODE_EXPRESSION_PATHS (every older row pinned byte-identical); recipients carries the new optional eachElement: true, a field added to the exported FlowNodeExpressionPath interface (the barrel re-exports flow-node-expression-paths); the module-private slotEmitter makes both walks emit each element of an array at its own index and any other value at the slot's path (pinned); LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains notify, so its keyof union widens; the descriptor offers recipients untyped and the other keys unmarked, so each slot has one channel (the ratchet pin) and the Studio form keeps its picker. These are additive, under the major and the Clause-②: yes; the api-surface/ snapshot records existence and did not move. The arm's prose names accept-set changes only — a completeness remark, not a defect.
  9. The executor — right. notify-node.ts no longer imports interpolate; recipients resolves through resolveRecipients over the shared resolveValueSlot, templateData through resolveValueSlotMap, sourceId through resolveStringSlot, actionUrl through renderTextSlot, and sourceObject, actorId and payload are read as written. No ?? fallback tolerates an off-spec shape (Prime Directive 12). A literal string reaching resolveValueSlot still passes through interpolate (S1's shape) but carries no token, because the contract parse refused one first — which is why ablation 1 reds the evaluation pins and not the refusal pins, as the dev measured. The empty-recipients guard is restated for CEL and names the envelope sources.
  10. The lint — right, patch. The flow-double-brace-interpolation hints name actionUrl among the text slots; at a notify literal both wrong-syntax hints name the value itself (NOTIFY_LITERAL_HINT), never {var}, which the build now refuses there; NOTIFY_LITERAL_KEYS is a local set held equal to the contract by a pin that asks the contract which keys refuse with the literal lead (an export for a hint would widen the spec API); the double-brace control moved from actionUrl, a text slot now, to sourceId; objectstack validate refuses a literal's token and envelope in the contract's words (pinned). No lint API change.
  11. ADR-0087 — right. D3 only: flow-value-slot-template-dialect-refused is amended the S1–S3 way (its surface widens to the notify value slots and the three literals; replacement, reason and acceptance criteria say what a recipient evaluates to, why the list token is not rewritten, and that a literal takes the value itself); the two text-slot entries widen their surface to actionUrl; the STEP18_RATIONALE fragment names the link. No D2, judged on the diff's own measurements: an absent variable was dropped by the template and faults under CEL; a list token written as a list entry reached nobody and now reaches each recipient; a record became [object Object] and now fails the node; a Date in a link renders JSON-quoted under the interpolator and as ISO text under the engine. No key is removed, so no tombstone. Exactly one marker, not-required (already-registered …) naming the three step-18 ids; Check Changeset is green on this head.
  12. Sites and generated artifacts — right. The showcase (16 recipients, 7 sourceId, 4 links), the todo example (2 and 2), three docs pages, the README and two dogfood fixtures are migrated; optional recipient fields are guarded has(record.x) ? record.x : null; {$User.Id} becomes current_user != null ? current_user.id : null (ruling 6063191653, Q1 A), and the urgent-task comment now states the measured both-recipients behaviour the stage plan asked to keep. The showcase inert-wirings guard derives its fields from envelope sources, so it is not vacuous. io-node-config.mdx is regenerated; dropped-refinements.baseline.json moves 708 to 711 with the three new projection-dropped sites. The three private packages touched publish nothing.

② Semver level

  • Changeset .changeset/19939-flow-notify-value-slots.md: @objectstack/spec major, @objectstack/service-automation major, @objectstack/lint patch; .changeset/pre.json at this head is mode: pre, tag: next (read at the head), so the release is 18.0.0-next.*. Right: spec narrows published accept sets on an authoring surface (BREAKING) and widens two; service-automation stops interpolating six positions and evaluates three, and a stored flow carrying a token is skipped at boot with a warn; lint changes finding text only. Not skip-changeset. The BREAKING banner, the FROM / TO table and the one-line fix are present; the title carries !.
  • Clause-②: yes (narrowing: …; widening: …): the value yes (the envelope arms are newly accepted at recipients and sourceId), the arm narrowing (the token and envelope refusals) — S2's ruled shape (6109208049) and S3's (6112829060). Byte-identical on both carriers, 537 bytes, compared from the PR body against the changeset hunk. What the parenthetical leaves out — the unbound-dollar-root hole at actionUrl (① 4a) and the additive type widenings (① 8) — sits under the declared value and arm.
  • ADR-0087 disposition: one marker; Check Changeset green.

③ Boundary flags

  • Round-1 open_questions (the three zero-census keys: value slots or literals) — answered B by the seat order 6117306001 under the dispatch's Zone 2 item 2, in the non-escalated class with the maintainer's veto window open; round 2 implements the order's five items; the round-2 report carries open_questions: []. Judged right (① 5 and 6).
  • Round-2 deviations: check:type-check-debt crossed (--re-measure plus an unlocked whole-workspace build, both rounds) — a process ⛔ the dev reported and the ACCEPT recorded for the seat's stand-down brief; exit 0, no ledger moved, nothing in the diff depends on it; noted. The pins the order placed in "section 4" lived in sections 1 and 2 and now sit in section 6 of notify-value-slots.test.ts — the pins exist; noted. The lint change beyond the order's text list — right, since without it the hints prescribe a spelling the build refuses; patch holds. origin/main moved after the round-2 merge — closed by this head, which merges main. The showcase, todo, dogfood and CLI suites were not re-run in round 2 — round 2 changes no site, and on this head Test Core (all shards and the aggregate) and Dogfood Regression Gate are green.
  • Round-1 deviations: the surface crossings, each named in the PR body with its reason and each a mechanical consequence of the slots; the ablation direction (measured, not the dispatch's prediction); check:migration-registry removed by [Decision] the migration registry registry.ts is the last committed generated aggregate on the ADR-0087 D3 path: keep it and finish B′, or generate it at build #22554 (the generator runs inside build, test and typecheck); the PR assignee written by label-write --assign where os-dev.md names pr_create — a conflict between two seat texts for the seat, no contract effect.
  • The hop, this round: pr_update_branch is a server-side text merge, so a generated artifact main moved could have drifted (the generated-artifacts rule); none did — the net diff is patch-id-equal to the reviewed one, and TypeScript Type Check (which runs check:generated) and Lint & Repo Gates are green on this head.
  • Out-of-scope findings, each with a carrier: the skill lines SKILL.md:107 / :114 / :115 / :240 and the class-c CSV-recipient finding → skills(objectstack-automation): SKILL.md still teaches {$User.<path>} as a kept value-slot spelling and the text-slot run-user remedy through it, which #19939 pass 2 (PR #22563) refuses #22585 (the seat); the designer's notify pickers and FlowRecipientsField, which writes {record.FIELD} and keeps strings only on read, so a stored envelope entry is lost on the next Studio save until the companion lands, now with the :1132 sourceObject picker → v18: Studio's subflow input and script inputs fields author a CEL value envelope, not a {var} token (the objectui half of objectstack-ai/objectstack#19939 pass 4, S1) objectui#12101 (the seat); migrations/entries/README.md:138 naming the removed gate → an Acceptance note; the lint's local literal-key set → held by a pin. Each is a note under Prime Directive 10; none buries a defect.
  • Acceptance note, the relation-hop expand remedy: as a CEL value the hop faults with the key and the source and no expand hint — the engine's shared value-slot fault since S1, not S4's. Accepted as noted; the earlier record's non-blocking pointer recommendation (S7 or skills(objectstack-automation): SKILL.md still teaches {$User.<path>} as a kept value-slot spelling and the text-slot run-user remedy through it, which #19939 pass 2 (PR #22563) refuses #22585) stands.
  • Not measured locally: dual-build-cjs-loads and the whole-tree lint, both CI's; Lint & Repo Gates is green on this head.
  • Governed surfaces: none among the 50 paths (no .claude/**, skills/**, docs/adr/**, AGENTS.md, CLAUDE.md); head repo equals base repo; 2,335 changed lines, under 3,000. This review is owed by the dispatch's tier rule (packages/spec/src/** non-test plus Clause-②: yes), not by Prime Directive 14.

Check-runs on this head

  • First read 2026-10-12T09:20Z: 41 check-runs — 36 success, 4 skipped, 1 in progress (Test Core (3/6)); the aggregate Test Core job had not yet reported.
  • Second read 2026-10-12T09:28Z: 42 check-runs — 38 success, 4 skipped, 0 failure, 0 in progress. Skipped: Console Pin Gate, Packed-tarball smoke (opt-in), and the second Auto Label / Check PR Size runs (their first runs are success). All seven required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core (and shards 1/6 to 6/6), Dogfood Regression Gate (and 1/3 to 3/3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also success: Build Docs, Check Changeset, Check Documentation Links, Dogfood Verify CLI, Flag docs affected by code changes, the three PR-context guards, Spec property liveness, the four Type Check · jobs and filter. The ceiling red that held the previous head (ci: merge-queue Test Core reds on the new suite-duration ceiling — @objectstack/cloud-connection measured 177.5 s and 200.3 s vs its 163.89 s ceiling, in two groups of PRs that do not touch it (live since 1bd612bd6) #22949) does not appear on this one.

Implemented-by: claude/issue-19939-p4-s4-notify
Reviewed-by: session_019G5ZnRrTMiwcHG6iZM19zf

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 12, 2026 09:33
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 12, 2026 09:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants