From 0510fae895f1ba4c525011963360f1ec99d8edef Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 8 Sep 2026 08:45:13 +0200 Subject: [PATCH 1/8] docs(schema): the legacy workflow schema is a fork, not a stale file ops/RUNTIME-STATUS.md records that the local SDK refuses the old swarm/workflows schema, which reads like a migration chore. It is not. Two runtimes consume these files and they disagree. The local SDK refuses 1.0 outright (five errors, rc=2). Cloud accepts it: on 2026-09-07 the review gate ran `agent-relay cloud run workflows/review-swarm.yaml` against the unmodified 1.0 file, got run 04da7e48 and sandbox b5f3b344, and failed later on Daytona quota rather than on schema. Three of the seven legacy files are consumed by cloud right now - review-swarm.yaml on every PR, watchdog.yaml as the live `flows-watchdog` schedule, drive.yaml via `agent-relay cloud schedule`. Migrating those would make them parse locally and might make them unrunnable in cloud, taking out the review gate and the watchdog together. So this PR migrates nothing. It provides the tool and the analysis, and leaves the question that has to be answered first: does cloud accept 0.1.0? The migrator refuses rather than guesses, because a migration that silently drops a field is worse than one that fails - the flow runs, looks fine, and means something else. Verified against the real SDK: all seven convert and compile. Losses are recorded as comments in each output rather than found later as bugs, and the one judgement call is documented - a named agent reference becomes the step's `cli`, since 0.1.0 requires a `model` the legacy schema never carried and inventing one is exactly the guess this refuses to make. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- ops/schema-migration/README.md | 66 +++++++++ .../migrate-legacy-workflow.py | 128 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 ops/schema-migration/README.md create mode 100644 ops/schema-migration/migrate-legacy-workflow.py diff --git a/ops/schema-migration/README.md b/ops/schema-migration/README.md new file mode 100644 index 000000000..3fef8dafc --- /dev/null +++ b/ops/schema-migration/README.md @@ -0,0 +1,66 @@ +# The legacy workflow schema is a fork, not a stale file + +`ops/RUNTIME-STATUS.md` records that the local SDK "refuses its old +`swarm`/`workflows` schema before execution". That is true, and it reads like a +migration chore. It is not. **Two runtimes consume these files and they speak +different schema versions.** + +## The evidence + +The local SDK refuses the 1.0 schema outright: + +``` +$ node scripts/run-local-workflow.mjs workflows/drive.yaml # rc=2 +spec: unknown key "swarm" (expected one of version|name|description|cli|agents|triggers|steps|budget) +spec: unknown key "workflows" +spec.version: unsupported version "1.0" (expected "0.1.0") +spec.agents: expected a map of named { cli, model } declarations +spec.steps: expected a non-empty array +``` + +Cloud accepts it. On 2026-09-07 the review gate ran +`agent-relay cloud run workflows/review-swarm.yaml --sync-code --json` against +the unmodified 1.0 file; it returned run `04da7e48-87ec-4c7a-a1ee-22fd482e1cd1` +and was given sandbox `b5f3b344-64cc-434d-97f8-f5da71ba4517`. It failed later, +on Daytona CPU quota — not on schema. + +## Why this matters before anyone migrates + +Three of the seven legacy files are consumed by cloud **right now**: + +| File | Consumer | +|---|---| +| `workflows/review-swarm.yaml` | `.github/workflows/review-swarm.yml:145`, every PR | +| `workflows/watchdog.yaml` | live cloud schedule `flows-watchdog`, cron `0 8 * * *`, active | +| `workflows/drive.yaml` | registered via `agent-relay cloud schedule` (see `ops/AUTONOMY.md`) | + +Migrating those to 0.1.0 makes them parse locally and may make them +unrunnable in cloud, which would take out the review gate and the watchdog +together. **Nothing here migrates a live file.** The question that has to be +answered first is simple and I could not answer it from this machine: *does +cloud accept 0.1.0?* + +## What this directory provides + +`migrate-legacy-workflow.py` converts one legacy file and **refuses rather than +guesses**. A migration that silently drops a field is worse than one that +fails: the flow runs, looks fine, and means something else. Verified against +the real SDK — all seven files convert and compile. + +The conversion is not lossless, and the losses are recorded as comments in +every output file rather than discovered later as bugs: + +- `swarm: {pattern, channel, timeoutMs}` — 0.1.0 has no run-level slot. +- agent `preset` — persona surface (RFC-0001 decision 9), not flow spec. +- agent `timeoutMs` — 0.1.0 bounds deterministic steps only. +- a named agent reference becomes the step's `cli`. 0.1.0 requires `model` in a + named declaration and the legacy schema never carried one; rather than invent + a model, the agent's `cli` is carried onto the step. Verified: an agent step + with `cli` and no agents map compiles; an agents map with `cli` and no + `model` is refused. + +Structural changes that are lossless: `version` 1.0 → 0.1.0, step `name` → `id`, +agent step `task` → `instruction`, and `workflows[0].steps` lifted to top level. +Every legacy file in this repo declares exactly one workflow, so that lift is a +lift and not a split — the script refuses a multi-workflow file rather than +guessing how to divide it. diff --git a/ops/schema-migration/migrate-legacy-workflow.py b/ops/schema-migration/migrate-legacy-workflow.py new file mode 100644 index 000000000..b50425f3e --- /dev/null +++ b/ops/schema-migration/migrate-legacy-workflow.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Migrate a legacy 1.0 relayflow YAML to the 0.1.0 authoring schema. + +The 1.0 schema wrapped a swarm definition around one or more named workflows. +0.1.0 is a single flow: one top-level `steps` array, agents as a map of named +{cli, model} declarations. Every legacy file in this repo declares exactly one +workflow, so the structural change is a lift, not a split. + +This script REFUSES rather than guesses. Anything it cannot place is reported +and the file is not written, because a migration that silently drops a field is +worse than one that fails: the flow would run, look fine, and mean something +different. What legitimately has no home in 0.1.0 is listed in +ops/schema-migration/README.md and carried into the migrated file as comments, +so the loss stays visible to the next reader. +""" +import sys, yaml + +FLOW = {'version', 'name', 'description', 'cli', 'agents', 'triggers', 'steps', 'budget'} +AGENT_DECL = {'cli', 'model'} +STEP_COMMON = {'id', 'type', 'dependsOn', 'verification', 'maxIterations', 'memory', 'requirements'} +STEP_BY_TYPE = { + 'deterministic': {'command', 'timeoutMs'}, + 'llm': {'prompt', 'model', 'cli', 'output'}, + 'agent': {'instruction', 'agent', 'cli', 'model', 'surfaces', 'recoveryMode', 'permissions', 'output'}, +} +# Legacy keys we drop on purpose, with the reason recorded in the output file. +DROPPED_TOP = {'swarm'} +DROPPED_AGENT = {'preset', 'role'} # persona surface (RFC decision 9) +DROPPED_STEP = {('agent', 'timeoutMs')} # 0.1.0 bounds deterministic steps only + + +def migrate(path): + raw = yaml.safe_load(open(path)) + problems, notes = [], [] + + unknown_top = set(raw) - FLOW - DROPPED_TOP - {'workflows'} + if unknown_top: + problems.append(f"unhandled top-level keys: {sorted(unknown_top)}") + + workflows = raw.get('workflows') or [] + if len(workflows) != 1: + problems.append(f"expected exactly one workflow, found {len(workflows)}; " + "a multi-workflow file must be split by hand, not guessed") + + if 'swarm' in raw: + s = raw['swarm'] + notes.append(f"legacy swarm dropped: {s} - 0.1.0 has no run-level " + "pattern/channel/timeout slot") + + agents, roles = {}, {} + for a in raw.get('agents') or []: + name = a.get('name') + if not name: + problems.append(f"agent declaration without a name: {a}") + continue + decl = {k: v for k, v in a.items() if k in AGENT_DECL} + leftover = set(a) - AGENT_DECL - DROPPED_AGENT - {'name'} + if leftover: + problems.append(f"agent {name}: unhandled fields {sorted(leftover)}") + if a.get('role'): + roles[name] = a['role'] + if a.get('preset'): + notes.append(f"agent {name}: preset '{a['preset']}' dropped - " + "presets are persona surface (RFC decision 9)") + agents[name] = decl + + steps = [] + for s in (workflows[0].get('steps') if workflows else []) or []: + t = s.get('type') + out = {'id': s.get('name'), 'type': t} + if not out['id']: + problems.append(f"step without a name: {s}") + allowed = STEP_COMMON | STEP_BY_TYPE.get(t, set()) + for k, v in s.items(): + if k == 'name': + continue + if k == 'task' and t == 'agent': + # An agent's role described who it was; the 0.1.0 step carries + # the whole contract, so the role is prepended rather than lost. + role = roles.get(s.get('agent')) + out['instruction'] = f"{role}\n\n{v}" if role else v + elif k == 'agent' and t == 'agent': + # 0.1.0 requires `model` in a named agent declaration and the + # legacy schema never carried one. Rather than invent a model, + # carry the agent's `cli` onto the step, which is the same + # information the legacy file actually held. Verified against + # the SDK: an agent step with `cli` and no agents map compiles; + # an agents map with `cli` and no `model` is refused. + cli = (agents.get(v) or {}).get('cli') + if not cli: + problems.append(f"step {out['id']}: agent '{v}' has no cli to carry") + else: + out['cli'] = cli + notes.append(f"step {out['id']}: agent reference '{v}' became cli " + f"'{cli}' - 0.1.0 named declarations require a model " + "the legacy file never had") + elif k in allowed: + out[k] = v + elif (t, k) in DROPPED_STEP: + notes.append(f"step {out['id']}: {k}={v} dropped - " + "0.1.0 bounds deterministic steps only") + else: + problems.append(f"step {out['id']}: unhandled field '{k}' for type {t}") + steps.append(out) + + if problems: + return None, problems, notes + return {'version': '0.1.0', 'name': raw.get('name'), + 'description': raw.get('description'), + 'steps': steps}, [], notes + + +if __name__ == '__main__': + src = sys.argv[1] + spec, problems, notes = migrate(src) + if problems: + print(f"REFUSED {src}") + for p in problems: + print(f" - {p}") + raise SystemExit(1) + header = [f"# Migrated from the legacy 1.0 schema by " + f"ops/schema-migration/migrate-legacy-workflow.py.", + "# Deliberate losses, recorded so they are not rediscovered as bugs:"] + header += [f"# - {n}" for n in notes] or ["# (none)"] + out = "\n".join(header) + "\n" + yaml.safe_dump(spec, sort_keys=False, width=100) + dest = sys.argv[2] if len(sys.argv) > 2 else src + open(dest, 'w').write(out) + print(f"MIGRATED {src} -> {dest} ({len(spec['steps'])} steps, {len(notes)} recorded losses)") From e37e035b1a13a7854ea02723f4139ba4e1b76060 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 8 Sep 2026 09:24:50 +0200 Subject: [PATCH 2/8] fix(schema-migration): carry every supported field, refuse unknown source versions Three cubic findings on #238, all on the migrator I wrote to prevent exactly this class of bug. P1 is the one that matters. `cli`, `triggers` and `budget` are valid 0.1.0 fields, so they passed the unknown-key check and were then dropped, because the return statement emitted a fixed four-key dict. A file declaring a global CLI, a trigger registration or a budget limit would have migrated cleanly and silently lost it - the precise failure this script's own docstring refuses to commit. Absent optionals were also written as explicit nulls, which is not a valid value for them. Output is now built conditionally from what the source set. P2: the source version was never checked, so a 0.2.0 file would have been restamped 0.1.0 and migrated by guesswork. Non-1.0 input is refused. P3: the README presented a reformatted multi-line list as a verbatim transcript. The script emits one JSON diagnostic on stderr and exits 2. Replaced with the literal captured output and a note about the earlier paraphrase, per the repo's evidence rule. Verified: 0.2.0 input refused rc=1; cli and budget survive and an absent description is omitted rather than nulled; all seven legacy files still convert and compile; nothing under workflows/ is touched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- ops/schema-migration/README.md | 13 ++++++----- .../migrate-legacy-workflow.py | 22 ++++++++++++++++--- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/ops/schema-migration/README.md b/ops/schema-migration/README.md index 3fef8dafc..6dcd61689 100644 --- a/ops/schema-migration/README.md +++ b/ops/schema-migration/README.md @@ -9,13 +9,14 @@ different schema versions.** The local SDK refuses the 1.0 schema outright: +Literal, captured 2026-09-08. The script emits one JSON diagnostic on stderr +and exits 2; earlier revisions of this file reformatted it into a readable +list, which made a paraphrase look like a transcript. + ``` -$ node scripts/run-local-workflow.mjs workflows/drive.yaml # rc=2 -spec: unknown key "swarm" (expected one of version|name|description|cli|agents|triggers|steps|budget) -spec: unknown key "workflows" -spec.version: unsupported version "1.0" (expected "0.1.0") -spec.agents: expected a map of named { cli, model } declarations -spec.steps: expected a non-empty array +$ node scripts/run-local-workflow.mjs workflows/drive.yaml; echo "rc=$?" +{"severity":"refusal","kind":"invalid_spec","message":"Relayflow spec is invalid: spec: unknown key \"swarm\" (expected one of version | name | description | cli | agents | triggers | steps | budget); spec: unknown key \"workflows\" (expected one of version | name | description | cli | agents | triggers | steps | budget); spec.version: unsupported version \"1.0\" (expected \"0.1.0\"); spec.agents: expected a map of named { cli, model } declarations; spec.steps: expected a non-empty array","errors":["spec: unknown key \"swarm\" (expected one of version | name | description | cli | agents | triggers | steps | budget)","spec: unknown key \"workflows\" (expected one of version | name | description | cli | agents | triggers | steps | budget)","spec.version: unsupported version \"1.0\" (expected \"0.1.0\")","spec.agents: expected a map of named { cli, model } declarations","spec.steps: expected a non-empty array"]} +rc=2 ``` Cloud accepts it. On 2026-09-07 the review gate ran diff --git a/ops/schema-migration/migrate-legacy-workflow.py b/ops/schema-migration/migrate-legacy-workflow.py index b50425f3e..2e9ca79e0 100644 --- a/ops/schema-migration/migrate-legacy-workflow.py +++ b/ops/schema-migration/migrate-legacy-workflow.py @@ -33,6 +33,13 @@ def migrate(path): raw = yaml.safe_load(open(path)) problems, notes = [], [] + # Refuse a schema this tool was not written for. Without this, a 0.2.0 file + # would be silently restamped as 0.1.0 and "migrated" by guesswork. + src_version = str(raw.get('version')) + if src_version != '1.0': + problems.append(f"source version is {src_version!r}, expected '1.0'; " + "this tool migrates the 1.0 schema only") + unknown_top = set(raw) - FLOW - DROPPED_TOP - {'workflows'} if unknown_top: problems.append(f"unhandled top-level keys: {sorted(unknown_top)}") @@ -105,9 +112,18 @@ def migrate(path): if problems: return None, problems, notes - return {'version': '0.1.0', 'name': raw.get('name'), - 'description': raw.get('description'), - 'steps': steps}, [], notes + # Carry every 0.1.0 field the source actually set. `cli`, `triggers` and + # `budget` pass the unknown-key check above because they ARE valid 0.1.0 + # fields; emitting a fixed dict dropped them silently, which is the exact + # failure this script's docstring refuses to commit. Absent optionals are + # omitted rather than written as null, because an explicit null is not a + # valid value for them. + out = {'version': '0.1.0'} + for key in ('name', 'description', 'cli', 'triggers', 'budget'): + if raw.get(key) is not None: + out[key] = raw[key] + out['steps'] = steps + return out, [], notes if __name__ == '__main__': From 2720f6216e0cbdeef49e44795cd0e35c90159943 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 8 Sep 2026 13:24:20 +0200 Subject: [PATCH 3/8] fix(schema-migration): refuse on version mismatch, and never treat null as absent Two cubic findings on #238. The version guard recorded a mismatch and then continued into the legacy loops. A future-version file using the 0.1.0-style `agents` MAP would reach `a.get(...)` and raise a traceback instead of the clean refusal this script promises. It now returns immediately. An explicitly null top-level field was treated as absent and silently dropped. An absent key and a key set to null are different facts, and dropping the second is exactly the failure this script refuses everywhere else. Missing keys are skipped; explicit nulls are refused with a message that says to remove the key or give it a value rather than guessing which was meant. Verified both: a 0.2.0 source carrying an agents map exits 1 naming the version, and `cli: null` exits 1 naming the field. All seven legacy files still convert and compile, and nothing under workflows/ is touched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- .../migrate-legacy-workflow.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/ops/schema-migration/migrate-legacy-workflow.py b/ops/schema-migration/migrate-legacy-workflow.py index 2e9ca79e0..ddebfc9d7 100644 --- a/ops/schema-migration/migrate-legacy-workflow.py +++ b/ops/schema-migration/migrate-legacy-workflow.py @@ -37,8 +37,12 @@ def migrate(path): # would be silently restamped as 0.1.0 and "migrated" by guesswork. src_version = str(raw.get('version')) if src_version != '1.0': - problems.append(f"source version is {src_version!r}, expected '1.0'; " - "this tool migrates the 1.0 schema only") + # Return, do not accumulate. Continuing into the legacy loops with a + # schema this tool does not understand means `a.get(...)` raises on a + # 0.1.0-style agents MAP and the caller sees a traceback instead of the + # clean refusal this script promises. + return None, [f"source version is {src_version!r}, expected '1.0'; " + "this tool migrates the 1.0 schema only"], notes unknown_top = set(raw) - FLOW - DROPPED_TOP - {'workflows'} if unknown_top: @@ -120,8 +124,18 @@ def migrate(path): # valid value for them. out = {'version': '0.1.0'} for key in ('name', 'description', 'cli', 'triggers', 'budget'): - if raw.get(key) is not None: - out[key] = raw[key] + # An absent key and a key explicitly set to null are different facts. + # Treating null as absent would silently drop a field the author wrote + # down, which is the exact failure this script refuses elsewhere. + if key not in raw: + continue + if raw[key] is None: + problems.append(f"{key} is explicitly null; remove the key or give " + "it a value -- this tool will not guess which you meant") + continue + out[key] = raw[key] + if problems: + return None, problems, notes out['steps'] = steps return out, [], notes From 1657bd77bd5b54eb8ee04d3b0a359375d2222683 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 06:57:17 +0200 Subject: [PATCH 4/8] chore: ignore Python bytecode ops/schema-migration/migrate-legacy-workflow.py compiles to __pycache__ when the review job runs it. Nothing tracks the .pyc, but the untracked file lands in the swarm's working tree and breaks patch application: error: cannot apply binary patch to 'ops/schema-migration/__pycache__/migrate-legacy-workflow.cpython-314.pyc' without full index line Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 554689b42..892081ba0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ dist/ .relayflow/ .relayflowd/ .relayflowd-*/ + +# Python bytecode: the schema-migration scripts generate this at runtime and +# an untracked .pyc breaks the review swarm patch application. +__pycache__/ +*.pyc From 2d78d0d0a9a897f029c3cb08c8bdded255825a86 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 07:18:28 +0200 Subject: [PATCH 5/8] fix(schema-migration): refuse an implicit in-place rewrite `dest = sys.argv[2] if len(sys.argv) > 2 else src` meant a single-argument run overwrote the input file. The review swarm called this out as a blocker, and it is right: silently rewriting the author's source is exactly the guessing this tool's docstring refuses to do everywhere else. An in-place migration is still possible -- pass the same path twice -- but only when asked for explicitly. Verified: a single-argument run now exits 2 and leaves the file byte-identical. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- ops/schema-migration/migrate-legacy-workflow.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ops/schema-migration/migrate-legacy-workflow.py b/ops/schema-migration/migrate-legacy-workflow.py index ddebfc9d7..5d78db5e3 100644 --- a/ops/schema-migration/migrate-legacy-workflow.py +++ b/ops/schema-migration/migrate-legacy-workflow.py @@ -153,6 +153,16 @@ def migrate(path): "# Deliberate losses, recorded so they are not rediscovered as bugs:"] header += [f"# - {n}" for n in notes] or ["# (none)"] out = "\n".join(header) + "\n" + yaml.safe_dump(spec, sort_keys=False, width=100) - dest = sys.argv[2] if len(sys.argv) > 2 else src + # No implicit in-place rewrite. Defaulting dest to src meant that running + # this with a single argument silently destroyed the input -- the exact + # "guessing on the author's behalf" this tool exists to refuse. An + # in-place migration is still available, but only when asked for by name. + if len(sys.argv) > 2: + dest = sys.argv[2] + else: + sys.stderr.write( + f"refusing to migrate {src} in place: pass an explicit destination, " + f"or '{src}' again if you really mean to overwrite it\n") + return 2 open(dest, 'w').write(out) print(f"MIGRATED {src} -> {dest} ({len(spec['steps'])} steps, {len(notes)} recorded losses)") From f3a9d44e3bb63aecedd377bf804839c7f1643856 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 07:18:56 +0200 Subject: [PATCH 6/8] fix(schema-migration): sys.exit(2), not return, at module scope The refusal path lives in the `if __name__` block, so `return` raised SyntaxError: 'return' outside function and the script would not run at all. I validated the syntax before writing the file rather than after, which is exactly the mistake the assert-the-mutation rule exists to catch. Verified after the change: single-arg exits 2 with the refusal and leaves the input byte-identical; two-arg still migrates. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- ops/schema-migration/migrate-legacy-workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ops/schema-migration/migrate-legacy-workflow.py b/ops/schema-migration/migrate-legacy-workflow.py index 5d78db5e3..08b7e742e 100644 --- a/ops/schema-migration/migrate-legacy-workflow.py +++ b/ops/schema-migration/migrate-legacy-workflow.py @@ -163,6 +163,6 @@ def migrate(path): sys.stderr.write( f"refusing to migrate {src} in place: pass an explicit destination, " f"or '{src}' again if you really mean to overwrite it\n") - return 2 + sys.exit(2) open(dest, 'w').write(out) print(f"MIGRATED {src} -> {dest} ({len(spec['steps'])} steps, {len(notes)} recorded losses)") From d1f7671beda7de07376a283d3bf0c4b510af8a98 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 07:58:02 +0200 Subject: [PATCH 7/8] fix(schema-migration): do not silently drop an agent's explicit model AGENT_DECL accepts `model` and a named agent's model was stored in agents[name], but lowering an agent reference copied only `cli`. A 1.0 file whose agent declared a model migrated with no problems reported while the model was discarded, and the loss note then claimed the legacy file "never had" one, which was false precisely when it mattered. Dropping it is not cosmetic: PR #136's resolveNamedAgent carries declaration.model so a named selection cannot inherit the host's model. Removing the selection lets a migrated run choose a different model, with different behaviour and cost. This is the same accepted-then-dropped failure this script repairs for top-level cli/triggers/budget, so it now refuses rather than guessing which model the author would have accepted. Where a declaration genuinely carried no model, the note says so truthfully. Verified against workflows/watchdog.yaml: the unchanged file still migrates (exit 0) and its header note now reads "carried no model, so nothing was lost"; the same file with an added model is REFUSED with the agent named. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- .../migrate-legacy-workflow.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/ops/schema-migration/migrate-legacy-workflow.py b/ops/schema-migration/migrate-legacy-workflow.py index 08b7e742e..507c2efff 100644 --- a/ops/schema-migration/migrate-legacy-workflow.py +++ b/ops/schema-migration/migrate-legacy-workflow.py @@ -97,14 +97,28 @@ def migrate(path): # information the legacy file actually held. Verified against # the SDK: an agent step with `cli` and no agents map compiles; # an agents map with `cli` and no `model` is refused. - cli = (agents.get(v) or {}).get('cli') + decl = agents.get(v) or {} + cli = decl.get('cli') + model = decl.get('model') if not cli: problems.append(f"step {out['id']}: agent '{v}' has no cli to carry") + elif model: + # AGENT_DECL accepts `model`, so a legacy file can declare one. + # Carrying only `cli` would drop it silently and let the run + # inherit the host's model instead -- different behaviour and + # cost than the author wrote down. That is the same + # accepted-then-dropped failure this script repairs for + # top-level `cli`, `triggers` and `budget`, so refuse instead + # of guessing which model the author would have accepted. + problems.append( + f"step {out['id']}: agent '{v}' declares model '{model}', " + "which a lowered step cannot carry; migrate this agent by " + "hand rather than let the run pick a different model") else: out['cli'] = cli - notes.append(f"step {out['id']}: agent reference '{v}' became cli " - f"'{cli}' - 0.1.0 named declarations require a model " - "the legacy file never had") + notes.append(f"step {out['id']}: agent reference '{v}' became cli " + f"'{cli}' - this declaration carried no model, so " + "nothing was lost") elif k in allowed: out[k] = v elif (t, k) in DROPPED_STEP: From bcafd4218c7011cc3ec4214d1c14703547a59fac Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 08:24:45 +0200 Subject: [PATCH 8/8] fix(schema-migration): close the refusal boundary on malformed input The structure lens raised two P1 blockers and both were right. 1. Malformed input escaped as a traceback. `yaml.safe_load(open(path))` assumed a mapping, so an empty document (None), a scalar document, invalid YAML, or an unreadable file crashed before the promised REFUSED result was printed. The same unchecked assumption sat at `agents` and at `workflows[0].steps`. An author could not tell an unsupported source from a bug in this tool. 2. An empty workflow migrated successfully. Only the workflow COUNT was checked, so a missing or empty `steps` became `steps: []` and returned MIGRATED -- an artifact the SDK then refuses, per the refusal recorded in this directory's README. That is the guessing this script exists to refuse. Both now return a diagnostic and write no output file. Verified: valid watchdog.yaml exit 0 MIGRATED (1 steps, 4 recorded losses) empty file exit 1 "is empty; there is nothing to migrate" scalar document exit 1 "must contain a mapping at the top level, found str" steps: [] exit 1 "0.1.0 requires a non-empty steps array" invalid YAML exit 1 "is not valid YAML: while parsing a block mapping" Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- .../migrate-legacy-workflow.py | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/ops/schema-migration/migrate-legacy-workflow.py b/ops/schema-migration/migrate-legacy-workflow.py index 507c2efff..af622c956 100644 --- a/ops/schema-migration/migrate-legacy-workflow.py +++ b/ops/schema-migration/migrate-legacy-workflow.py @@ -30,8 +30,23 @@ def migrate(path): - raw = yaml.safe_load(open(path)) + # The refusal boundary has to be closed. Letting a parser error or an + # unexpected root type escape as a traceback means an author cannot tell an + # unsupported source from a bug in this tool, and no REFUSED result is ever + # printed -- the opposite of the fail-closed promise in the docstring. problems, notes = [], [] + try: + with open(path) as handle: + raw = yaml.safe_load(handle) + except OSError as exc: + return None, [f"cannot read {path}: {exc}"], notes + except yaml.YAMLError as exc: + return None, [f"{path} is not valid YAML: {exc}"], notes + if raw is None: + return None, [f"{path} is empty; there is nothing to migrate"], notes + if not isinstance(raw, dict): + return None, [f"{path} must contain a mapping at the top level, " + f"found {type(raw).__name__}"], notes # Refuse a schema this tool was not written for. Without this, a 0.2.0 file # would be silently restamped as 0.1.0 and "migrated" by guesswork. @@ -49,6 +64,10 @@ def migrate(path): problems.append(f"unhandled top-level keys: {sorted(unknown_top)}") workflows = raw.get('workflows') or [] + if not isinstance(workflows, list): + return None, ["'workflows' must be a list"], notes + if len(workflows) == 1 and not isinstance(workflows[0], dict): + return None, ["the workflow entry must be a mapping"], notes if len(workflows) != 1: problems.append(f"expected exactly one workflow, found {len(workflows)}; " "a multi-workflow file must be split by hand, not guessed") @@ -59,7 +78,14 @@ def migrate(path): "pattern/channel/timeout slot") agents, roles = {}, {} - for a in raw.get('agents') or []: + declared_agents = raw.get('agents') or [] + if not isinstance(declared_agents, list): + return None, ["'agents' must be a list"], notes + for a in declared_agents: + if not isinstance(a, dict): + problems.append(f"agent entry must be a mapping, found " + f"{type(a).__name__}") + continue name = a.get('name') if not name: problems.append(f"agent declaration without a name: {a}") @@ -75,8 +101,22 @@ def migrate(path): "presets are persona surface (RFC decision 9)") agents[name] = decl + # The 0.1.0 target requires a non-empty `steps` array -- the SDK refusal is + # recorded in this directory's README. Emitting `steps: []` produced a + # cheerful MIGRATED line and an artifact the SDK then rejects, which is the + # same guessing this tool refuses everywhere else. + raw_steps = (workflows[0].get('steps') if workflows else None) + if workflows and not isinstance(raw_steps, list): + problems.append("workflow 'steps' must be a list; 0.1.0 requires a " + "non-empty steps array") + raw_steps = [] + elif workflows and not raw_steps: + problems.append("workflow has no steps; 0.1.0 requires a non-empty " + "steps array, and an empty migration would be refused " + "by the SDK rather than run") + raw_steps = [] steps = [] - for s in (workflows[0].get('steps') if workflows else []) or []: + for s in raw_steps or []: t = s.get('type') out = {'id': s.get('name'), 'type': t} if not out['id']: