ADR-0151 — VayuFlow: the deterministic automation engine
- Status: Accepted
- Date: 2026-07-30 (accepted 2026-08-05, after the adversarial pass below)
- Relates to: ADR-0139 (VayuMCP), ADR-0141 (VayuOS Spaces), ADR-0146 (Buzz connector), ADR-0149 (network intelligence), ADR-0150 (VayuVeil)
The claim, worded to be defensible
Every automated action this install takes was authorised in advance by a named operator, is reproducible from its recorded inputs, and could not have exceeded the blast radius declared when it was armed — including when a model chose its content.
Note what that does not claim. It does not claim the automation is smart, that it will pick the right moment, or that a model's output will be good. Those are quality questions. The claim above is an authority question, and it is the one worth making unbreakable, because the failure mode of an automation engine is never "it wrote a mediocre summary" — it is "it published 400 of them", "it emailed the list twice", or "a comment told it to and it did".
Context
What we already have, and why none of it is an automation engine
The pieces look close enough to be mistaken for it:
| Piece | What it actually does | Why it is not automation |
|---|---|---|
internal/scheduler |
Stages articles with a publish_at, ticker promotes them |
One trigger, one action, hardcoded to posts |
internal/events |
In-process typed bus, Subscribe/Publish |
No persistence; a crash loses in-flight subscribers |
internal/outbox |
Durable relay: DB row → DispatchFn |
Delivery machinery, not decision machinery |
internal/webhooks |
HMAC-signed POST to a subscribed URL | Pushes a fact out; someone else decides |
/mcp (ADR-0139) |
20 tools, open-ended, human in the loop | Requires a model and a person at the other end |
The gap is the middle: nothing in this codebase can express "when X happens, if
Y holds, do Z — and prove afterwards that it did exactly that." Operators
currently get that by pointing an external agent at /mcp, which works, but
makes a per-site automation depend on someone else's uptime, someone else's
token budget, and a session that has to be running.
Why deterministic-first, and why this is the strategic call
The tempting version of this feature is an in-house agent: give a local model the MCP toolset, a cron entry and a prompt, and let it work out the rest. That should not be built, for one architectural reason and one security reason.
Architectural. Being the tool provider is the durable position. Every improvement in every model that speaks MCP accrues to this install for free, at zero maintenance cost. An in-house agent inverts that: it becomes a component that must be kept competitive with an industry, forever, by one project.
Security. The MCP surface deliberately withholds a general write hole. A scheduled agent with tool access and a context window fed by comments, inbound mail and fetched pages is an autonomous prompt-injection target with no human in the loop — and it holds the operator's own authority. "Summarise today's comments and post the digest" is one hostile comment away from "and also update the site settings". The industry has not solved this; it is not going to be solved incidentally, here, as a side feature.
So VayuFlow is a rules engine, not an agent. Model output is admitted, but only as a value inside a step whose effect was already bounded — never as the thing that chooses the step. Section 6 makes that structural rather than advisory.
Decision
Build internal/vayuflow: a durable, auditable trigger → condition → action
engine, surfaced at /os/vayuflow, with capability-bounded actions, a
declared-blast-radius contract enforced by type, and an honest posture report.
1. The object model
A Flow is a persisted, versioned document:
// Flow is one automation. It is inert until Armed, and every field that
// can cause an effect on the world is bounded before it can run.
type Flow struct {
ID string
Name string
Enabled bool
Trigger Trigger
Condition Condition // zero value = "always"; explicitly allowed
Steps []Step // executed in order, short-circuit on failure
Budget Budget // REQUIRED — see §3
Owner string // the operator who armed it; authority is theirs
Mode RunMode // runDryRun | runLive — zero value is invalid
Version int // bumps on every edit; runs record the version
}
RunMode's zero value is runUnset and is not a valid answer. This is the
rule.go lesson applied directly (internal/vayushield/rule.go): the first
version of OnionPolicy made the safe option the zero value, which meant a
literal that omitted the field had the type answering its own question. Here the
stakes are higher — a Flow{} that defaults to live is an automation that
someone forgot to arm running anyway.
2. Triggers — the only three kinds, and why not more
| Kind | Fires on | Source |
|---|---|---|
TriggerSchedule |
a cron expression, install timezone | ticker, same shape as scheduler |
TriggerEvent |
a typed domain event | internal/events + a durable spool |
TriggerManual |
an operator pressing Run | /os/vayuflow |
There is deliberately no webhook-inbound trigger in v1. An inbound trigger
is an unauthenticated remote party choosing when this install does work, which
is a denial-of-service surface and a rate-limit problem before it is a feature.
It lands only once it can be metered by the same machinery as /api, and it
carries its own ADR.
TriggerEvent requires work the current bus cannot do. internal/events is
in-process and lossy by design — Publish fans out to subscribers with no
persistence, so a crash between the event and the action loses the run silently.
VayuFlow subscribes once, writes an inbox row in the same transaction as the
originating change where one exists, and the runner drains it. That is the
outbox pattern pointed inward, and it is what makes "did this flow run?" a
question with an answer.
Which events a flow may subscribe to is not VayuFlow's decision, and the
ceiling is lower than it looks. This install defines four domain events in
total — article.created, article.updated, article.deleted and
cache.invalidated — and the last is internal plumbing that would be
meaningless as an automation trigger. So the three a flow can name are every
event worth naming, not a subset somebody stopped short of exposing.
That distinction is worth writing down because it was got wrong once: a review recorded "only three events are subscribable" as a VayuFlow gap. It is not one. The engine already reaches everything the install emits, and the sentence describes the event catalogue, not the automation engine.
Widening it is a change to a different subsystem, and it is not free. "When a
comment arrives, draft a reply" is the automation operators ask for first, and it
needs a durable comment.created — which means the comment write path joining
the transactional outbox, because an inbox row written outside the originating
transaction is exactly the lossiness this section exists to avoid. Comments
currently write straight to SQLite. That work belongs to internal/queue and
internal/events, gets its own ADR, and touches the public submission path,
which is why it is named here rather than done quietly as an automation feature.
3. Budget — the declared blast radius
This is the part that makes the headline claim true, and it has no equivalent anywhere in the codebase today.
// Budget is what a flow is permitted to spend on ONE run and across a window.
// Every field is a hard ceiling checked before the effect, not after.
type Budget struct {
MaxStepsPerRun int // refuses runaway step expansion
MaxRunsPerHour int // refuses a trigger storm
MaxWritesPerRun int // posts created/updated, mails sent, ...
MaxEgressPerRun int // outbound fetches, via safefetch only
Timeout time.Duration // whole-run wall clock
}
// Complete reports whether every ceiling was declared. A Budget with any
// zero field is refused at save time — "unlimited" is not expressible.
func (b Budget) Complete() error
"Unlimited" being inexpressible is the design. An operator who genuinely wants a
thousand writes types 1000, and that number appears in the audit trail beside
what the run actually spent. The interesting column on the runs table is
writes: 3 / 20 — a flow consistently near its ceiling is one to look at.
Ceilings are checked in the effect path, not in the planner, so a bug in step expansion cannot route around them.
4. Actions — a capability registry, not a function map
Every action type registers a contract, and cannot be invoked without one. This
is rule.go's registry pattern, and the reason for it is identical: four
obligations re-implemented by hand at twenty action sites is four obligations
forgotten at the twenty-first, silently.
// Capability is what an action is allowed to touch. A registration missing
// any answer fails a test, not a review.
type Capability struct {
Kind Kind // actContent, actMail, actEgress, actAdmin, actModel
Writes WritePolicy // writeNone | writeDraft | writeLive — no zero default
Onion OnionPolicy // inert or active under VAYUOS_MODE=tor, with reason
Reversible Tri // can an operator undo it, and how
MinRole users.Role // authority floor; checked against Flow.Owner at RUN
Rationale string // why these answers, in prose, shown in the UI
}
Four consequences that fall out of this and are worth stating:
actAdminis registered but has no members in v1. Settings, users, keys, domains, VayuShield tiers and payment config are not automatable. The registry makes that a visible, testable emptiness rather than an absence nobody audited.- Authority is re-checked at run time against
Flow.Owner. A flow armed by an admin who is later demoted stops working. Without this, a flow is a permanent capability grant that outlives the grant — the exact bug pattern ADR-0149's amnesty walk exists to prevent elsewhere. Onionis answered per action. UnderVAYUOS_MODE=toran egress action is a clearnet callback, which is precisely what ADR-0141 exists to prevent. Every egress action routes throughsafefetchand is therefore already closed bySetBlockClearnetEgress— the registry entry makes it explicit and testable instead of relying on the call site.Reversibledrives the UI, honestly. An irreversible action gets a confirmation and a distinct chip. A "send mail" step cannot be undone and should never render like a draft edit.
5. Conditions — total, side-effect-free, no expression language
Conditions are a small closed set of typed predicates over the trigger payload and site state (tag equals, author is, status is, count over window, time window, and boolean composition). Deliberately not a scriptable expression language.
A general expression evaluator inside a rules engine is a sandbox with a
different name, and this project already carries one real sandbox
(internal/sandbox) whose 37 dead entries are a standing reminder of what an
unfinished isolation surface costs. Every condition here is total — it
terminates, allocates nothing unbounded, and cannot call out.
6. Model steps — bounded output, never bounded authority
A step may call internal/aiassist (which already has provider-error scrubbing,
per-user rate limiting and quality.Unusable rejection from v3.14.0). It is
constrained by three structural rules:
- A model step produces a value; it never selects a step. The step graph is fixed at save time. There is no branch whose target is model output.
- Its output is typed and validated before it reaches the next step —
length bounds,
Unusable()rejection, and HTML sanitisation on the same path the editor uses. A failed validation fails the run; it does not pass raw text through. - A model step can never raise
Writesabove the flow's declared level. A flow whose capability iswriteDraftcannot publish, whatever the model returns. This is the sentence that makes "a bad generation can't publish itself" a property of the type system rather than a hope.
Under VAYUOS_MODE=tor, a model step using a remote provider is egress and is
closed by the same kill-switch. A local provider is not, and the posture report
must distinguish those two cases rather than reporting "AI: enabled".
7. Execution, and what "durable" costs
One runner goroutine, bounded concurrency, backed by a flow_runs table. Each
run records: flow ID and version, trigger cause, mode, every step with its
inputs, outputs, duration and error, budget spent against budget declared, and
the resolved owner role at run time.
- Idempotency. Every run carries a key derived from
(flow, trigger identity). The event trigger's identity is the inbox row, so redelivery cannot double-execute. This is the single most valuable property in the whole design: the failure operators actually fear is the newsletter that went out twice. - Crash recovery. A run interrupted mid-flight resumes as
interrupted, not as a silent success and not as an automatic retry. Retry is an operator decision, because a step that already sent mail must not be replayed by a ticker. - Retention. Runs are pruned on the same policy as the VayuShield trail, and the panel states the retention window rather than implying infinite history.
8. Dry run is the default, and it is a real execution
Mode starts at runDryRun, and a dry run executes the whole flow —
conditions evaluated against live state, model steps genuinely called, every
effect captured and rendered as a diff — while the effect path refuses at the
capability boundary. A dry run that skips the model, or that stubs the
condition, tells the operator nothing about what the live run will do.
Going live is an explicit, per-flow, logged action.
9. /os/vayuflow — the panel
House style, per the repository contributor notes: page-header, page-sub, a stat-grid of four
(armed flows, runs 24h, refusals 24h, budget-capped runs 24h — that last tile
takes stat-card--warn), section-head bands, and a mon-stack of monAcc
accordions with mon-chip--on/--off so a flow's state reads while collapsed.
No inline style=, one nonce'd script.
Nav key added to adminAreas in osPathMinLevel (handlers_auth.go) and
pinned by test — a flow editor that inherited the permissive author default
would let an author arm an automation that runs with their own authority, which
is a privilege-escalation bug wearing a routing mistake's clothes.
10. The posture report
internal/vayuflow/flowaudit, following anonaudit and shieldaudit: a set of
Checks with a Status, computed from live state and shown on the panel. It
reports what is not true as prominently as what is:
- flows armed live vs. dry-run
- any flow whose owner's role no longer satisfies its actions'
MinRole - any flow that hit its budget ceiling in the window (a ceiling reached is a ceiling doing its job or a ceiling set wrong — either wants a human)
- egress-capable flows while
OnionModeis on, and confirmation they are inert - model steps, split by local vs. remote provider
- the honest ceiling: VayuFlow bounds what an authorised automation can do. It is not a defence against an operator account that has been taken over, and the report says so in those words. A posture panel that implies otherwise is the same defect class as the report that told an operator their readers were broken on the strength of the operator's own request.
Phasing
Each phase lands on main as its own commit; one release at the end, after
the adversarial pass the release discipline requires. Not one release per phase.
| Phase | Content | Gate that proves it |
|---|---|---|
| P1 | Store, schema, Flow/Budget/Capability types, registry, Complete() |
a flow with any unset contract field fails to save |
| P2 | Runner, flow_runs, idempotency keys, crash → interrupted |
redelivering an event twice produces one run |
| P3 | Schedule + manual triggers; content actions at writeDraft |
a writeDraft flow cannot produce a live post |
| P4 | /os/vayuflow panel, dry-run diff view, arming |
arming is logged with actor and prior mode |
| P5 | Event trigger + durable inbox | a crash between event and run loses no run |
| P6 | Model steps, output validation, budget accounting | a model returning 50KB of garbage fails the run |
| P7 | flowaudit posture, mail + egress actions, Tor inertness |
egress actions inert under VAYUOS_MODE=tor |
The adversarial pass this design already anticipates
Written now, so the pre-release audit starts from attacks rather than features:
- Trigger storm. Publish 10,000 articles; does
MaxRunsPerHourhold, and does the inbox grow without bound while it does? - Budget bypass via step expansion. Can any step cause more effects than
MaxWritesPerRun, by looping, by fan-out, or by a model returning a list? - Authority outliving the grant. Arm as admin, demote the owner, fire the trigger. The run must refuse.
- Injection through content. A comment or inbound mail containing instructions reaches a model step. It must be incapable of changing the step graph — verify by construction, then by test.
- Tor leak.
VAYUOS_MODE=torwith an armed egress flow. Nothing leaves. - Idempotency under redelivery. Same event twice, crash mid-run, resume.
- The dry-run lie. Does dry run genuinely evaluate everything a live run would, or does it skip the expensive parts and under-report?
Each becomes a failing test first, in the attacker's voice, and every fix is mutation-tested — a test that passes against the broken version proves nothing, which this project has learned twice already.
What the adversarial pass actually found
Run before the release rather than after it, over everything the seven phases accumulated. Four of the seven pre-declared attacks landed. Each was written as a failing test in the attacker's voice before any code changed, and each fix was mutation-tested — the fix was re-broken and the test confirmed to go red again. Six mutations, six kills.
| Attack | Verdict | What it cost |
|---|---|---|
| 1 — trigger storm | Half found. The rate ceiling held and wrote no row, as designed. The inbox did not: PruneDrained existed, said it was "bounded by policy rather than by hope", and was called by nothing in the binary. |
A drain pass now also forgets — 30-day window, hourly interval. Pruning every pass would have swapped an unbounded table for an unbounded scan every five seconds. |
| 2 — budget bypass via step expansion | Nothing found. There is no expansion: Steps is a fixed ordered list settled at save time, chargeStep runs before every step, Complete() refuses a flow whose step count exceeds its own MaxStepsPerRun, and a model returning a list returns a string that nothing iterates. An action calling Write in a loop is stopped by the ledger, not by its own restraint. |
Pinned by TestAnActionCannotOutspendTheWriteCeilingByLooping. |
| 3 — authority outliving the grant | Nothing found. The owner's role is not stored on the flow at all, and a resolver that errors reads as no role rather than as the last known one. Re-attacked from the demotion side. | Pinned by TestADemotedOwnerStopsTheFlowEvenThoughItWasArmedByAnAdmin. |
| 4 — injection through content | Nothing found. There is no edge to take: Step has no branch target, prev is one value, and substitution replaces only a parameter whose whole value is the placeholder — so content cannot append to an operator-written URL, and the fan-out a whole-value substitution would buy is refused by mail.send itself. |
Pinned by TestInjectedContentCannotSpliceASecondRecipientOrRedirectAFetch. |
| 5 — Tor leak | Found, twice. Effects.Fetch refused only when the calling action declared itself inert, and the registry test that keeps egress actions inert covers KindEgress and nothing else — so an action of any other kind that reached for the network got the clearnet, with only safefetch left downstream. Separately, Flow.NeedsEgress keyed on the Onion policy, so the panel told an operator whose model runs on this very host that their flow "reaches a remote host" while the posture report on the same page said it did not. |
Fetch now refuses whenever clearnet egress is blocked, whatever the action declared — it is called Fetch; being outbound is not something it needs to be told. NeedsEgress keys on the kind, and a model step gets its own line naming which provider this install actually has. |
| 6 — idempotency under redelivery | Nothing found. The key is claimed in Begin, before any step runs, and derives from the inbox row id. Covered by the P2 and P5 suites. |
No change needed. |
| 7 — the dry-run lie | Found. Effects.Model is deliberately ungated so a dry run calls the model for real. Effects.Fetch was gated — so a fetch → model → draft flow dry-ran with an empty body into a model that genuinely ran. Under-reporting the read and over-reporting the generation, on the same screen, in the same run. |
A fetch is a read that produces a value, already charged and already refused in a Tor Space. It happens, and the capture says fetched rather than would fetch. |
One of the new assertions was itself wrong on the first attempt, and it is worth
recording because it is the repository's own named failure mode: searching the
whole rendered page for reaches a remote host matched the posture report's
sentence "No armed flow reaches a remote host." — a substring that passes a
regression and fails a correct fix. The test now extracts the one accordion and
fails loudly if it cannot say which element it read.
Consequences
Good. Automation stops depending on an external session being alive. Every automated effect gets an owner, a ceiling and a record. The capability registry makes "what can this install do to itself without me?" an enumerable question. MCP stays the open-ended path, so the two are complementary rather than competing.
Costs, stated plainly. A new persistent subsystem with its own schema,
runner and panel — real surface. Deterministic conditions will not cover every
case an operator imagines, and some will want the scriptable version we are
declining to build; the honest answer is "use MCP for that, with a human in the
loop." And the engine is only as good as its ceilings: a Budget filled in with
large numbers to make a warning go away is a real, and likely, operator failure
mode. The panel should make a near-ceiling run visible rather than pretending
the number was chosen carefully.
Rejected alternatives. A general in-house agent (§Context). A scriptable
expression language (§5). Inbound webhook triggers in v1 (§2). Automating
actAdmin (§4).
Appendix — dead-code inventory (evidence, not a deletion order)
Nothing is deleted by this ADR. This records what was measured, so a future decision starts from evidence rather than a fresh guess.
Method. go list -deps ./cmd/vayupress gives the packages actually linked
into the shipped binary: 103. Comparing against go list ./... gives the
packages that are not. This is stronger evidence than an import grep, because it
follows the real transitive closure from the real entrypoint.
Never linked into the shipped binary (with their scripts/deadcode-allow.txt
entry counts):
| Package | Dead entries | Note |
|---|---|---|
internal/federation |
27 | ActivityPub, ahead of its wiring |
internal/migrations |
16 | schema migrations do not use this — they run through internal/db (runMigrations, verifyMigrationChecksums, embedded FS). Only bench/fuzz tests import it |
internal/storage |
13 | arweave_stub.go, ipfs_stub.go — stubs by their own filenames |
internal/ai |
12 | one importer: internal/search/semantic, itself unlinked. Only ai.Embedding crosses the boundary |
internal/slo |
10 | |
internal/graph, internal/merkle, internal/governance, internal/profiling |
7 each | /os/governance does not import internal/governance; the page is alive, the package is not |
internal/registry, internal/events/schema |
6 each | |
internal/did |
5 | |
internal/archive, internal/search/sharded, internal/search/semantic, internal/signing |
4 each | signing referenced only by tests and archcheck rules |
internal/spam |
3 | comments carry their own StatusSpam constant |
Legitimately unlinked and not candidates: cmd/vayudocs (separate binary),
docs/plugins/examples/* (documentation), internal/archcheck,
internal/testutil, internal/compat (test-only tooling),
scripts/screenshot-proxy.
Two findings worth carrying forward. First, internal/sandbox has the
largest dead surface (37) but is linked and does have production importers —
it is partially wired, which is a different and more interesting state than
unused. Second, the two entries that read as load-bearing from their names —
migrations and governance — are the two where the live service turned out to
use something else entirely. Name-based intuition was wrong in exactly the cases
where being wrong would have hurt; that is the argument for the linker-based
method over grep.
If a removal is ever undertaken, the order that minimises risk is: stubs
first (storage), then leaf packages with zero importers of any kind, then
ai+semantic as a pair, and never a package with production importers. Each
step is its own commit with scripts/deadcode-gate.sh --update, full gates, and
a build of the real binary — because the only proof that matters is that the
shipped artifact still links and passes.