Skip to content
Closed
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,29 @@ called out explicitly under **Changed** or **Removed**.

## [Unreleased]

### Added

- **Automation specs can author loops.** A spec names the array variable,
its item variable, a `loop` step with `step_loop: {array_variable,
item_variable}`, the body, a `next_or_finish` closer and any `exit_loop`
steps, and `create`, `update` and `diff` handle them like any other step.
A loop is always sent as a branch group initiator. `diff` no longer
reports the keys Kizen adds to a loop's item variable. The plan preview
no longer warns about a fan-out on every loop. The plan refuses a loop
nested more than three deep, or one over the same array as a loop around
it, as Kizen's builder does. `kizen docs show automation` has a worked
example.

### Fixed

- **`automations diff` no longer reports Kizen's defaults as changes when a
spec leaves them out.** An omitted `error_notification_email`,
`deduplicate_array`, `array_separator` (on a static list source),
`value_html` (on an `update_variable` source), or `description` on the
steps and trigger Kizen gives a fixed one (`kizen docs show automation`
lists them) is compared as the value Kizen stores for it. So is a null
key in a top-level or `initialize_variable` variable definition. A spec
that sets a different value still shows the change.
- **CLI writes no longer fail on automations with loops, and `show` renders
loops.** `activate`, `deactivate`, `move`, `steps add|edit|remove` and
`roundtrip --execute` failed with `HTTP 400: … step_loop must be provided
Expand Down
122 changes: 105 additions & 17 deletions src/kizen_builder/docs/specs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,18 +112,11 @@ branches) and 2026-09-29 (goals).
`merge_branches` child to rejoin them. Lanes can be `branch` steps themselves,
merged or not, and a `branch` can follow a merge. Put a `branch` step in front
of any fan-out: a plain step with two or more children still saves, but the
plan preview warns about it. Kizen's 2026-09-28 migration inserted a `branch`
step ahead of every existing fan-out, and an `update` from a spec without one
removes it again. Confirmed live 2026-09-28.

**Loops** are initiators too. A `loop` step's body hangs off the loop, its one
`next_or_finish` child closes the body, and the steps after the loop hang off
the `next_or_finish`. `exit_loop` leaves the loop from inside the body.
`next_or_finish` and `exit_loop` take no config block. `step_loop` names an
array variable of records, strings or UUIDs and a separate item variable, each
declared by an `initialize_variable` step. The item variable is a single
variable of the array's type. `--dry-run` checks loops against
the server's rules too. Confirmed live 2026-10-27.
plan preview warns about it. A group's closer (`merge_branches` or
`next_or_finish`) isn't a lane, so it doesn't count toward that warning.
Kizen's 2026-09-28 migration inserted a `branch` step ahead of every existing
fan-out, and an `update` from a spec without one removes it again. Confirmed
live 2026-09-28.

**Skipping a condition or goal** (`should_skip_execution: true`) needs
`continue_with_branch: "yes"` or `"no"`, the branch the run takes past it. The
Expand All @@ -141,6 +134,87 @@ sets a step's own severity. Omitted, the step inherits. Confirmed live
(`account_region`), *not* object-qualified — it's the filter DSL, same as
`records list --filter` and saved views.

### Loops

A loop runs its body once per item of an array variable. Loops are branch
group initiators, closed by `next_or_finish` instead of `merge_branches`. A
spec names all six parts; the CLI adds none of them:

1. **The array variable:** an `initialize_variable` step with `is_array: true`.
2. **Its item variable:** an `initialize_variable` step with `sources: []`,
declaring a single variable of the array's type (for records, the same
`data_subtype`). It needs no sources, though the server accepts one. Each
loop needs its own.
3. **The `loop` step.** `step_loop` names both variables by **name**; their ids
are a 400. The CLI always sends a loop as `is_branch_group_initiator: true`,
because the server stores true even when false is sent.
4. **The body**, hanging off the loop.
5. **One `next_or_finish` child of the loop**, which closes the body. The steps
after the loop hang off it.
6. **Optional `exit_loop` steps** inside the body.

Both variable steps go at the front of the automation, like every
`initialize_variable`. Confirmed live 2026-10-27.

```json
{ "name": "Visit every account", "api_name": "visit_every_account",
"type": "global",
"steps": [
{ "key": "accounts", "parent_key": null, "step_type": "initialize_variable",
"order": 0, "description": "accounts = []",
"action_initialize_variable": { "sources": [], "array_aggregation_mode": "add_all",
"variable": { "name": "accounts", "data_type": "entity", "is_array": true,
"data_subtype": "<accounts object uuid>" } } },
{ "key": "status", "parent_key": "accounts", "step_type": "initialize_variable",
"order": 1, "action_initialize_variable": { "sources": [],
"variable": { "name": "status", "data_type": "string" } } },
{ "key": "account", "parent_key": "status", "step_type": "initialize_variable",
"order": 2, "description": "Item Reference Variable",
"action_initialize_variable": { "sources": [],
"variable": { "name": "accounts_item", "data_type": "entity",
"data_subtype": "<accounts object uuid>" } } },
{ "key": "find", "parent_key": "account", "step_type": "search_records",
"order": 3, "action_search_records": { "custom_object": "accounts",
"filter_type": "all_records", "destination_variable": "accounts" } },
{ "key": "each", "parent_key": "find", "step_type": "loop", "order": 4,
"description": "For each item in 'accounts'",
"step_loop": { "array_variable": "accounts", "item_variable": "accounts_item" } },
{ "key": "visit", "parent_key": "each", "step_type": "update_variable", "order": 5,
"action_update_variable": { "variable": { "name": "status", "data_type": "string" },
"sources": [ { "source_type": "static", "source_subtype": "string",
"value": "visited" } ] } },
{ "key": "next", "parent_key": "each", "step_type": "next_or_finish", "order": 6 },
{ "key": "done", "parent_key": "next", "step_type": "update_variable", "order": 7,
"action_update_variable": { "variable": { "name": "status", "data_type": "string" },
"sources": [ { "source_type": "static", "source_subtype": "string",
"value": "done" } ] } }
] }
```

- **Array types:** strings, UUIDs and records (`data_type` `string`, `uuid` or
`entity`). Kizen rejects an array of any other type at the
`initialize_variable` step. A `search_records` destination can be the loop's
array, as above. Confirmed live 2026-10-27.
- **Descriptions:** a CLI-created loop reads back as "Loop" and its item step as
"Initialize Variable". The UI writes "For each item in '<array>'" and "Item
Reference Variable". Set `description` to match the UI. Confirmed live
2026-10-27.
- **`diff`** ignores `is_array_item_variable` and `source_array_variable`, which
the server adds to the item variable from the loop. It reads a null key in a
top-level or `initialize_variable` variable definition as absent. Confirmed
live 2026-10-27.
- **`exit_loop`** ends the innermost loop it sits in, and an outer loop carries
on with its next item. It can be the loop's direct child, sit under a
condition in the body, or follow an inner loop inside an outer body. After
the loop's `next_or_finish` it is a 400. Confirmed live 2026-10-27.
- **Body lanes:** a body can be empty or have two lanes. Two lanes get the
fan-out warning, like any plain fan-out. Confirmed live 2026-10-27.
- **Nesting:** a loop can sit in another loop's body, up to three levels, and a
nested loop needs a different array from every loop around it. Kizen's
builder refuses both, so the CLI refuses them at plan time (`--dry-run` shows
it). The API accepts both: six levels, and one array at two levels, each
confirmed live 2026-10-27.

---

## Wired trigger types (11)
Expand Down Expand Up @@ -202,8 +276,8 @@ to wait for a reply. Its `wait_type` is `never`, `delay` or
| `search_records` | `action_search_records` | search an object, write results into an array variable |
| `branch` | none | run its children in parallel — see Branch groups |
| `merge_branches` | none | rejoin a branch group — see Branch groups |
| `loop` | `step_loop` | run its body once per item of an array variable — see Branch groups |
| `next_or_finish` | none | close a loop body — see Branch groups |
| `loop` | `step_loop` | run its body once per item of an array variable — see Loops |
| `next_or_finish` | none | close a loop body — see Loops |
| `exit_loop` | none | leave the loop from inside its body |

`search_records` (the automation-builder equivalent of `records list
Expand Down Expand Up @@ -896,6 +970,18 @@ changed field is identified by that octet alone; an added or removed
step/trigger carries the whole step/trigger, full `id` included, in its
`--json` leaf value.

A key the spec leaves out compares as the value Kizen stores when it's
omitted, so a spec that leaves out a default shows no change. A non-default
value on either side still shows. Confirmed live 2026-10-27:

| Key | Omitted reads back as |
|---|---|
| `error_notification_email` | `''` |
| `deduplicate_array` on `initialize_variable`/`update_variable` | `false` |
| `array_separator` on a static source whose `value` is a list | `'comma'` |
| `value_html` on an `update_variable` source | `''` |
| `description` on a step or trigger | a fixed text per type: `Initialize Variable`, `Update Variable`, `Action: Search Records`, `Loop`, `Next/Finish`, `Exit Loop`, `Condition`, `Branch`, `Merge Branches`, `Delay`, `Stop Execution`, and `Manual` for the manual trigger. It is written on create and on a PUT that omits it, so an update from a spec without one replaces a description set in the UI. Other types compare as sent |

### Read→write transforms

Each of these was discovered via a live 400, 500, or silent data loss:
Expand All @@ -914,12 +1000,12 @@ Each of these was discovered via a live 400, 500, or silent data loss:
| Step/trigger orders with gaps or nulls | sequential 0..N |
| `change_field_value.fields_to_clear` (expanded field objects) | bare field UUIDs |
| `start_automation.automation_variable_overrides` — flat list, every ref expanded (`target_automation`, `variable_to_override`, plus a `value_source`-specific ref) | grouped by target automation id: `[{automation_id, variable_overrides: [...]}]`, and the unwrapping is non-uniform — `context_entity_field` → bare UUID, `variable_to_override`/`automation_variable` → bare **name**, `specific_value` → passthrough (already a bare scalar on read), `blank` → no key at all |
| `is_branch_group_initiator` on every step | echoed as read, default `false`. Dropped, every merge step 400s: "Merge step's parent (…) must be a branch group initiator step." Confirmed live 2026-09-28 |
| `is_branch_group_initiator` on every step | echoed as read, default `false`, and always `true` on a loop (see Loops). Dropped, every merge step 400s: "Merge step's parent (…) must be a branch group initiator step." Confirmed live 2026-09-28 |
| `continue_with_branch`, present only when set | echoed when non-null. A condition or goal with `should_skip_execution: true` 400s without it. Confirmed live 2026-09-28 |
| `error_notification_severity_level` on every step | echoed as read. Omitted, the step is silently reset to `inherit`. Confirmed live 2026-09-28 |
| `branch` and `merge_branches` steps, read with no config block | no `action_*` block written. Confirmed live 2026-09-28 |
| `step_loop` with `array_variable`/`item_variable` as variable definitions | bare variable **names**; their ids are a 400 ("The loop must iterate an array variable…"). `next_or_finish` and `exit_loop` are read with no config block, and none is written. Confirmed live 2026-10-27 |
| A loop's item variable read with `is_array_item_variable: true` and `source_array_variable: <array>` | echoed as read. The server sets both itself from the loop, so sending them is harmless and omitting them changes nothing. Confirmed live 2026-10-27 |
| A loop's item variable read with `is_array_item_variable: true` and `source_array_variable: <array>` | echoed as read. The server sets both itself from the loop, so sending them is harmless and omitting them changes nothing. `diff` ignores both. Confirmed live 2026-10-27 |
| Extraction/transcription `input_field` plus its mirrored `context_entity_field` `input_value_source` | `input_field` only — see [Extraction input](#extraction-input) |
| `modify_related_entities.automation_variables` (expanded variable definitions) and the block's own `id` | not sent; `record_source_variables` (bare names) is the write key |

Expand Down Expand Up @@ -964,7 +1050,9 @@ inconsistent:
variable of the array's type (and, for records, the same object), and no
two loops share one.

There is no nesting limit (6 deep saves) and no limit on body lanes.
The server has no nesting limit (6 deep saves) and no limit on body lanes.
The CLI adds two rules of its own, from Kizen's builder: at most three
levels, and no loop over the same array as a loop around it (see Loops).

### Unlisted step types

Expand Down
3 changes: 2 additions & 1 deletion src/kizen_builder/models/spec/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ class AutomationStepDef(BaseModel):
description=(
"Set on a `branch`, `condition` or `goal` step whose lanes rejoin. "
"The step then needs exactly one `merge_branches` child, and steps "
"after the merge hang off that merge step."
"after the merge hang off that merge step. A `loop` is always sent "
"as an initiator, whatever this says."
),
)
continue_with_branch: Literal["yes", "no"] | None = Field(
Expand Down
14 changes: 12 additions & 2 deletions src/kizen_builder/tools/planners/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,12 +585,20 @@ def _automation_preview(
}


# A group's closer is a child of its initiator, not a parallel lane.
_CLOSER_STEP_TYPES = frozenset({"merge_branches", "next_or_finish"})


def _fan_out_warnings(auto: AutomationDef) -> dict[str, Any]:
"""Parallel lanes belong under a `branch` step. A plain fan-out still
saves, but Kizen's 2026-09-28 migration put a `branch` step in front of
every one, and an update from a spec without it removes it again."""
lanes = Counter(
s.parent_key for s in auto.steps if s.parent_key and s.parent_branch is None
s.parent_key
for s in auto.steps
if s.parent_key
and s.parent_branch is None
and s.step_type not in _CLOSER_STEP_TYPES
)
types = {s.key: s.step_type for s in auto.steps}
warnings = [
Expand Down Expand Up @@ -1220,7 +1228,9 @@ def _build_step_payload(
if step.description:
p["description"] = step.description
# Emitted only when set, so a spec that predates them builds unchanged.
if step.is_branch_group_initiator:
# A loop is always an initiator: the server stores true even when false
# is sent (confirmed live 2026-10-27).
if step.is_branch_group_initiator or step.step_type == "loop":
p["is_branch_group_initiator"] = True
if step.continue_with_branch is not None:
p["continue_with_branch"] = step.continue_with_branch
Expand Down
Loading
Loading