Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/agents-pd12-shim-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,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.
49 changes: 49 additions & 0 deletions .changeset/flow-node-config-alias-graduation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": minor
---

feat(spec,automation): graduate the seven flow-node config key aliases into the conversion layer — the `readAliasedConfig` shim retires with them (#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 as tolerance the spec never declared: one behind the
`readAliasedConfig` deprecation shim (warned, ledgered), six as open-coded
`??` fallbacks (no warning, no ledger, no retirement path). All seven now
graduate into the ADR-0087 D2 conversion layer as protocol-17 **live-window**
entries: a stored flow authored with an alias is rewritten to the canonical
key at load — `defineStack` / `validate` / `lint` and the
`AutomationEngine.registerFlow` rehydration seam alike — with a structured
`ConversionNotice` per rewrite, and the executors read the canonical keys
only. The shim (`service-automation/src/builtin/config-aliases.ts`) is empty
and deleted.

FROM → TO (per node type; conversion entry in parentheses):

- `get_record`/`create_record`/`update_record`/`delete_record`:
`config.object` → `config.objectName` (`flow-node-crud-object-alias`)
- `notify`: `config.to` → `config.recipients`, `config.subject` →
`config.title`, `config.body` → `config.message`, `config.url` →
`config.actionUrl` (`flow-node-notify-config-aliases`)
- `script`: `config.functionName` → `config.function`, `config.input` →
`config.inputs` (`flow-node-script-config-aliases`)

One-line fix: rename the key in your flow source — values are unchanged; `os
migrate meta --from 16` rewrites all seven mechanically. Until then nothing
breaks: the protocol-17 loader accepts and converts the old shape (window
retires in 18).

`actionUrl` (not `url`) is the deliberate canonical of its pair, resolving a
contradiction where the notify descriptor documented `url` as canonical while
the executor, tests, and examples preferred `actionUrl`: the whole downstream
chain already uses that name (`sys_notification.action_url`, the
channel-dispatch contract, the REST notification read model), and `url`
elsewhere in the platform means "HTTP endpoint to call" (`http` node,
webhooks) — a different concept from this in-app click-through target. The
executor precedence already put `actionUrl` first, so the choice is
behaviour-preserving; the `notify` descriptor's `configSchema` now documents
`actionUrl`.

Callers that hand a node config **directly** to an executor (bypassing
`registerFlow`) no longer get alias resolution — build the config with the
canonical keys.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Other scripts: `objectui:bump` (pull only), `objectui:build`, `objectui:clean`.
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.
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**.
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.
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.
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.

---

Expand Down
14 changes: 7 additions & 7 deletions content/docs/getting-started/common-patterns.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,10 @@ export const assignmentNotification = defineFlow({
label: 'Notify Assignee',
config: {
// String fields use single-brace {…} templates, not {{…}}.
// `to` takes recipient USER IDs — a lookup value interpolates to the id.
to: '{record.assigned_to}',
subject: 'Task Assigned: {record.title}',
body: 'You have been assigned to task "{record.title}".'
// `recipients` takes recipient USER IDs — a lookup value interpolates to the id.
recipients: '{record.assigned_to}',
title: 'Task Assigned: {record.title}',
message: 'You have been assigned to task "{record.title}".'
}
},
{ id: 'end', type: 'end', label: 'End' }
Expand Down Expand Up @@ -354,9 +354,9 @@ Define a multi-step approval flow for records.
type: 'notify',
label: 'Request Approval',
config: {
to: '{record.submitted_by}',
subject: 'Expense approval needed: {record.title}',
body: 'Expense "{record.title}" needs manual approval.'
recipients: '{record.submitted_by}',
title: 'Expense approval needed: {record.title}',
message: 'Expense "{record.title}" needs manual approval.'
}
},
{ id: 'end', type: 'end', label: 'End' }
Expand Down
27 changes: 27 additions & 0 deletions content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,33 @@ existed to warn when an author declared both an alias and its canonical key,
which the parse now rejects outright. Delete the import; there is no replacement
because the condition can no longer occur.

### Seven flow-node config key aliases graduate into the conversion layer (#3796)

| Node type(s) | Deprecated | Use instead |
|---|---|---|
| `get_record` / `create_record` / `update_record` / `delete_record` | `config.object` | `config.objectName` |
| `notify` | `config.to` / `config.subject` / `config.body` / `config.url` | `config.recipients` / `config.title` / `config.message` / `config.actionUrl` |
| `script` | `config.functionName` / `config.input` | `config.function` / `config.inputs` |

All are pure key renames with unchanged values. Unlike the three tombstoned
aliases above, these **cannot** be rejected in the schema —
`FlowNodeSchema.config` is an unconstrained record — so they keep a load-path
acceptance window instead: a stored flow authored with an alias keeps loading
through protocol 17, rewritten to the canonical key at load (including
`AutomationEngine.registerFlow` rehydration) with a `ConversionNotice`; the
window retires in 18. The executors read canonical keys only, and the
`readAliasedConfig` executor shim (which covered `object` → `objectName` and
warned at run time) is deleted — the conversion layer is now the single seam
that declares, converts, and retires flow-config aliases.

`os migrate meta --from <your current major>` rewrites all seven in your
source; or rename the keys by hand. `actionUrl` is the deliberate canonical of
its pair: the downstream chain already uses that name
(`sys_notification.action_url`, the channel contract, the REST notification
model), while `url` platform-wide means an HTTP endpoint to call (`http` node,
webhooks). The singular `input` on `map` / `subflow` / `connector_action` is
those nodes' own canonical key and is untouched.

### `agent.tools[]` is removed — capability comes from skills (ADR-0109, #3820)

ADR-0064's invariant is "an agent's tool set is the union of its
Expand Down
5 changes: 5 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ It also removes the sharing-rule access level `full` (#3865): declared as "Full

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`.

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.

### Mechanical (applied for you)

| Conversion | Surface | Change | Load window |
Expand All @@ -131,6 +133,9 @@ Finally it removes agent `tools` (#3894): the legacy inline `{type,name,descript
| `agent-knowledge-topics-to-sources` | `agent.knowledge.topics` | agent knowledge key 'topics' → 'sources' (the deprecated RAG-source alias, #1891) | retired — `migrate meta` only |
| `agent-tools-to-skills` | `agent.tools` | agent key 'tools' removed — declare capability in a skill (ADR-0064, #3894) | retired — `migrate meta` only |
| `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 |
| `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 |
| `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 |
| `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 |

---

Expand Down
4 changes: 3 additions & 1 deletion packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
// not serialized into the artifact — so this is a structural check; the
// runtime verifies the named function is actually registered.)
if (node.type === 'script') {
// `function` is canonical; `functionName` is an accepted alias.
// `function` is canonical; a pre-parse source may still carry the
// `functionName` alias during the protocol-17 window, until the
// 'flow-node-script-config-aliases' conversion (#3796) canonicalizes it.
const fn =
(typeof cfg.function === 'string' ? cfg.function.trim() : '') ||
(typeof cfg.functionName === 'string' ? cfg.functionName.trim() : '');
Expand Down
8 changes: 5 additions & 3 deletions packages/lint/src/validate-readonly-flow-writes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,11 @@ function buildReadonlyIndex(objects: AnyRec[]): Map<string, Map<string, FieldRea

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