Skip to content

Commit a47ac06

Browse files
os-zhuangclaude
andauthored
feat(spec,automation): graduate the seven flow-node config aliases into the protocol-17 conversion layer (#3796) (#3976)
* feat(spec,automation): graduate the seven flow-node config aliases into the protocol-17 conversion layer (#3796) FlowNodeSchema.config is an unconstrained record, so the executors were the only statement of which config key is canonical — and seven deprecated aliases lived there: object→objectName behind the readAliasedConfig shim, plus six open-coded ?? fallbacks (notify to/subject/body/url, script functionName/input) with no warning, no ledger, and no retirement path. All seven graduate into the ADR-0087 D2 conversion layer as protocol-17 live-window entries (flow-node-crud-object-alias, flow-node-notify-config-aliases, flow-node-script-config-aliases): a stored flow authored with an alias is rewritten to the canonical key at load — normalizeStackInput and the AutomationEngine.registerFlow rehydration seam alike — with a structured ConversionNotice per rewrite. The executors read canonical keys only, and the emptied readAliasedConfig shim is deleted. actionUrl (not url) is the deliberate canonical of its pair, resolving the contradiction where the notify descriptor documented url as canonical while the executor precedence, tests, and examples all preferred actionUrl: the whole downstream chain already uses that name (sys_notification.action_url, the channel contract, the REST read model), and url elsewhere means an HTTP endpoint to call. The choice is behaviour-preserving. Also: step-17 migration chain + regenerated spec-changes.json / protocol-upgrade-guide.md carry the new entries; PD #12 records the endgame; common-patterns.mdx now authors canonical keys; lint comments updated to reference the conversions instead of the deleted shim. Refs #3796, #3713, #3742, #3754, #3795, ADR-0087. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MFnoExtwnGhNDWXNvn7RKN * docs(releases): v17 notes cover the seven flow-node config alias graduations (#3796) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MFnoExtwnGhNDWXNvn7RKN --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eb9230c commit a47ac06

18 files changed

Lines changed: 460 additions & 173 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(agents): PD #12 records the #3796 endgame — all seven flow-node config aliases graduated into the protocol-17 conversion layer and the `readAliasedConfig` shim is deleted; new aliases go straight to conversion entries, never executor shims — releases nothing.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
---
5+
6+
feat(spec,automation): graduate the seven flow-node config key aliases into the conversion layer — the `readAliasedConfig` shim retires with them (#3796)
7+
8+
`FlowNodeSchema.config` is an unconstrained record, so the executors were the
9+
only statement of which config key is canonical — and seven deprecated aliases
10+
lived there as tolerance the spec never declared: one behind the
11+
`readAliasedConfig` deprecation shim (warned, ledgered), six as open-coded
12+
`??` fallbacks (no warning, no ledger, no retirement path). All seven now
13+
graduate into the ADR-0087 D2 conversion layer as protocol-17 **live-window**
14+
entries: a stored flow authored with an alias is rewritten to the canonical
15+
key at load — `defineStack` / `validate` / `lint` and the
16+
`AutomationEngine.registerFlow` rehydration seam alike — with a structured
17+
`ConversionNotice` per rewrite, and the executors read the canonical keys
18+
only. The shim (`service-automation/src/builtin/config-aliases.ts`) is empty
19+
and deleted.
20+
21+
FROM → TO (per node type; conversion entry in parentheses):
22+
23+
- `get_record`/`create_record`/`update_record`/`delete_record`:
24+
`config.object``config.objectName` (`flow-node-crud-object-alias`)
25+
- `notify`: `config.to``config.recipients`, `config.subject`
26+
`config.title`, `config.body``config.message`, `config.url`
27+
`config.actionUrl` (`flow-node-notify-config-aliases`)
28+
- `script`: `config.functionName``config.function`, `config.input`
29+
`config.inputs` (`flow-node-script-config-aliases`)
30+
31+
One-line fix: rename the key in your flow source — values are unchanged; `os
32+
migrate meta --from 16` rewrites all seven mechanically. Until then nothing
33+
breaks: the protocol-17 loader accepts and converts the old shape (window
34+
retires in 18).
35+
36+
`actionUrl` (not `url`) is the deliberate canonical of its pair, resolving a
37+
contradiction where the notify descriptor documented `url` as canonical while
38+
the executor, tests, and examples preferred `actionUrl`: the whole downstream
39+
chain already uses that name (`sys_notification.action_url`, the
40+
channel-dispatch contract, the REST notification read model), and `url`
41+
elsewhere in the platform means "HTTP endpoint to call" (`http` node,
42+
webhooks) — a different concept from this in-app click-through target. The
43+
executor precedence already put `actionUrl` first, so the choice is
44+
behaviour-preserving; the `notify` descriptor's `configSchema` now documents
45+
`actionUrl`.
46+
47+
Callers that hand a node config **directly** to an executor (bypassing
48+
`registerFlow`) no longer get alias resolution — build the config with the
49+
canonical keys.

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Other scripts: `objectui:bump` (pull only), `objectui:build`, `objectui:clean`.
7474
When renaming a legacy var, use `readEnvWithDeprecation('OS_NEW', 'LEGACY')` from `@objectstack/types` (keeps legacy working one release). Third-party exceptions kept as-is: `NODE_ENV`, `HOME`, `OPENAI_API_KEY`, `TURSO_*`, OAuth `*_CLIENT_ID/SECRET`, `RESEND_API_KEY`, `POSTMARK_TOKEN`, `AI_GATEWAY_*`, `SMTP_*`. See #1382.
7575
10. **File issues for out-of-scope findings — don't silently expand scope or leave them buried.** When you hit a bug, gap, or unenforced capability that's unrelated to the current task, or too large to fix in scope, open a GitHub issue (`gh issue create`) with a clear repro/decision and link it from your PR. Corollary: **never advertise or demo a capability the runtime doesn't actually deliver** (declared ≠ enforced) — fix it, trim it, or file an issue, but don't fake coverage. Example: the spec once declared 9 validation-rule types while the write-path validator enforced only 3 (`state_machine`/`script`/`cross_field`); the gap was filed as #1475 rather than demoed in the showcase, then closed by **trimming** what could never be enforced (`unique`/`async`/`custom`) and **implementing** the rest — the spec now declares 6 and `rule-validator.ts` handles all 6. Note how narrow that claim stayed even so: the evaluator was wired into insert and single-id update only, so a bulk `updateMany` silently skipped every rule — a second `declared ≠ enforced` gap one layer down, at the **call site** rather than the `switch`; filed as #3106 and closed by evaluating the bulk match set per row. A `case` label is not enforcement; check the **call site**.
7676
11. **Worktree-first — never edit on the shared `main` checkout.** This repo is edited by **multiple agents at once**; the shared `main` tree has its HEAD switched and reset *under you*, silently clobbering uncommitted work. Before your **first file edit**, you MUST be in a dedicated worktree on a feature branch: `git worktree add ../objectstack-<task> -b <branch> main && cd ../objectstack-<task> && pnpm install`. A PreToolUse hook (`.claude/hooks/guard-main-checkout.sh`) **enforces** this — it blocks `Edit`/`Write`/`NotebookEdit` unless the edited file is in a dedicated **worktree** — a feature branch on the *shared* checkout is **not** enough (it still gets switched under you) — and it checks the **edited file's own repo**, so sibling repos (`objectui`/`cloud`) you touch are covered too (override for a deliberate non-task fix with `OS_ALLOW_MAIN_EDITS=1`). Full playbook below.
77-
12. **Contract-first — fix the metadata, not the runtime.** This is a metadata-driven framework: `packages/spec` is the one contract between metadata *producers* and the runtime/renderers that *consume* it. When a piece of metadata "doesn't work," ask **first**: *is it spec-compliant? is this the long-term-correct direction?* If the metadata is wrong, fix it at the **producer** and **reject it at authoring/publish** (validation / lint) so the error surfaces loudly — do **not** add a lenient alias or `??` fallback in the consumer (a node executor, the REST layer, a renderer) to tolerate off-spec input. A tolerant fallback fossilizes the wrong convention into a second de-facto contract, dilutes the spec, and hides the producer's bug — one strict contract beats N dialects. This is an **internal** contract (we own both ends), so "be liberal in what you accept" (Postel) does **not** apply — that's for untrusted boundaries. Change the **spec** only when the spec itself is genuinely wrong, and then deliberately (edit the Zod schema + migrate), never by accreting consumer-side fallbacks. The `cfg.filter ?? cfg.filters` / `cfg.objectName ?? cfg.object` fallbacks the flow executors once carried are **debt to pay down, not a pattern to copy** — and the way they are being paid down is the pattern to copy. `filters` → `filter` has **graduated** into the ADR-0087 D2 conversion layer (`flow-node-crud-filter-alias`): rewritten to the canonical key at load, including the `AutomationEngine.registerFlow` rehydration seam, so the CRUD executors read `cfg.filter` directly and no consumer-side fallback survives. `object` → `objectName` is mid-retirement behind `readAliasedConfig` (`packages/services/service-automation/src/builtin/config-aliases.ts`) — canonical wins, the alias keeps already-stored flows running, and each one warns once so authors get steered off it — with graduation into the conversion layer as the next step. When you must tolerate an alias at all, put it behind that shim (never a bare `??`) so it is declared, warned, lintable by `graph-lint` and *removable on a schedule*; stragglers still open-coded are tracked in #3796. *Worked example:* an AI-authored `create_record` used `fieldValues` / `today()` / `{{trigger.record.id}}` while the executor reads `fields` / `{TODAY()}` / `{record.id}` → the fix was correcting the authoring skill + a publish-gate lint that rejects the wrong shape (cloud#688), **not** a `cfg.fields ?? cfg.fieldValues` runtime alias (framework#2419, rejected). Strengthens #5.
77+
12. **Contract-first — fix the metadata, not the runtime.** This is a metadata-driven framework: `packages/spec` is the one contract between metadata *producers* and the runtime/renderers that *consume* it. When a piece of metadata "doesn't work," ask **first**: *is it spec-compliant? is this the long-term-correct direction?* If the metadata is wrong, fix it at the **producer** and **reject it at authoring/publish** (validation / lint) so the error surfaces loudly — do **not** add a lenient alias or `??` fallback in the consumer (a node executor, the REST layer, a renderer) to tolerate off-spec input. A tolerant fallback fossilizes the wrong convention into a second de-facto contract, dilutes the spec, and hides the producer's bug — one strict contract beats N dialects. This is an **internal** contract (we own both ends), so "be liberal in what you accept" (Postel) does **not** apply — that's for untrusted boundaries. Change the **spec** only when the spec itself is genuinely wrong, and then deliberately (edit the Zod schema + migrate), never by accreting consumer-side fallbacks. The `cfg.filter ?? cfg.filters` / `cfg.objectName ?? cfg.object` fallbacks the flow executors once carried are **debt to pay down, not a pattern to copy** — and the way they are being paid down is the pattern to copy. `filters` → `filter` has **graduated** into the ADR-0087 D2 conversion layer (`flow-node-crud-filter-alias`): rewritten to the canonical key at load, including the `AutomationEngine.registerFlow` rehydration seam, so the CRUD executors read `cfg.filter` directly and no consumer-side fallback survives. `object` → `objectName` and the six open-coded stragglers #3796 tracked (notify `to`/`subject`/`body`/`url`, script `functionName`/`input`) graduated the same way at protocol 17 (`flow-node-crud-object-alias`, `flow-node-notify-config-aliases`, `flow-node-script-config-aliases`), emptying the `readAliasedConfig` executor shim — deleted with them. When you must tolerate an alias at all, declare it as a conversion-layer entry (never a bare `??`, and no new executor shims) so it is declared, loud, tested, and *removable on a schedule*. *Worked example:* an AI-authored `create_record` used `fieldValues` / `today()` / `{{trigger.record.id}}` while the executor reads `fields` / `{TODAY()}` / `{record.id}` → the fix was correcting the authoring skill + a publish-gate lint that rejects the wrong shape (cloud#688), **not** a `cfg.fields ?? cfg.fieldValues` runtime alias (framework#2419, rejected). Strengthens #5.
7878

7979
---
8080

content/docs/getting-started/common-patterns.mdx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -276,10 +276,10 @@ export const assignmentNotification = defineFlow({
276276
label: 'Notify Assignee',
277277
config: {
278278
// String fields use single-brace {…} templates, not {{…}}.
279-
// `to` takes recipient USER IDs — a lookup value interpolates to the id.
280-
to: '{record.assigned_to}',
281-
subject: 'Task Assigned: {record.title}',
282-
body: 'You have been assigned to task "{record.title}".'
279+
// `recipients` takes recipient USER IDs — a lookup value interpolates to the id.
280+
recipients: '{record.assigned_to}',
281+
title: 'Task Assigned: {record.title}',
282+
message: 'You have been assigned to task "{record.title}".'
283283
}
284284
},
285285
{ id: 'end', type: 'end', label: 'End' }
@@ -354,9 +354,9 @@ Define a multi-step approval flow for records.
354354
type: 'notify',
355355
label: 'Request Approval',
356356
config: {
357-
to: '{record.submitted_by}',
358-
subject: 'Expense approval needed: {record.title}',
359-
body: 'Expense "{record.title}" needs manual approval.'
357+
recipients: '{record.submitted_by}',
358+
title: 'Expense approval needed: {record.title}',
359+
message: 'Expense "{record.title}" needs manual approval.'
360360
}
361361
},
362362
{ id: 'end', type: 'end', label: 'End' }

content/docs/releases/v17.mdx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,33 @@ existed to warn when an author declared both an alias and its canonical key,
257257
which the parse now rejects outright. Delete the import; there is no replacement
258258
because the condition can no longer occur.
259259

260+
### Seven flow-node config key aliases graduate into the conversion layer (#3796)
261+
262+
| Node type(s) | Deprecated | Use instead |
263+
|---|---|---|
264+
| `get_record` / `create_record` / `update_record` / `delete_record` | `config.object` | `config.objectName` |
265+
| `notify` | `config.to` / `config.subject` / `config.body` / `config.url` | `config.recipients` / `config.title` / `config.message` / `config.actionUrl` |
266+
| `script` | `config.functionName` / `config.input` | `config.function` / `config.inputs` |
267+
268+
All are pure key renames with unchanged values. Unlike the three tombstoned
269+
aliases above, these **cannot** be rejected in the schema —
270+
`FlowNodeSchema.config` is an unconstrained record — so they keep a load-path
271+
acceptance window instead: a stored flow authored with an alias keeps loading
272+
through protocol 17, rewritten to the canonical key at load (including
273+
`AutomationEngine.registerFlow` rehydration) with a `ConversionNotice`; the
274+
window retires in 18. The executors read canonical keys only, and the
275+
`readAliasedConfig` executor shim (which covered `object``objectName` and
276+
warned at run time) is deleted — the conversion layer is now the single seam
277+
that declares, converts, and retires flow-config aliases.
278+
279+
`os migrate meta --from <your current major>` rewrites all seven in your
280+
source; or rename the keys by hand. `actionUrl` is the deliberate canonical of
281+
its pair: the downstream chain already uses that name
282+
(`sys_notification.action_url`, the channel contract, the REST notification
283+
model), while `url` platform-wide means an HTTP endpoint to call (`http` node,
284+
webhooks). The singular `input` on `map` / `subflow` / `connector_action` is
285+
those nodes' own canonical key and is untouched.
286+
260287
### `agent.tools[]` is removed — capability comes from skills (ADR-0109, #3820)
261288

262289
ADR-0064's invariant is "an agent's tool set is the union of its

docs/protocol-upgrade-guide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ It also removes the sharing-rule access level `full` (#3865): declared as "Full
122122

123123
Finally it removes agent `tools` (#3894): the legacy inline `{type,name,description}[]` fallback, which the runtime resolved against the FULL tool registry with no surface check — the one seam that broke ADR-0064's "an agent reaches exactly its surface-compatible skills' tools, nothing falls through to the global registry". Unlike the renames above this has NO lossless target: each entry has to become a reference inside a skill, which is a human decision about which skill. The conversion therefore drops the dead key (the runtime stopped reading it in cloud#910, so it already contributes nothing) and emits a notice per agent so the author knows where capability must be re-declared; the schema tombstones the key with a fix-it error naming `skills`.
124124

125+
Beyond those spec-surface removals, it graduates the seven flow-node config key aliases the executors still tolerated (#3796): the CRUD nodes' `object` (use `objectName`) — the last tenant of the `readAliasedConfig` executor shim, which is deleted with it — plus the six open-coded fallbacks that never went through that shim: notify `to`/`subject`/`body`/`url` (use `recipients`/`title`/`message`/`actionUrl`) and script `functionName`/`input` (use `function`/`inputs`). All are pure key renames with unchanged values and replay losslessly. Like the sharing-rule access level above they keep a load-path acceptance window: none carried a prior deprecation warning, and `FlowNodeSchema.config` is an unconstrained record, so no schema tombstone can reject them — the conversion layer is the only seam that can declare, convert, and retire them.
126+
125127
### Mechanical (applied for you)
126128

127129
| Conversion | Surface | Change | Load window |
@@ -131,6 +133,9 @@ Finally it removes agent `tools` (#3894): the legacy inline `{type,name,descript
131133
| `agent-knowledge-topics-to-sources` | `agent.knowledge.topics` | agent knowledge key 'topics' → 'sources' (the deprecated RAG-source alias, #1891) | retired — `migrate meta` only |
132134
| `agent-tools-to-skills` | `agent.tools` | agent key 'tools' removed — declare capability in a skill (ADR-0064, #3894) | retired — `migrate meta` only |
133135
| `sharing-rule-access-level-full-to-edit` | `sharingRule.accessLevel` | sharing-rule accessLevel 'full' → 'edit' (#3865`full` never granted more than `edit`) | live — protocol 17 loader accepts the old shape |
136+
| `flow-node-crud-object-alias` | `flow.node.config.objectName` | CRUD flow-node config key 'object' → 'objectName' (#3796`readAliasedConfig` shim graduation) | live — protocol 17 loader accepts the old shape |
137+
| `flow-node-notify-config-aliases` | `flow.node.notify.config` | notify flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' → 'message', 'url' → 'actionUrl' (#3796) | live — protocol 17 loader accepts the old shape |
138+
| `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape |
134139

135140
---
136141

packages/lint/src/validate-expressions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,9 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
130130
// not serialized into the artifact — so this is a structural check; the
131131
// runtime verifies the named function is actually registered.)
132132
if (node.type === 'script') {
133-
// `function` is canonical; `functionName` is an accepted alias.
133+
// `function` is canonical; a pre-parse source may still carry the
134+
// `functionName` alias during the protocol-17 window, until the
135+
// 'flow-node-script-config-aliases' conversion (#3796) canonicalizes it.
134136
const fn =
135137
(typeof cfg.function === 'string' ? cfg.function.trim() : '') ||
136138
(typeof cfg.functionName === 'string' ? cfg.functionName.trim() : '');

packages/lint/src/validate-readonly-flow-writes.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,11 @@ function buildReadonlyIndex(objects: AnyRec[]): Map<string, Map<string, FieldRea
103103

104104
/**
105105
* The target object of an `update_record` node, when statically knowable. Reads
106-
* the canonical `objectName` and its historical `object` alias (the same pair
107-
* `readAliasedConfig` resolves at run time). A templated value (contains `{`) is
108-
* dynamic — return undefined so the node is skipped rather than guessed.
106+
* the canonical `objectName` and its historical `object` alias — a pre-parse
107+
* source may still carry the alias during the protocol-17 window, until the
108+
* 'flow-node-crud-object-alias' conversion (#3796) canonicalizes it at load. A
109+
* templated value (contains `{`) is dynamic — return undefined so the node is
110+
* skipped rather than guessed.
109111
*/
110112
function readLiteralObjectName(config: AnyRec): string | undefined {
111113
const raw = config.objectName ?? config.object;

0 commit comments

Comments
 (0)