Two weathered navigation compasses independently align to the same bearing on a rain-soaked topographic map, illuminated by an amber work lamp and a restrained cyan route marker.

Saturday evening the swamp maintainer pinged Discord: the new workflow output was live, and an assertions library — "so you can validate the things that are actually happening actually did happen" — was about to land.

My first ask to the agent was mundane. Run swamp update and swamp repo upgrade everywhere: this laptop, three homelab hosts, a container image that pins its swamp release, and something like fifty-five git repos. Read the changelog. Adopt what shipped. The usual post-release chore.

Halfway through I realized I was describing the same chore I'd be describing again next week. And the week after.

This is a fit for swamp

Release adoption has a shape. Fetch the release notes between the version you were on and the version you're on now. Capture the CLI surface and diff it against a baseline. Inventory every repo in the fleet — workflows, models, extensions, scripts that invoke the tool. Decide what to adopt. Record what you applied and what you skipped, with reasons and commit hashes. Render a brief.

None of that should be an agent improvising from memory each time. It's a pipeline with typed steps and persistent evidence — which is exactly what swamp models are for. So before the assertions release even landed, we built the machinery to receive it: a release-adoption extension with methods to open a campaign, fetch release notes, capture and diff the CLI surface, inventory the fleet, record opportunities, and render the brief. The extension is published publicly; my fleet configuration and campaign records stay private in the consuming repo. The generic process is shareable. The specifics are mine.

Then the release shipped, and the machinery got its first live run.

The campaign caught a bug in itself

The campaign opened, fetched eleven release bodies, inventoried five repos, and diffed the CLI surface. The diff came back: zero added commands, zero added options.

That was wrong. The release notes plainly added three options to swamp workflow run--fail-on, --junit, --out. The deterministic pipeline had produced a confident, persisted, completely false answer.

The bug was ours. Swamp's machine-readable help stores option names under a plural flags field, and the newest release added a globalOptions array to subtree schemas. Our parser read neither. One fix, two regression tests, an adversarial review, a version bump, and a re-run later:

{
  "addedOptions": [
    { "command": "swamp workflow run", "option": "--fail-on" },
    { "command": "swamp workflow run", "option": "--junit" },
    { "command": "swamp workflow run", "option": "--out" }
  ]
}

I want to be honest about what happened there: the reusable verification machinery needed verification. That's not an argument against building it. It's the argument for it — the false negative was persisted, inspectable, and contradicted by evidence sitting right next to it, so it got caught in minutes instead of silently shaping decisions for months.

What assert steps actually are

The headline feature was a fourth workflow task type. After a step that produces data, you add a step that checks reality:

- name: verify-review-posted
  dependsOn:
    - step: post-review
      condition:
        type: succeeded
  task:
    type: assert
    expr: >-
      data.latest("peer-review", "review-42").attributes.status == "posted"
    message: "Posted review was not persisted"
    severity: high

The expression is raw CEL over persisted data. Failures respect a --fail-on severity threshold, results persist on the step run, and --junit emits XML any CI reporter can consume.

These are not types, and they are not tests. Types check the recipe before anything runs. Tests cook the dish once at home against fixtures. Asserts taste every plate before it leaves the kitchen — every production run, against real state. The gap they close is the sneakiest failure mode in automation: a step that "succeeds" — exit zero, no exception — while the world didn't actually change the way it claimed. The API returned 200 and saved nothing. The download finished and wrote zero files. I've been burned by process success masquerading as task success before; asserts are the workflow-layer answer.

The adoption pass

The agent fanned out across the fleet and came back with a first wave: twenty-four asserts across six repos. Worktree creation pinned to the expected branch. Teardowns pinned to the expected head SHA. A posted GitHub review verified as status == "posted" in persisted data rather than trusted because the posting step didn't throw.

Just as important: roughly fifty-six workflows got nothing, deliberately. Where the model's own code already refuses to report success unless the postcondition holds, an assert would just check the same thing twice — and every extra assert is another way an unattended 3am run can die on a legitimate edge case. Quality over quantity.

Then the adversarial reviews ran, and earned their keep three times.

First review: three teardown asserts read attributes.expectedHeadSha when the model persists it at attributes.options.expectedHeadSha. Validation passed — swamp validates workflow structure, not CEL — so those asserts would have failed every legitimate teardown at runtime. Second: an assert required a watcher snapshot to report initialized == true, but the model deliberately writes false on its very first baseline run. A brand-new deployment would have failed its first night.

Third was the interesting one. One reviewer concluded assert expressions had no way to correlate data to the current run — data.latest() returns the newest artifact regardless of which run produced it, so a stale artifact from yesterday could satisfy today's assert. Another agent, in a different repo, was already using run.id correlation as if it worked. Both could not be right. A throwaway fixture settled it:

"expr": "data.latest(...).tags.?workflowRunId.orValue('') == run.id"
"passed": false
FIXTURE_EXIT_CODE=1

run.id exists. Workflow-produced artifacts carry a workflowRunId tag. The guarded form fails cleanly — a false assertion, not a CEL crash — when the artifact predates tagging. The "platform limitation" one reviewer had accepted was not a limitation at all, so every assert in the fleet got retrofitted with run scoping. Forty-four asserts now verify not just that the expected artifact looks right, but that this run produced it.

That contradiction between two reviewers is worth sitting with. Neither was careless; one reasoned from documentation and one from behavior, and the documentation didn't mention run.id. The fixture was cheap and it beat both opinions.

The flywheel

Adopting a day-old feature means finding its rough edges. We filed three upstream — an eager interpolation bug that crashes the exact collect-then-assert pattern the manual demonstrates, a validate/runtime disagreement, a doubled output line in the new renderer — each with a minimal fixture repro, in the tone of a happy first-day adopter rather than a bug-report sniper.

And here's the part that keeps me investing in this loop: four issues we'd filed against the previous release were all fixed in this batch. Same-day turnaround on some. Filing good issues into a project that ships this fast is not a chore. It's compounding.

The campaign closed with a proper ledger — seven opportunities applied with commit hashes, five skipped with reasons, two extensions published, three issues filed. Closing it required one more improvement: the campaign lifecycle originally dead-ended at scanned, so ledgers stayed open forever. The extension now has a close-campaign method that refuses to close while opportunities are still proposed, and treats a closed ledger as immutable.

The assertions release was announced Saturday night and fully adopted — reviewed, run-scoped, recorded — by early Sunday morning. Next release, the loop is already loaded: open a campaign, diff the surface, inventory the fleet, adopt, close.

Trust the pipeline. But assert.