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:
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:
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