Phase 2 Design: Subflows, Error Handling, and Run Records
Status: delivered in 0.2.0b4 (proposed 2026-10-12, released 2026-10-16). Milestone: Phase 2: Subflows and error handling. Issues: #49, #50, #51, #52, #54, #60, #62.
1. Goal
A person should be able to ask an AI the morning after a run, "what failed last night, and where?", and get a correct answer. The AI answers from recorded facts, not guesses. Later the Hub exposes these facts as an MCP server, so any MCP client (Claude Code, Cursor, VS Code, or a local model) can query them.
Phase 2 builds the foundation:
- Flows handle failures in one place (
## On Error,## Finally) instead offallback_step_idon every step. - Every failure is recorded as complete, structured facts, with secrets removed on the worker.
- The Hub stores those facts in tables and views designed to be queried by people and AI, documented in a data dictionary.
- Subflows work like the rest of the bundle: same config, same folder, written in
flow.mdformat.
Out of scope (later phases): the MCP server itself, AI analysis on the Hub, page evidence (accessibility tree, similar elements), full-text and vector search, and logic.try blocks.
2. Decisions
| # | Topic | Decision |
|---|---|---|
| A1 | Flow-level error handling | ## On Error and ## Finally sections in flow.md |
| A1 | Failure records | Complete structured facts (section 6), for morning questions |
| A1 | Secrets | Redacted on the worker before logs, ${error}, or the Hub; pattern redaction is best effort |
| A2 | Retries | max_retries always retries first; on_error decides what happens after the last attempt |
| A3 | Subflow files | flow.call accepts subflows/x.md, compiled in memory on every run; no generated x.json |
| A4 | Subflow context | Local subflows (./subflows/) get the bundle's config and folder; data only through inputs. @shared/ subflows keep their own folder and config, take values through inputs, or receive the caller's config with inherit_config: true |
| 6 | Database | SQLite; hybrid storage (stable, filtered fields as columns, the rest in a JSON details column) |
| 7 | Schema changes | A small built-in migration runner (no Alembic); SQLite WAL mode on |
| 8 | Tables and views | executions (+ details), step_results, failures; views v_runs, v_failures, v_steps; user views in ~/.kinenix/hub_views/; a data dictionary checked by tests |
| - | Loop items | logic.loop gets item_label |
| - | Querying | kinenix hub query (read-only) |
3. flow.md changes
3.1 ## On Error and ## Finally
# TIPH Quotes
## Steps
### 1. Email Start (`email.send`)
...
### 12. Email Complete (`email.send`)
...
## On Error
### 1. Capture Screen (`web.screenshot`)
- **path:** ./output/error.png
### 2. Email Error (`email.send`)
- **subject:** TIPH failed at ${error.step_name}
- **body:** |
${error.message}
Item: ${error.loop.item}
Cause: ${error.root_cause}
- **attachments:** ["./output/error.png"]
## Finally
### 1. Zip Output (`file.zip`)
- **source:** ./output
- **destination:** ./outbox/run.zip
### 2. Close Browser (`web.close`)
Run order:
1. Run Steps.
2. A step fails (after its retries, and its on_error is not continue or fallback):
the remaining Steps are skipped; ${error} is set; On Error runs.
3. Finally runs, whether Steps succeeded or failed.
4. Result: failed if Steps failed, otherwise success. On Error does not turn a failure into a success.
A step that fails in Finally also makes the run fail, and the log says so (decided 2026-10-13); On Error does not run for it.
- A
flow.fail(business error) also runs On Error;${error.type}isBusiness. - A failing step inside On Error or Finally is recorded, and the rest of that section still runs. On Error never triggers itself again.
- A subflow can have its own On Error and Finally. They run inside the subflow, then the failure continues to the caller.
- Steps in these sections are ordinary steps: parameters,
condition, loops, and subflows work the same.
3.2 ${error}
Set when a step fails (including failures handled by on_error: continue), so On Error and later steps can use it:
| Field | Example |
|---|---|
${error.message} | Timeout 30000ms exceeded waiting for role=option[name='HONDA'] |
${error.type} | Technical or Business |
${error.step_id}, ${error.step_name}, ${error.action} | sub_step_3_4, Choose Brand, web.click |
${error.path} | TIPH Quotes > Each Request > process_one > Choose Brand (through subflows) |
${error.subflow} | process_one, or empty |
${error.line} | Line of the step in its flow.md |
${error.loop.item}, ${error.loop.index}, ${error.loop.of} | R007 HONDA HR-V, 7, 12 |
${error.retries} | 2 |
${error.root_cause}, ${error.suggested_fix} | From the existing rule-based diagnosis |
${error.screenshot} | Path of the automatic failure screenshot |
The values are redacted (section 7) before they are stored in ${error}.
3.3 Retries and what happens next (#52)
- **max_retries:** 2
- **retry_interval:** 5
- **on_error:** continue # after 2 more attempts, record the failure and go on
on_error | After the last attempt |
|---|---|
(not set) or fail | The flow fails; On Error runs |
retry | Same as fail (kept for existing flows) |
continue | The failure is recorded (handled = continued) and the flow goes on |
fallback_step_id keeps working; the failure is recorded with handled = fallback. max_retries, retry_interval, and config.error_dir accept ${...} (#52, #54).
3.4 item_label on logic.loop
### 2. Each Request (`logic.loop`)
- **items:** `${requests}`
- **item_var:** `req`
- **item_label:** ${req.request_id} ${req.brand} ${req.model}
Every step result and failure inside the loop records loop.index, loop.of, and loop.item. Without item_label, loop.item is built from the first three scalar fields of the item, redacted.
3.5 Subflows (#49, #60)
### 3. Process One Request (`flow.call`)
- **flow:** ./subflows/process_one.md
- **inputs:** {"req": "${req}"}
### 1. Log In (`flow.call`)
- **flow:** @shared/erp_login.md
- **inputs:** {"login_url": "${config.erp_url}"}
### 2. Use Shared Report (`flow.call`)
- **flow:** @shared/monthly_report.md
- **inherit_config:** true
| Subflow | Folder for relative paths | ${config} | Other caller variables |
|---|---|---|---|
./subflows/x.md (bundle) | The bundle root | The caller's config | Only through inputs |
@shared/x.md | Its own folder | Its own config; the caller's with inherit_config: true | Only through inputs |
.mdsubflows are compiled in memory on every run. Nox.jsonis written. Existing.jsonsubflows still work.kinenix validatefollowsflow.calltargets in the bundle: it checks the subflow, and reportsinputskeys that the subflow does not declare in its## Variables, with a suggestion.- A failure inside a subflow takes one screenshot, in the subflow (#54).
- Subflows no longer write their own log files; the caller's log contains their steps (#62).
3.6 Guarding against older Kinenix versions
Kinenix 0.2.0b3 and older do not know ## On Error and ## Finally. Their parser treats the steps under these headings as ordinary steps, so an old worker would, for example, send the error email on every successful run.
- From 0.2.0b4, a flow may declare the version it needs:
> Requires: kinenix>=0.2.0b4. Versions from 0.2.0b4 refuse to run a flow that needs a newer version, with a clear message.kinenix validatereports a flow that uses On Error, Finally,item_label, orinherit_configwithout this line. - 0.2.0b3 and older cannot be changed. The release notes and
docs/releasing.mdsay: upgrade every worker before deploying a flow that uses On Error or Finally. The Hub dashboard shows each worker's Kinenix version (from heartbeats) to make this visible.
4. Compiled form changes
Flows exist only as flow.md (decided after this design was first written); the structure below is the compiled form that kinenix compile prints and that the runner builds in memory.
{
"name": "TIPH Quotes",
"requires": "kinenix>=0.2.0b4",
"steps": [ ... ],
"on_error_steps": [
{ "id": "on_error_1", "name": "Capture Screen", "action": "web.screenshot", "parameters": { "path": "./output/error.png" } }
],
"finally_steps": [
{ "id": "finally_1", "name": "Close Browser", "action": "web.close", "parameters": {} }
]
}
on_error_stepsandfinally_stepsare optional and default to empty; their steps have the same shape assteps.- Step ids in these sections are
on_error_Nandfinally_N(sub-stepssub_on_error_N_M, ...), so logs show where a step came from. logic.loopparameters gainitem_label;flow.callparameters gaininherit_config.schemas/flow.schema.json,export-md, andkinenix validatecover the new fields.
5. Interpreter behavior
- Each step result records: section (
steps,on_error,finally), path through subflows, loop index,of, and item, retries made, theflow.mdline, and a short redacted output summary. - A failure creates a failure record (section 6) whether it stops the flow, is continued, or goes to a fallback.
- The execution log is written while the flow runs (one JSON line per step) to
logs/<flow>/<date>/<time>_<run_id>.jsonl, and the final JSON log is written at the end as today.${run.id}and${run.log_path}are available to steps, so On Error or Finally can attach the log so far (#62). ${run.id}can be used in paths, for example./output/${run.id}/, and inconfig.error_dir(#54).
6. What is recorded for every failure
| Fact | Source |
|---|---|
| Flow, run id, worker, Kinenix version, time | Run context |
Step id, name, action, flow.md line, section | Step |
| Path through subflows, subflow name | Call stack |
| Loop item (label, index, of) | Enclosing loops |
Retries made, how it was handled (stopped, continued, fallback) | Error handler |
| Error type, exception class, message | Exception |
| Root cause and suggested fix | Existing rule-based diagnosis |
| Screenshot path | Automatic capture |
| Results of the On Error steps (for example, whether the email was sent) | On Error section |
| Steps completed before the failure, items done and failed | Run summary |
7. Redaction on the worker
Built in PR 3; the user-facing description is in logging.md. Applied to ${error}, step output summaries, log files, and everything sent to the Hub. Best effort: pattern matching can miss values or hide too much, so the rule is also to record only what is needed.
| What | How it is found | Replaced by |
|---|---|---|
Values of variables from .env and ${env.*} used by the flow | Exact value match | **** |
Values of keys that look secret (password, passwd, secret, token, api_key, apikey, authorization; cookie and credential only as the whole key name) | Key name, in parameters, config, and outputs | **** |
Text typed into password fields (web.type on type=password) | Field type | **** |
| Thai national ID (13 digits with a valid check digit) | Pattern | [thai-id] |
| Card numbers (13-19 digits with a valid Luhn check) | Pattern | [card] |
| Phone numbers and e-mail addresses | Pattern; on by default, "redact_contacts": false in config turns it off | [phone], [email] |
Fields the user lists in config.redact | Key name | **** |
8. Hub database
8.1 Migrations and WAL
- A
schema_versiontable and an ordered list of SQL steps in the Hub package. At start, the Hub applies the steps it has not applied yet, inside a transaction, and records each one. No new dependency. - Existing
hub.dbfiles are upgraded in place; no data is lost. The first migration adds what Phase 2 needs to an existing database. - SQLite runs in WAL mode (
PRAGMA journal_mode=WAL), so workers can write while people, dashboards, and AI read.
8.2 Tables
Built in PR 5b; the reference for every column, view, and details key is hub_data.md. Hybrid storage: stable fields that are filtered often are columns; everything else is in a JSON details column with a version key "v". New facts are added to details without a migration. A test keeps the allowed details keys, the code, and the data dictionary in line.
executions (exists; one row per run): existing columns, plus details:
{"v": 1, "run_id": "a1b2c3", "kinenix_version": "0.2.0b4", "items_total": 12, "items_done": 9, "items_failed": 2, "report_md": "..."}
step_results (new; one row per executed step, including every loop iteration, subflow steps, and On Error and Finally steps):
| Column | Type | Meaning |
|---|---|---|
id | integer | Row id |
execution_id | text | Run |
seq | integer | Order within the run |
section | text | steps, on_error, finally |
step_id, step_name, action | text | Step |
status | text | success, failed, skipped |
started_at, duration_seconds | datetime, real | Timing |
details | JSON | v, line, path, subflow, loop {index, of, item}, retries, skipped_reason, output (short, redacted) |
failures (new; one row per failure, including failures handled by on_error: continue, so a run that succeeded overall can still report failed items):
| Column | Type | Meaning |
|---|---|---|
id | integer | Row id |
execution_id, step_result_id | text, integer | Run and step |
failed_at | datetime | Time |
flow_name, worker_id | text | Copied from the run for easy filtering |
step_name, action | text | Step |
error_type | text | Business, Technical |
handled | text | stopped, continued, fallback |
message | text | Error message (a column, for future full-text search) |
details | JSON | v, exception, root_cause, suggested_fix, path, line, loop, retries, screenshot, on_error results |
Indexes: failures(failed_at), failures(flow_name, failed_at), step_results(execution_id, seq).
8.3 Views
Views expose JSON fields as columns, so people and AI query one flat view.
| View | Answers |
|---|---|
v_runs | How many runs, success rate, duration per flow and worker |
v_failures | What failed, where, which item, how it was handled (loop_item, retries, step_path from details) |
v_steps | Slowest steps, steps that fail most |
User views: .sql files in ~/.kinenix/hub_views/ (setting KINENIX_HUB_VIEWS_DIR) are applied at Hub start. User view names start with custom_; Kinenix views start with v_, so upgrades never overwrite user views. A .md file with the same name documents a user view. A failing user view is reported with its file name and does not stop the Hub. Views are read-only by nature.
8.4 Data dictionary
docs/hub_data.md documents every view and table: columns, meaning, example values, and the questions each view answers. It is written for people and AI (an MCP tool will return it later). A test fails when a column or details key exists without documentation, or the other way round.
8.5 Run report
Each run gets a short Markdown report in executions.details.report_md, for people, email, and AI:
# TIPH Quotes, 2026-10-12 02:00 (failed)
- Worker: rpi4, Kinenix 0.2.0b4, duration 14 min
- Items: 6 of 12 done, 1 failed, 5 not reached
- Failed: R007 HONDA HR-V at "Choose Brand" (subflow process_one, line 42), option not found after 2 retries
- On Error: screenshot saved, error email sent
9. kinenix hub query
Built in PR 6; the user guide is hub.md, Querying Runs. Read-only queries from the terminal, on the Hub machine (reads hub.db) or against a remote Hub (--url, with the dashboard credentials). The same code will back the MCP tools.
kinenix hub query --list # views and tables, row counts, what they answer
kinenix hub query --describe v_failures # columns and meaning, from the data dictionary
kinenix hub query v_failures --since yesterday # filters: --since --flow --worker --status --filter k=v --limit
kinenix hub query "SELECT step_name, COUNT(*) FROM v_failures GROUP BY step_name"
kinenix hub query v_failures --format json # table (default), json, csv (--output), md; --expand shows details
- The database is opened read-only; one statement per call; a time limit and a default row limit of 50.
10. Compatibility
- Flows without the new sections and fields run as before.
on_error: retrykeeps its meaning.- Workers on 0.2.0b3 or older must be upgraded before they run flows that use the new sections (section 3.6).
- An existing
hub.dbis migrated in place at the first start of the new Hub.
11. Pull requests
| PR | Content | Issues |
|---|---|---|
| 1 | Hub migration runner, WAL, schema_version | (foundation) |
| 2 | Subflow context (config, folder, inherit_config), .md subflows, validate follows subflows, hello gets a subflow example | #49, #60 |
| 3 | Redaction on the worker | (guardrail 1) |
| 4 | ## On Error, ## Finally, ${error}, ${run.id} (and flow, started, status), item_label, retries then on_error, > Requires: | #50, #51, #52 |
| 5a | Worker side: every step result records line, path, subflow, loop item, retries, and failure facts with handled; subflow steps join the main run's log; subflows send no run of their own; live log and ${run.log_path} (moved from PR 4); one screenshot per failure; items from loops with item_label | #54, #62 |
| 5b | Hub side: step_results, failures, executions.details (existing runs are backfilled from raw_log), views, data dictionary and its test, run report, step outputs stored short (500 characters; the full log stays in raw_log) | - |
| 6 | kinenix hub query, user views folder | - |
| 7 | Docs (spec, actions reference, hub guide, quickstart), release 0.2.0b4 (Apache-2.0, upgrade note) | - |