Skip to content

Enforce a lane workflow

This guide shows how to make your repository enforce a branching model — Git Flow, GitHub Flow, a release train — instead of merely documenting one. You declare which lane transitions are legal, and tovio land refuses the rest.

A workflow is the third policy axis, and the only one keyed on a pair of lanes:

Policy Triggers on A rule it can express
policy protect the target lane "main needs two approvals"
policy change-control the changed paths "anything under crypto/** needs review"
policy workflow the (source → target) edge "a feature lane may not land onto main"

That last rule is the one the other two cannot express. "Feature may not land on main" is not a fact about main — plenty of lanes may land there. It is not a fact about the files either. It is a fact about the pair, and the pair is what a workflow is keyed on.

Client-enforced and Forge-enforced; not an authorization boundary

The gate runs at every land entry point and at the native Forge's land gate. It is enforcement against an honest client and advisory against a hostile one: the source half of the edge is asserted by the proposer, because a lane is local state until it is pushed and the server cannot derive it. The gate can only ever deny — it never relaxes a review, check, coverage, or path requirement. A repository that needs the edge to bind against a hostile client must back it with target-lane protection. The hosted Cloudflare Forge applies no edge gate at all.

Quick start: adopt a preset

Five workflows ship with TOVIO. Declaring one takes a single command:

$ tovio policy workflow set gitflow
Workflow `com.tovio.gitflow@1` set (manifest v1).
  5 role(s), 7 transition(s), unmatched = refuse
Preset Shape
gitflow feature → develop → release/** → main, with release and hotfix merging back to develop
github-flow short-lived topic lanes land straight onto main
gitlab-flow-env topic lanes land on main, which promotes forward through env/staging to env/prod
trunk-based short-lived lanes onto main; the recommended default
release-train main cuts release/** lanes; hotfixes land on a release and back onto main

set expands the preset in full into your signed policy manifest — the name is never stored for later resolution. A future TOVIO that revises a preset therefore cannot change your repository's enforcement underneath its existing signature. Fork a preset freely; an expanded declaration is ordinary manifest data.

Inspect what you declared at any time:

$ tovio policy workflow show
Workflow: com.tovio.gitflow@1
  unmatched edges: refuse    unknown source: allow

  Roles (first match wins, in this order):
    trunk          main
    integration    develop
    release        release/**
    hotfix         hotfix/**
    feature        **

  Permitted transitions:
    feature        → integration
    feature        → feature
    integration    → release
    release        → trunk   (also lands onto integration)
    release        → integration
    hotfix         → trunk   (also lands onto integration)
    hotfix         → integration

What a refusal looks like

Every refusal names both roles, why the edge failed, and where you can land:

$ tovio land --into main
✗ Cannot land: this lane transition is not permitted by the repository workflow

  workflow `com.tovio.gitflow@1` does not permit feature/login (role: feature) → main (role: trunk); a `feature` lane may land onto: feature, integration; a `trunk` lane may receive from: hotfix, release

  Land onto a target a `feature` lane may reach: feature, integration
  See the declared workflow:
    tovio policy workflow show

  [TVO-FLOW-001]  https://tovio.dev/errors/TVO-FLOW-001

Write your own

A workflow is two lists: roles map lane-name globs to names, and transitions say which role may flow into which. Save it as a .toml file and point set at the path instead of a preset name.

id = "acme.two-stage@1"

# Refuse any edge this matrix does not name. This is the fail-closed direction, and the only setting
# under which "here is the list of permitted edges" means anything.
unmatched = "refuse"

[[roles]]
name = "trunk"
pattern = "main"

[[roles]]
name = "staging"
pattern = "env/staging"

[[roles]]
name = "production"
pattern = "env/prod"

# The catch-all MUST come last — see the warning below.
[[roles]]
name = "feature"
pattern = "**"

# Feature work reaches trunk only through a gated land.
[[transitions]]
from = "feature"
to = "trunk"
required_checks = ["build-check"]
require_review = { min_approvals = 2 }

[[transitions]]
from = "trunk"
to = "staging"

# Production may only receive what already passed staging.
[[transitions]]
from = "staging"
to = "production"
require_ancestor = "staging"
$ tovio policy workflow set ./acme-two-stage.toml
Workflow `acme.two-stage@1` set (manifest v1).
  4 role(s), 3 transition(s), unmatched = refuse

Transition options

Field Effect
required_checks named checks that must pass for this edge. Conjoined with the target lane's own required_checks — never replacing them
require_review the review this edge requires, e.g. { min_approvals = 2 }. Conjoined with (the tighter of) the target lane's own requirement
require_ancestor forward-only promotion: the source tip must descend from a lane holding this role. This is what makes "env/prod only receives what passed env/staging" real
also_land the coordinated merge-back — landing this edge also obliges landing onto a lane of each named role

require_ancestor names a role, not a lane. If no live lane holds that role the land is refused, not vacuously permitted — content cannot be proven to have passed a stage that does not exist.

Role order is significant — and the linter cannot fully check it

Roles match first-match in declaration order. Put the ** catch-all first and it swallows every lane, killing every role below it. The workflow then refuses everything it was written to permit:

$ tovio land --into main
✗ Cannot land: this lane transition is not permitted by the repository workflow

  workflow `acme.broken@1` does not permit feature/x (role: anything) → main (role: anything); a `anything` lane may land onto: trunk; a `anything` lane may receive from: (none)

Note main (role: anything) — the catch-all took it, so the trunk role is dead. check detects shadowing only by exact pattern equality, deliberately: proving general glob containment is undecidable in the useful direction, and a false "this is shadowed" on a legitimate pair would be worse than missing a subtle one. Always declare the catch-all last, and read show after set to confirm each lane resolved to the role you meant.

Lint before you commit to it

check reports defects without changing anything, and set refuses a blocking defect unless you pass --force:

$ tovio policy workflow check
Workflow `acme.two-stage@1`: no defects.

Blocking (Error) defects are the ones that would strand you: a total lockout (unmatched = refuse with no transitions at all), an also_land naming an unknown role, an also_land whose own edge is not permitted, and an also_land that names its own source — which would create a debt nothing could ever discharge. Unreachable roles, shadowed patterns, and a missing catch-all are warnings.

The coordinated merge-back

Git Flow's rule is "a release lands onto main and back onto develop." That is also_land, and the presets already declare it. Every target is gated before any ref advances, so if one target fails its gate, nothing moved at all.

flowchart LR
    L["tovio land --into main<br/>from release/2.4"] --> G{"gate every target:<br/>main AND develop"}
    G -- "all pass" --> A["both lanes advance"]
    G -- "any fails" --> N["nothing advanced"]
    A -. "a ref fails mid-sequence" .-> P["durable obligation<br/>tovio land --resume"]

A coordinated gate, not a two-phase commit

Refs advance one at a time, so a failure partway through leaves real partial state. TOVIO does not hide that. The outstanding target is written to a durable local ledger, reported, and resumable — it is never silently dropped, and never described as atomic.

When a merge-back cannot complete — here develop is protected, so it is Forge-advanced only — the land says so up front:

$ tovio land --into main
! coordinated merge-back outstanding: `release/2.4` still owes develop (1 landed)
  complete it with: tovio land --resume
✓ fast-forwarded main → release/2.4  blake3:b4d8b9067d…

The debt survives the process. tovio health leads with it:

$ tovio health
! Outstanding merge-back: `release/2.4` still owes a land onto develop [TVO-FLOW-007]
    Complete the outstanding target(s): tovio land --resume
    See every outstanding obligation: tovio health
Change health — `release/2.4` landing into `main`
  already contained in `main` — nothing to land

Once the blocker is cleared, resume attempts only the outstanding targets:

$ tovio land --resume
All coordinated merge-back obligations are discharged.

Autosync and the refresh direction

TOVIO's background autosync fold — where a lane pulls from its upstream — is gated too, but on the inverse edge. A refresh is the return leg of the promotion that lane will itself later make, so develop → feature/x is judged on whether feature → integration is permitted.

Both halves matter. Leaving the fold ungated would be a real bypass: set a lane's upstream, let a background tick advance it, and you have laundered a forbidden promotion. Judging it by the forward rule would refuse every legitimate sync.

Because of that, set audits your existing upstreams and tells you which ones the new workflow will not fold:

$ tovio policy workflow set gitflow
Workflow `com.tovio.gitflow@1` set (manifest v1).
  5 role(s), 7 transition(s), unmatched = refuse

! these lanes have an upstream this workflow will not let autosync fold:
    develop ← main
    feature/x ← main
  Switch to each lane, then `tovio autosync upstream --set <LANE>` or `--unset`.

Git Flow genuinely does not permit develop ← main — it models that as the explicit hotfix → develop merge-back — so the fix is to retarget or drop those upstreams, not to weaken the workflow.

Changing or removing a workflow

clear is the escape hatch, and it always succeeds — a workflow that refuses too much must never be able to trap you:

$ tovio policy workflow clear
Workflow cleared (manifest v3). Every lane transition is permitted again.

To change one, edit your .toml and set it again; each write bumps the manifest version and re-signs it. There is no flag that edits a declaration in place.

Adopting a workflow on a busy repository

Leave unknown_source = "allow" (the default) while you migrate. A Git-compatible refs/for/<lane> push transmits only the destination, so it can never supply a source lane, and every proposal opened before you declared the workflow has none either. Setting it to refuse rejects all of those the moment the workflow lands — choose it only once your team is on native TOVIO proposals.

What a workflow deliberately does not enforce

Worth knowing before you rely on one:

  • Lane origin. "A release lane may only be cut from develop" needs a durable, synced record of where a lane came from. None exists — the upstream map is local and never synced. The edge is enforced; a lane's provenance is advisory.
  • cherry-pick. It transfers content between lanes by re-deriving commits. This gate scopes to lands.
  • Clock-dependent rules. "A lane older than N days may not land" belongs in required_checks, as an attested check, not in the matrix.
  • A hostile client, and the hosted Cloudflare Forge — see the note at the top of this page.

Next steps

  • Lane protection — the target-lane axis: required reviews, required checks, cleared-reviewer coverage. What actually binds when the edge gate cannot.
  • Team workflows & diagrams — the visual map of how a team threads these together day to day.
  • Propose and review — how a change reaches a protected lane at all.

Suggest an improvement to this page Not for security reports — see disclosure