Skip to content

Commit 968acc8

Browse files
feat(spec,service-automation,lint)!: an http node's url and headers are text slots, its body a value slot, the rest literals — the {…} token refused (#19939 pass 4, S5) (#23062)
Part of #19939 Clause-②: yes (narrowing: a {…} token is refused at http url, each headers value, any string of a literal body, method, durable, timeoutMs and signingSecret, and as a whole headers map, and a body value that names a dialect must be a valid CEL value envelope; widening: the exported FlowNodeTextSlot type gains the optional eachValue field, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains http, and the published HttpConfig JSON Schema marks body as a value slot) Pass 4, stage S5 of #19939 (stage plan `6096263424`, claim `6122880753`), under ruling D on #11182 and ADR-0032 Decisions 2 and 3. An `http` node reads each config key by what it is: `url` and every `headers` value are **text slots** with `{{ }}` holes (the plan's carry line), `body` is a **value slot** (a CEL value envelope or a literal), and `method`, `durable`, `timeoutMs` and `signingSecret` are **literals**, read as written (S4's seat order `6117306001`, answer B, applied per key on the census below). A single-brace `{…}` token at any of them is refused. The executor parses its config as authored, renders the request once before either mode, and no longer imports the single-brace interpolator. #19939 stays open for S6 and S7, so this PR says `Part of`. ## The census, per key (base `9432bac07`) Every token-bearing string at an `http` node position, in `examples/**`, `content/docs/**` and `docs/**` (references and releases excluded), `packages/qa/dogfood`, `packages/platform-objects` and every tracked TS / JS file. The test column is the fix round's structural sweep at `1105b4a06` (each of the seven keys on an object typed `http`, ternary branches and helper arguments followed); the first census matched http fixtures by text and missed the ten strings marked †, rewritten in `af1317fd1`. hotcrm is not measured (another repository; the dispatch carries it). | key | S5 | shipped (examples, docs, dogfood, platform-objects) | tests | why | |:--|:--|:--|:--|:--| | `url` | text slot, `{{ }}` | 0 | 6: `lint-flow-credential-literals.test.ts:269` (a template draws no credential finding), `validate-flow-template-paths.test.ts:181`, `flow-builtin-node-config-values.test.ts:240`, `flow-text-slot-template.test.ts:259`, † `runtime-authoring-gate.flow-credential-literal.test.ts:39` and † `flow-credential-literal-doors.e2e.test.ts:71` (the DARK credential controls) | the plan's carry line (binding): a url is text | | `headers.*` | text slot per value, `{{ }}`; a token written as the whole map refused | 0 | 5: `flow.test.ts:786` (`Bearer {$Credential.token}`), `flow-builtin-node-config-values.test.ts:224` (`headers: '{headers}'`, the build-door carve-out control), † `runtime-authoring-gate.flow-credential-literal.test.ts:42` and † `flow-credential-literal-doors.e2e.test.ts:74` (the DARK credential controls), † `lint-flow-credential-literals.test.ts:84` (the scanner's template control) | the plan's carry line (binding) | | `body` | **value slot**, one value (the whole payload) | **5 tokens in 4 bodies**: showcase `flows/index.ts:1059` (text with a hole), `:1239` (two whole tokens), `:1444` (one); `content/docs/automation/flows.mdx:464` (one) | 5: † `flow.test.ts:492`-`:493` (the approval flow, two tokens), `flow.test.ts:788`, `flow-walk.test.ts:165`, `validate-flow-template-paths.test.ts:560`, † `validate-flow-template-paths.test.ts:465` (the hand-off helper, fed `:441`, `:490`, `:500`) | measured pull | | `method` | **literal** (B) | 0 | 0 | no flow computes it | | `durable` | **literal** (B) | 0 | 1: `flow-builtin-node-config-values.test.ts:223`, the build-door carve-out control | the one test pins the interpolation itself, not a use | | `timeoutMs` | **literal** (B) | 0 | 2: `config-parse.test.ts:193` (the post-interpolation parse), `flow-builtin-node-config-values.test.ts:222` (the carve-out control) | both pin the interpolation itself | | `signingSecret` | **literal** (B), run-resolved, ⛔ not a template or value slot | 0 | 5: `http-node-signing.test.ts:208`, `:241`, `:255`, `:309` (a secret through a template, and the "resolved to nothing" refusal), `guard-refusal-inventory.test.ts:160` | the tests pin the interpolation itself; the credential channel is unchanged | **The test, applied.** A test that exists to pin the interpolation is not pull: it is the mechanism this card retires, the same reading S4's B gave the designer's picker ("a surface, not a use"). So the four zero-shipped keys are literals, and each such test is re-judged in this PR (below). ## FROM → TO, per position | position | you wrote | write instead | what changes | |:--|:--|:--|:--| | `url` | `'https://api.example.com/v1/orders/{record.id}'` | `'https://api.example.com/v1/orders/{{ record.id }}'` | a `Date` renders as its ISO text, not JSON-quoted | | `headers.X` | `'r-{record.id}'` | `'r-{{ record.id }}'` | a header that renders to no text is not sent (the template sent it empty) | | `headers` (whole) | `'{headers}'` | the map: `{ 'X-Ref': '{{ record.id }}' }` | a map is not computed into it | | `body` | `{ id: '{record.id}', status: 'done' }` | `{ dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }` | an absent `id` fails the run instead of sending `null`; a literal beside a field read is wrapped in `dyn(…)` (CEL holds a map's values to one type) | | `body` | `{ text: 'Done: {record.title}' }` | `{ dialect: 'cel', source: "{'text': 'Done: ' + record.title}" }` | an absent `title` fails the run instead of sending `'Done: '` | | `method` / `durable` / `timeoutMs` | `'{verb}'` / `'{flag}'` / `'{timeout}'` | the value itself: `'POST'` / `true` / `5000` | a literal now; nothing computes it | | `signingSecret` | `'{signing_key}'` | the secret itself (a metadata-API save keeps it in the write-only credential store), or `''` | refused at the run | **The one-line fix: double the braces in `url` and the `headers` values, write `body` as one CEL value envelope that builds the payload, and write `method`, `durable`, `timeoutMs` and `signingSecret` as the values themselves.** ## The mechanism assumptions, measured - **A1 holds.** `HttpConfigSchema` (`io-node-config.zod.ts:745` at the base) carries `url`, `method`, `headers`, `body`, `durable`, `timeoutMs`, `signingSecret` and the `surface` / `history` keys of its `strictObject`, nothing else. - **A2 holds, and what replaces the order.** `PARSED_AFTER_INTERPOLATION = new Set(['http'])` existed because `http-nodes.ts:182` parsed `interpolate(raw, …)`: the build doors judged an `http` rule only on a config carrying no token at all. The executor no longer interpolates, so the set had one member and loses it; the constant and its one line in `builtinValueJudged` are deleted (a set that is always empty is a check that can never fire). What replaces the order: credential channel → parse the config **as authored** → render `url` and the `headers` values → evaluate `body` → either mode. That is the order every other contract-carrying builtin already had (notify since S4). - **A3 holds, with one note.** `http-nodes.ts` imports `renderTextSlot` and `resolveValueSlot`, not `interpolate`. A literal body passes through `resolveValueSlot`'s residual `interpolate()` (the identity on a token-free literal; the contract parse refused any token first), the same residual call S1–S4's slots make, which S7 deletes. The three `??` left in the executor are the declared absent-key defaults, not tolerance of an off-spec shape: `cfg.method ?? 'POST'` / `?? 'GET'` (the contract documents both, and why there is no `.default()`), and `payload: body ?? {}` on the outbox arm (unchanged). - **A4 holds, and the request is rendered once.** At the base the whole config was interpolated in the executor before the durable branch, and `service-messaging`'s outbox and dispatcher render nothing (`http-outbox.ts` stores the url, headers and payload as handed; read at the base). After S5 the executor renders `url` and the headers and evaluates `body` once, before the durable / inline branch, so the outbox enqueues exactly what the inline call would send — pinned (`http-slots.test.ts` 3: the enqueued url, headers and payload are the rendered ones, and nothing in the enqueue carries a `{{`). ## `signingSecret` — the one judgement this PR settles itself The binding text: "`signingSecret` is a run-resolved key (`RUN_RESOLVED_KEYS`): its secret-reference resolution stays exactly as it is. ⛔ Do not turn it into a template or value slot." Read with A2 ("once no `{var}` is read at any `http` key") and A3 ("no longer calls `interpolate`"), the resolution that stays is the write-only credential channel, and it does: `holdsFlowCredential` / `resolveFlowCredential` and `RUN_RESOLVED_KEYS` are byte-identical. What goes is the `{token}` interpolation of the secret, which the census shows only the interpolation's own tests ever wrote. The key is therefore a literal (S4's B), a token in it is refused with a remedy that never echoes the value, and because the build doors leave a run-resolved key to the run, that refusal is the executor's parse — pinned both ways (`registerFlow` and `objectstack validate` accept `signingSecret: '{k}'`; the run refuses it, nothing is sent). The dead "set but resolved to no value" refusal goes with the interpolation: with nothing rendered, the parsed secret is the authored one. Four axes: no measured business use (tests only); one dialect and S7's resolver deletion stay possible (keeping `interpolate` on this one key would block it); a token is refused loudly at the run instead of signing with its braces or leaving unsigned; immediate retirement, no window. The boundary this leaves is in Acceptance notes. ## What lands **The contract (`@objectstack/spec`).** - `url` stays `z.string()` and each `headers` value `z.string()`; the contract's refinement refuses a single-brace token in either through the one text-slot judge (`textSlotTemplateRefusal`), with its hole spelling, and a hole over a `$` root the engine does not bind. - `body` takes `FlowValueSlotSchema`: an object naming a `dialect` must be a valid CEL value envelope, and a `{…}` token in any string of a literal is refused by the one value-slot judge, which names the envelope that builds the whole body. - `method`, `durable`, `timeoutMs` and `signingSecret` keep their 17.x types; a token or an envelope there is refused with the literal's remedy. The refusal of a token at `method`, `durable`, `timeoutMs` and of a token written as the whole `headers` map carries `READ_AS_WRITTEN_PARAM` (S4's mechanism, unchanged), so the build doors report it. The refinement runs with `when: () => true`, so a token at a typed literal (`timeoutMs: '{t}'`) gets the literal's sentence beside the type's refusal; the build doors report the marked one, once. - Every `describe()` of the http keys is restated, and the module docblock. **The ledger and the doors.** - `FLOW_NODE_EXPRESSION_PATHS` appends the `http` `body` row (one value, the whole payload); every older row is byte-identical (pinned). - `FLOW_NODE_TEXT_SLOTS` gains `http.url` and `http.headers`. The exported `FlowNodeTextSlot` gains the optional `eachValue`: `flowNodeTextSlotSources` yields each value of the map at its own path (`headers.X-Ref`), so `registerFlow` and `objectstack validate` judge and compile each header like every other text slot. - `HttpConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the body's `xExpression: 'value'` to the reconciliation ratchet (the descriptor offers `body` with no marker: one slot, one channel). - `flow-node-config-refusals.ts`: `PARSED_AFTER_INTERPOLATION` and its line are deleted (A2); `RUN_RESOLVED_KEYS` is unchanged. **The executor (`@objectstack/service-automation`).** `http-nodes.ts` parses the config as authored (after the credential channel), renders `url` and each header through `renderTextSlot` (a url that renders to no text refuses the node, a header that renders to no text is not sent), evaluates `body` through the shared `resolveValueSlot`, reads the literals as written, and no longer imports `interpolate`. The descriptor's help strings are restated; its `type`s are unchanged. **The lint (`@objectstack/lint`, patch).** The notify-literal hint machinery becomes per node type (`LITERAL_KEYS` / `LITERAL_HINTS`): at an `http` literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself. The `flow-double-brace-interpolation` hints name the http url and headers among the text slots. The credential-literal docblock stops calling `http`'s single brace its template. `objectstack validate` refuses each position through the shared judges and the contract, with no rule change. **ADR-0087, D3 only.** `18.flow-value-slot-template-dialect-refused` (surface: the http body and its four literals; replacement, reason, acceptance), `18.flow-text-slot-single-brace-refused` and `18.flow-text-slot-unbound-dollar-root-refused` (surface: the http url and headers values), and `18.flow-builtin-node-config-values-refused` with its `STEP18_RATIONALE` fragment (it said `http` parses after interpolating and that only an `http` typed slot may take a sole-token template; both stop being true here). No D2: an absent value in the body wrote `null` or nothing under the template and fails the run under CEL; a whole token at `timeoutMs` / `durable` resolved to its type and is text now; a `Date` in a url or header rendered JSON-quoted under the interpolator and as ISO text under the engine. No key is removed, so no tombstone. The changeset marker is `not-required (already-registered …)` naming the four ids. **Sites.** The showcase's three bodies (`flows/index.ts`) and the flows guide's http example are migrated; a literal beside a field read inside a CEL map literal is wrapped in `dyn(…)` (measured: `{'id': record.id, 'status': 'done'}` is refused by the CEL checker at `registerFlow`, "Map value uses wrong type"). The flows guide gains an http paragraph and the http keys in its text-slot and value-slot lists and its dialect table; the service-automation README's dialect table likewise. Test fixtures that wrote the retired spellings at these keys are re-judged: in the first pass `flow.test.ts`'s api flow, the lint credential-literal fixtures, `config-parse.test.ts`, the signing tests, the guard-refusal inventory and the build-door carve-out control; in the fix round (`af1317fd1`) every remaining fixture the structural sweep found outside the refusal pins: the two DARK credential controls (`metadata-protocol` save door, `cli` doors; `{{ api_token }}` at the url and header, still no advisory), the lint scanner's template header control (`{{ run_token }}`), `flow-walk.test.ts`'s body (a CEL envelope), `validate-flow-template-paths.test.ts`'s url pin (a `{{ }}` hole) and hand-off payload (moved from the body, which no template reaches, to an `X-Billing-Amount` header text slot), and `flow.test.ts`'s approval-flow body (a CEL envelope, now parsed against `HttpConfigSchema`). ## Pins, and the ablations that turn them red - `http-slots.test.ts` (service-automation, 21 engine pins): each position refused at `registerFlow`, located and in its own judge's words; the executor's contract parse refuses url, header and body tokens and each literal's token as a guard, nothing sent; `signingSecret` accepted at `registerFlow` and refused at the run; holes render in the url and headers, the body envelope evaluates (`dyn`), a literal body and a nested envelope are sent as written, a whole hole holding a map renders JSON; the durable enqueue carries the rendered request; a url rendering nothing refuses the node, a header rendering nothing is not sent, an absent variable in the body fails the run with `EXECUTION_ERROR` naming `body`. - Spec: `io-node-config.test.ts` (24 contract pins: text slots, body, literals with the marker, `signingSecret` without one and never echoing the value, the build doors, the JSON projection, the describes); `flow-node-expression-paths.test.ts` (the body row, read whole; the census row list); `flow-text-slot-template.test.ts` (the eight slots, `eachValue` on `headers` only, the header sources); `flows-translation-face.test.ts` (`http.url` / `http.headers` classified); `builtin-node-config.test.ts` (the ledger-declared map lists `http`). - service-automation: `config-expression-ledger.test.ts` (http declares its one value slot on the spec Zod channel alone); `config-parse.test.ts`, `http-node-signing.test.ts`, `guard-refusal-inventory.test.ts` re-judged. - lint: `validate-expressions.test.ts` (`objectstack validate` refuses each position; `signingSecret` left to the run); `lint-flow-patterns.test.ts` (the http literal hints, the contract's literal keys asked of the contract; the #16405 controls move to a CRUD `filter`, the one position S6 has not reached). Ablations, each through `scripts/ablation-replace.mjs` (WRAP, trap on EXIT/INT/TERM; anchor x1 → x0; restored blob == HEAD blob, `git diff HEAD` empty): | # | ablated | red | green after restore | |:--|:--|:--|:--| | 1 | the contract's url text-slot refusal | the 2 url pins | ✓ | | 2 | the contract's header text-slot refusal | the 2 header pins | ✓ | | 3 | the literal token refusal | the 3 literal pins + the build-door pin | ✓ | | 4 | the read-as-written marker on it | the same 4 (the build doors stop reporting the token) | ✓ | | 5 | the `signingSecret` token refusal | its pin + the run-resolved pin | ✓ | | 6 | the whole-`headers` token refusal | its pin + the build-door pin | ✓ | | 7 | the ledger's body row | the 2 body-row pins + the census row list | ✓ | | 8 | the two `FLOW_NODE_TEXT_SLOTS` http rows, **through `dist/`**: rebuild, `ablation-dist-preflight.mjs @objectstack/spec 'http-ablated-s5'` (marker present in 2 built files) | exactly the 4 door pins: `registerFlow` url / header (`http-slots.test.ts`), `objectstack validate` url / header | ✓ after rebuild; preflight `--absent` (absent from 232 built files, tree clean); `http-slots.test.ts` 21/21 | Ablations 1–7 ran on spec `src` (those pins import `src`); 8 is the door, which `service-automation` and `lint` read through the spec's `dist`. ## Verification record Each suite through `scripts/pm/os-verify-lock.sh` (slot `issue-19939-s5`, a shared box). `origin/main` was merged once, through `os-regen-merge.sh` (`ca951bcb5`, from `9f4b652e3`; no path overlapped, and the generated reference kept this branch's bytes); the last commit after it, `1105b4a06`, is one spec test file. - **At `af1317fd1` (the head; test files only on `1105b4a06`):** metadata-protocol FULL "227 passed | 3 skipped (230) / 28190 passed"; lint FULL "137 / 6579"; spec local "653 / 19526 passed | 1 todo", repo "56 / 973"; cli unit 281 + the 2 build-dependent pins after the cli build; the cli e2e in its nightly tier "1 / 2"; typecheck exit 0 for lint, metadata-protocol, cli, spec; the turbo-affected closure against `9f4b652e3` (76 packages, 71 test tasks plus dogfood) all exit 0; the 120 derived gates accounted for (118 run, 2 NOT MEASURED); ESLint narrowed 44 files, 0 / 0. Size: 50 files, +1,566 / −366 against `9f4b652e3`. - **At `1105b4a06`:** spec `--project local` FULL: `"653 passed (653) / 19526 passed | 1 todo"`; the gate union (below); eslint narrowed (below). - **At `ca951bcb5` (the merge):** the sa + lint + showcase dependency closure built ("Tasks: 60 successful, 60 total"); `check:generated` exit 0 (14 artifacts, none stale; `check:api-surface` and `check:authorable-surface` ✓); spec `--project repo` FULL "56 passed (56) / 973 passed"; spec `--project local` FULL "1 failed | 652 passed (653) / 19525 passed" — the one red was `builtin-node-config.test.ts`'s ledger-declared key list, which this stage widens; fixed in `1105b4a06`, and that file with `flow.test.ts` reads "250 passed" there; spec typecheck exit 0 (test layer 52 / 246 / 135 held); the http / notify / ledger set of service-automation "10 passed / 175 passed"; the lint set "7 passed / 796 passed"; `@objectstack/example-showcase` FULL "33 passed (33) / 409 passed". - **At `bef14eda3` (before the merge; the implementation unchanged since):** service-automation FULL "189 passed (189) / 2481 passed"; lint FULL "137 passed (137) / 6579 passed"; spec `--project repo` FULL "56 / 973"; typecheck exit 0 for spec, service-automation (test layer 0 errors) and lint (test layer 2 / 6 / 2 held). - **Ablations:** the table above (src at `1105b4a06`; the dist ablation at `1105b4a06`). - **The gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `1105b4a06` (no paths; "derived from the tree of 'objectstack-ai/objectstack' at commit 1105b4a", the `--repo` assertion holds, no stale-tree warning): 118 derived; 116 run, each exit 0; 2 NOT MEASURED: `pnpm check:type-check-debt` (the dispatch rules it out) and `pnpm check:dual-build-cjs-loads` (it needs the whole workspace's `dist`). `--ran` over the annotated record (`COMMAND :: exit N`): "118 derived, 116 run, 2 NOT-MEASURED, 0 UNRUN", exit 0. At the merge `ca951bcb5` the same battery had one refusal, `check:skill-examples` exit 3 (PREREQUISITE NOT MET: the client SDK unbuilt); after that closure was built it exits 0. The five artifact rosters kept in a directory this diff touches each exit 0 at the head: `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`) need this PR's number and body, so CI's runs on this PR are their record. - **ESLint, narrowed:** `eslint --no-inline-config --format json` over the 40 changed `.ts` / `.mjs` files of the net diff against `9f4b652e3`, at `1105b4a06`: exit 0; the JSON report holds 40 files, 0 errors, 0 warnings. The config enables no type-aware linting (`eslint.config.mjs`: every `parserOptions` is `{ ecmaVersion, sourceType }`, no `project` / `projectService`), so no untouched file's verdict can move. The whole-tree `pnpm lint` is CI's. - **Size:** 46 files, +1,528 / −346 (1,874 changed lines, generated included), against `9f4b652e3`. ## Out of the claim's file surface Each a mechanical consequence of the slots, or a stale sentence about them: - `flow-node-expression-paths.ts` (the body row) and `schemaless-node-config.zod.ts` (`http` joins the ledger-declared map); `dropped-refinements.baseline.json` (the spec build refused until `automation/HttpConfig` was declared: sites `""` and `body`, 711 → 713). - `18.flow-builtin-node-config-values-refused.ts` and its `STEP18_RATIONALE` fragment (they said `http` parses after interpolating). - Docblocks that named the old order or the http single brace: `flow-template-token.ts`, `builtin-node-config.zod.ts`, `flow-value-slot-template.ts`; `parse-config.ts`, `template.ts`, `value-slot-map.ts`, the service-automation README; lint `credential-literal.ts`, `flow-walk.ts`, `validate-flow-template-paths.ts`, `validate-expressions.ts`; `service-messaging`'s `http-outbox.ts` and `plugin-webhooks`'s `webhook-headers.ts` (comment-only, nothing published moves). - Test fixtures and pins outside the http tests: `flow.test.ts`, `builtin-node-config.test.ts`, `flow-builtin-node-config-values.test.ts`, `flows-translation-face.test.ts`, the lint credential-literal tests. - Fixtures the fix-round sweep found outside the kit, rewritten to their intent in `af1317fd1`: `metadata-protocol`'s `runtime-authoring-gate.flow-credential-literal.test.ts` and `cli`'s `flow-credential-literal-doors.e2e.test.ts` (the DARK controls, `{{ api_token }}` at the url and header), and lint's `flow-walk.test.ts` (the body a CEL envelope) and `validate-flow-template-paths.test.ts` (the url pin a `{{ }}` hole; the hand-off payload moved to an `X-Billing-Amount` header). - **In-place fixes in the same sentences (the exemption's four conditions hold: the same stale single-brace teaching, mechanical, no other claim on the file, the same gate family):** `template.ts` and the flows guide still named a notify recipient as single-brace, the README's sentence named `recipients`, and a ledger test comment counted S4's notify rows as six (three). ## Acceptance notes - **`signingSecret` is refused at the run, not at the build doors** — the binding run-resolved order. So a Studio save of `signingSecret: '{k}'` is accepted, moves into the credential store, and every run of that node is refused (as a guard, nothing sent, the remedy naming the secret itself). Making it a build-door refusal would mean reading the marked token refusal ahead of `RUN_RESOLVED_KEYS`, which would also refuse a packaged flow whose credential-store row supplies the secret the run actually signs with; that is a contract decision for the seat, not taken here. - **A body envelope that evaluates to `null`** sends no body inline and `{}` to the outbox (`payload: body ?? {}`) — the same asymmetry an absent body had before; unchanged. - **A CEL map literal holds one value type.** `{'id': record.id, 'status': 'done'}` is refused at `registerFlow` by the CEL checker ("Map value uses wrong type, expected type 'dyn' but found 'string'"), loud, and the value-slot judge's own remedy names `dyn(…)`; the docs, the describe, the changeset and the examples write the `dyn` form. - `validate-flow-template-paths` still path-checks a single-brace `{record.x}` in an http body, beside the value-slot judge's refusal of the same token; its single-brace reading goes in S7. - The lint's http literal keys are a local set, as S4's notify set is; a pin asks the contract which keys refuse a token as read-as-written and holds the set to it (the contract also lists `headers`, whose whole-map token it refuses; its values are text slots, where `{{ }}` is the spelling). - **NOT MEASURED:** `pnpm check:type-check-debt` (the dispatch rules it out: `--re-measure` plus an unlocked whole-workspace build) and `pnpm check:dual-build-cjs-loads` (it loads every published `require` entry, so it needs the whole workspace's `dist`; CI's); hotcrm at these keys (another repository; the dispatch carries it). - **Carried by the seat, not here:** the skill text (`skills/objectstack-automation/SKILL.md:236`–`:241` lists `http` among the slots that keep the single brace) to #22585; the objectui designer's `http` pickers to an objectui companion card. --- _Generated by [Claude Code](https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c10776 commit 968acc8

50 files changed

Lines changed: 1566 additions & 366 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/service-automation': major
4+
'@objectstack/lint': patch
5+
---
6+
7+
An `http` node reads each config key by what it is now. Its `url` and every `headers` value are text slots, like a notify `title`: they render `{{ }}` holes, `'https://api.example.com/v1/orders/{{ record.id }}'`. Its `body` is a value slot: a CEL value envelope, `{ dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }`, that the executor evaluates in the run's scope to the payload, or a literal sent as written. Its `method`, `durable`, `timeoutMs` and `signingSecret` are literals, read as written. A single-brace `{…}` token at any of them is refused, naming its `{{ }}` spelling at a text slot, its CEL spelling in the body, and at a literal the remedy to write the value: at `objectstack validate`, at `registerFlow` and by the executor, except at `signingSecret`, which the build doors leave to the run because the flow credential store may hold the secret it signs with, and which the executor's parse refuses there.
8+
9+
Clause-②: yes (narrowing: a {…} token is refused at http url, each headers value, any string of a literal body, method, durable, timeoutMs and signingSecret, and as a whole headers map, and a body value that names a dialect must be a valid CEL value envelope; widening: the exported FlowNodeTextSlot type gains the optional eachValue field, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains http, and the published HttpConfig JSON Schema marks body as a value slot)
10+
11+
<!-- adr-0087: not-required (already-registered flow-value-slot-template-dialect-refused, flow-text-slot-single-brace-refused, flow-text-slot-unbound-dollar-root-refused, flow-builtin-node-config-values-refused) The value-slot retirement's step-18 D3 entry, registered on this line before this change, is amended in this diff: its surface widens to an http node's body and to its four literals, and its replacement, reason and acceptance criteria say what the body evaluates to and that a literal takes the value itself. The two text-slot D3 entries widen their surface to the http node's url and headers values, and the builtin-values D3 entry stops saying that http parses after interpolating. No new D3 entry and no D2 conversion: an absent value in the body wrote null or nothing under the template and fails the run under CEL, a whole token at timeoutMs or durable resolved to its value's type under the template and is read as text now, and a Date in a url or a header rendered JSON-quoted under the interpolator and as its ISO text under the template engine. -->
12+
13+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`).
14+
15+
**Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a url and a header are text. Until now the executor handed its whole config to the single-brace interpolator and parsed the result, so every key read the retired dialect, a whole token in a typed key (`timeoutMs: '{t}'`) resolved to its value's type, and a CEL envelope written as the body was sent to the endpoint as the object it spells.
16+
17+
**The request is rendered once.** The executor parses the config as authored — after the flow credential store's `signingSecret`, unchanged — then renders the url and the headers and evaluates the body, before either mode. The durable outbox enqueues exactly the request the inline call would send, and nothing renders at delivery.
18+
19+
**What changes where a value may be absent.** A url that renders to no text refuses the node, as an empty one did. A header that renders to no text is not sent, where the template sent it empty. In the body an absent variable or key fails the run under CEL, naming it, where the template wrote `null` for a whole token and nothing inside text; guard a value that may be unset, `has(record.owner) ? record.owner : null`. CEL holds a map's values to one type, so in a CEL map literal a literal or a text beside a field read is wrapped in `dyn(…)`.
20+
21+
**The literals.** `method`, `durable`, `timeoutMs` and `signingSecret` passed through the interpolator too, but no shipped flow computed one: the census found no `{…}` token at any of them in this repository's examples, docs or dogfood fixtures, only in the tests that pinned the interpolation itself. So no value slot is built there. They are read as written, and a `{…}` token in one is refused with the literal's remedy; an envelope there is refused too, since nothing evaluates it. A token written as the whole `headers` map, which the template resolved to a map, is refused the same way. A signing secret that arrived through a template (`signingSecret: '{signing_key}'`) is refused: write the secret itself, which a flow saved through the metadata API keeps in the write-only flow credential store, or `''` to send unsigned on purpose.
22+
23+
**What does not change.** Every literal is sent as written. The method still defaults to `GET` inline and `POST` durable. An authored `signingSecret: ''` still sends unsigned, and a secret the credential store holds still takes the literal's place. A url or header with no token, and a body with no token, behave as before.
24+
25+
## FROM → TO
26+
27+
| you wrote | write instead | what changes |
28+
|:--|:--|:--|
29+
| `url: 'https://api.example.com/v1/orders/{record.id}'` | `url: 'https://api.example.com/v1/orders/{{ record.id }}'` | a `Date` in the url renders as its ISO text, not JSON-quoted |
30+
| `headers: { 'X-Ref': 'r-{record.id}' }` | `headers: { 'X-Ref': 'r-{{ record.id }}' }` | a header that renders to no text is not sent |
31+
| `body: { id: '{record.id}', status: 'done' }` | `body: { dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }` | an absent `id` fails the run instead of sending `null` |
32+
| `body: { text: 'Done: {record.title}' }` | `body: { dialect: 'cel', source: "{'text': 'Done: ' + record.title}" }` | an absent `title` fails the run instead of sending `'Done: '` |
33+
| `body: '{payload}'` | `body: { dialect: 'cel', source: 'payload' }` | an absent `payload` fails the run instead of sending no body |
34+
| `timeoutMs: '{timeout}'`, `durable: '{flag}'`, `method: '{verb}'` | the value itself: `timeoutMs: 5000`, `durable: true`, `method: 'POST'` | the key is a literal now; nothing computes it |
35+
| `headers: '{headers}'` | the map itself: `headers: { 'X-Ref': '{{ record.id }}' }` | a map is not computed into `headers` |
36+
| `signingSecret: '{signing_key}'` | the secret itself, or `''` to send unsigned | a secret read from a flow variable is refused at the run |
37+
38+
**The one-line fix: double the braces in `url` and the `headers` values, write `body` as one CEL value envelope that builds the payload, and write `method`, `durable`, `timeoutMs` and `signingSecret` as the values themselves.**
39+
40+
**Who is affected, measured.** In this repository, at the base: the showcase example (three bodies holding four tokens) and the flows guide (one body); both are migrated in this change. No url, header, method, `durable`, `timeoutMs` or `signingSecret` in the examples, docs or dogfood fixtures carries a token. hotcrm was not measured at these keys.
41+
42+
**Newly refused in the body.** An object naming a `dialect` was data before and was sent as written; it is an expression now, evaluated per run, and a malformed one is refused. An envelope nested inside a literal body is still data, sent as written.
43+
44+
**Still accepted, unchanged.** Every literal at these keys, and the published shapes of `url` (a string), `headers` (a map of strings), `method`, `durable`, `timeoutMs` and `signingSecret`. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. The notify changeset on this line lists an `http` body among those positions; this one supersedes that line.
45+
46+
### The kit
47+
48+
- **The contract.** `HttpConfigSchema`: `url` and each `headers` value refuse a single-brace token through the text-slot judge; `body` takes `FlowValueSlotSchema`; `method`, `durable`, `timeoutMs` and `signingSecret` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. The token refusals at `method`, `durable`, `timeoutMs` and a whole `headers` map are marked read-as-written, so the build doors' value arm reports them at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated.
49+
- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains the `http` body row. `FLOW_NODE_TEXT_SLOTS` gains `http.url` and `http.headers`, whose new `eachValue` flag reads each value of the map at its own path (`headers.X-Ref`). `HttpConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the body's marker to the reconciliation ratchet. The build doors' carve-out for a config parsed after interpolation is gone with the interpolation.
50+
- **The executor.** The `http` node parses its config as authored, renders the url and headers through the text renderer, evaluates the body through the shared value-slot resolver, and reads the literals as written; it no longer imports the single-brace interpolator.
51+
- **The lint.** The `flow-double-brace-interpolation` hint names the http url and headers among the text slots, and at an http literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract.
52+
- **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused`, `flow-text-slot-unbound-dollar-root-refused` and `flow-builtin-node-config-values-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion.

‎content/docs/automation/flows.mdx‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ positions that hand a value to the client: an object form's `defaults` (each
235235
field's prefill), a field's `defaultValue`, and an object form's `recordId`,
236236
the list a `loop` (with a `body`) and a `map` iterate, their `collection`, and a
237237
`notify` node's computed values: its `recipients`, its `templateData` map and
238-
its `sourceId`. Each value
238+
its `sourceId`, and an `http` node's `body`, the payload it sends. Each value
239239
takes a CEL value envelope or a literal — a `collection`'s literal is an inline
240240
array, never a string, and a `recipients` list holds one entry per recipient,
241241
each a string or an envelope. Only a slot's top-level
@@ -380,7 +380,8 @@ does evaluate:
380380
### Text slots read double-brace holes
381381

382382
A flow's **text** slots — a `notify` node's `title`, `message` and `actionUrl`
383-
(the link, since [#19939](https://github.com/objectstack-ai/objectstack/issues/19939)), a `screen` node's `title` and `description`, and a
383+
(the link, since [#19939](https://github.com/objectstack-ai/objectstack/issues/19939)), an `http` node's `url` and each of its `headers` values
384+
(since the same card), a `screen` node's `title` and `description`, and a
384385
refusing `end` node's `message` — render through the formula template engine
385386
([#22110], ADR-0032 §3): each
386387
`{{ }}` hole is a **variable path** with an optional **formatter**, and every
@@ -459,13 +460,29 @@ check.
459460
type: 'http',
460461
label: 'Post Order Event',
461462
config: {
462-
url: 'https://api.example.com/v1/order-events',
463-
method: 'POST',
464-
body: { event: 'order.created', name: '{record.name}' },
463+
url: 'https://api.example.com/v1/order-events/{{ record.id }}', // a text slot: `{{ }}` holes
464+
method: 'POST', // a literal, read as written
465+
headers: { 'X-Request-Id': '{{ $runId }}' }, // each value a text slot
466+
// a value slot: one CEL value envelope builds the whole payload
467+
body: { dialect: 'cel', source: "{'event': dyn('order.created'), 'name': record.name}" },
465468
},
466469
}
467470
```
468471

472+
An `http` node reads each key by what it is. `url` and every `headers` value
473+
are **text** slots with `{{ }}` holes, like a notify title; a header that
474+
renders to no text is not sent. `body` is a **value** slot: a CEL value
475+
envelope evaluated per run to the payload — a CEL map literal builds an object,
476+
`'Done: ' + record.title` is text with a hole, and since CEL holds a map's
477+
values to one type, a literal or a text beside a field read is wrapped in
478+
`dyn(…)` (`dyn('order.created')`) — or a literal sent as written,
479+
where an envelope nested inside the literal is data. `method`, `durable`,
480+
`timeoutMs` and `signingSecret` are literals, read as written. A single-brace
481+
`{…}` token is refused at every one of them, with its `{{ }}` or CEL spelling,
482+
or for a literal with the value itself; the request is rendered once, before
483+
the call, so a `durable` callout enqueues exactly the request the inline call
484+
would send.
485+
469486
<Callout type="warn" title="Do not put a secret in an http node's url or headers">
470487
A flow definition, including an `http` node's `url` and `headers`, is served to every member who can read flows. A token, API key or signed webhook url written there is readable by all of them. Route an outbound credential by where it sits, and call the connector with a `connector_action` node instead — see [Connectors](/docs/automation/connectors#authentication). A credential in a header goes to a declarative connector with `bearer`, `basic` or `api-key` auth, whose `auth.credentialRef` names the secret. A key in the query string goes to `api-key` auth with `paramName`. No `credentialRef` variant carries a secret in the url path, so an incoming-webhook url (whose path is the secret) cannot be routed that way: call the service through a token-authenticated connector instead, such as the `slack` connector, whose bot token is supplied to the plugin by host code rather than written in the flow (it is registered by that plugin, not declared as a `connectors:` instance). Otherwise such a url is served with the definition. Only `signingSecret` and a start node's `secret` are kept out of a flow saved through the metadata API: the metadata save door stores each in a write-only, encrypted flow credential store, a read never returns it, and the engine reads it only to verify an inbound post or sign an outbound request. A packaged flow keeps its literal in its package source. The literal is withheld when the definition is served, and a credential store row for that flow, where one exists, wins when the engine verifies or signs. `url` and `headers` are stored and served as written.
471488
</Callout>
@@ -2090,11 +2107,12 @@ failures so one broken flow does not abort startup.
20902107

20912108
A flow computes with **one expression dialect, CEL**: every condition is bare
20922109
CEL, and every computed value (a field value, an assignment value) is a CEL
2093-
value envelope. **Text** slots — a `notify` title, message or link, a screen
2094-
title or description, a refusing `end` node's message — are `{{ }}` templates
2110+
value envelope. **Text** slots — a `notify` title, message or link, an `http`
2111+
url and header values, a screen title or description, a refusing `end` node's
2112+
message — are `{{ }}` templates
20952113
(see [text slots](#text-slots-read-double-brace-holes)). Single braces remain
2096-
only on the value-like positions that hand a resolved value over and that the
2097-
retirement has not reached yet (an `http` body, a CRUD `filter` value, …).
2114+
only on the value-like position that hands a resolved value over and that the
2115+
retirement has not reached yet, a CRUD `filter` value.
20982116

20992117
| Where | Dialect | Write it like | Bindings |
21002118
|:---|:---|:---|:---|
@@ -2103,7 +2121,7 @@ retirement has not reached yet (an `http` body, a CRUD `filter` value, …).
21032121
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, `vars.*` and `current_user` |
21042122
| Field values and assignment values, as a **literal** | none — written as it is | `'open'`, `42`, `true`, `['a', 'b']` | — (a `{…}` template token is refused here since [#19939](https://github.com/objectstack-ai/objectstack/issues/19939), `{$User.*}` and the date macros `{NOW()}` / `{TODAY() ± N}` included: a date is `isoDate(today())` / `isoDatetime(now())` in an envelope) |
21052123
| Field values and assignment values, as a **CEL value envelope** | **CEL** (in an envelope) | `{ dialect: 'cel', source: 'round(price * 100.0) / 100.0' }` | flow variables by name, `vars.*` and `current_user` — the whole CEL stdlib (`joinNonEmpty`, …) |
2106-
| Text slots — `notify` `title` / `message` / `actionUrl`, `screen` `title` / `description`, `end` `message` | **template** (`{{ }}` holes) | `'Deal won: {{ record.name }} ({{ record.amount \| currency }})'` | flow variables by name (`$`-named ones too: `{{ $error.message }}`) — a path and an optional formatter, never logic |
2124+
| Text slots — `notify` `title` / `message` / `actionUrl`, `http` `url` and `headers` values, `screen` `title` / `description`, `end` `message` | **template** (`{{ }}` holes) | `'Deal won: {{ record.name }} ({{ record.amount \| currency }})'` | flow variables by name (`$`-named ones too: `{{ $error.message }}`) — a path and an optional formatter, never logic |
21072125

21082126
A value slot takes either form, chosen by shape: an object naming a `dialect`
21092127
is a CEL envelope, everything else is a literal. The `{token}` template dialect
@@ -2135,7 +2153,7 @@ and is read as `vars["current_user"]` / `vars["vars"]`.
21352153
braces.
21362154
3. **A `{…}` template in a value slot** — `total: '{round(x * 100) / 100}'`,
21372155
`owner: '{record.owner}'` — is refused with the CEL spelling to write
2138-
instead. In a single-brace position (an `http` body, a `notify` recipient), an
2156+
instead. In a single-brace position (a CRUD `filter` value), an
21392157
unsupported function in braces — `'{ROUND(x, 2)}'`, `'{Math.round(x)}'`,
21402158
`'{(x).toFixed(2)}'` — fails the node with a **named error** listing the
21412159
supported set and, where one is close, the spelling you meant. Before #11060

0 commit comments

Comments
 (0)