Follow the same eight questions, in order, that turn that sentence into a declared workload: one mechanism per answer, then the files that declare it, then the commands that run it with the CLI. The point of doing this with money instead of code: none of the 's own logic had to change to support a household ledger. Only new declaration files did. Everything here runs today, scripted, under the project's CI, and the screenshots are the 's read-out of the result.
packages/regulator built and regulator on the path (OPERATING.md). Otherwise, just follow along.Strip away the control-system vocabulary for a moment. Once a month, this system is supposed to:
The rest of this page builds that system for real, in five steps: design it as a control system first, declare it as files, run it end to end, read the results back, and check it against what it honestly doesn't do yet.
The course builds regulator against a software repository because that is
where agents are most common today. But the control plane is generic over workloads, and the fastest way to
see which parts of the design are yours to make is to put a different domain under it.
For each function of the Viable System Model, ask the regulatory question it exists to answer, and answer it with a mechanism at the highest level of the hierarchy that fits (type → gate → typed tool → judgment → prompt), with a reason whenever it is not the top. "The agent will be careful" is not an answer.
ledger/<period>.csv, reports/<period>.md, payments/pending/*.json. Truth: statements/, protected. The person's record: payments/executed/, protected. The domain's own checks: test/ledger.test.js, which check that every category is in the taxonomy, every period balances to its statement row for row and in total, a report's totals are the ledger's, and a pending payment is well formed.ingest, categorize, reconcile, report, prepare-payment, close), under two that are grants over declared tool effects. The bookkeeper writes under ledger/, reports/, payments/pending/ and gets no shell, so the grant is complete at the tool boundary. The auditor is read-only by declared effect. lib/ and test/, the checks that will judge a unit, are under nobody's write grant.categorize: fixed: the statement is not edited, the taxonomy is not extended; delegated: each row's category within the taxonomy, July as precedent, every new merchant reported; unresolved: UNKNOWN MERCHANT 8831, a 120.00 debit nobody can explain: defer. The unit does not guess, and the close carries it as an open item. Budgets and routes per unit type sit in a policy file.identity-untouched diffs the protected prefixes against the base at closeout, so an edit committed around a gate is a failing check. glossary-lint reads the branch for the identity's refused words. inherited-tests runs the suite the unit inherited against the unit's tree, so a test cannot be deleted or weakened to make a close look right. After every merge the base is checked again, and a failure there holds the whole instance.--protected statements/ at closeout). Write a payment file without a person's yes (ask_human with kind=consent opens a blocking obligation; the pause gate refuses every tool with an effect until it is answered; silence is never , by protocol constant). Write payments/executed/ (as the statement). In your own definition these are INV-005…007 in the identity, seeded into every instance and cited by contracts. The lab's identity is the software harness's, so the contracts here cite the person who fixed them, and the mechanisms are the same.| Rule | Level | Why not higher |
|---|---|---|
| a category is from the taxonomy | 2 · check | a type cannot see the file; the ledger's test can |
| the ledger balances to the statement | 2 · check | — |
| the statement is never edited | 2 · gate + check | — |
| the report's totals are the ledger's | 2 · check | — |
| consent before a payment | 3 · typed tool + 2 · gate | whether an action needs consent is still the model's to notice (DEBT row 29); an effect-declared pay tool would let a gate derive it |
| which category a merchant gets | 4 · judgment | it is judgment; the contract bounds it and the report records the choice |
| don't guess the unknown merchant | 4 · judgment | bounded by an unresolved decision; that the report carries every holding row is level 2 |
| "run the tests before you report" | 5 · advice | advice is advice |
In the docs: The worked example, in full
This is the whole implementation. Nothing in the orchestrator changed for it except
one line: the CLI now loads the workload a contract names. Each file is in
packages/regulator and checked by regulator check.
First, the domain. This already exists as a fixture, so there's nothing to invent: a taxonomy, a protected statement, a ledger, and the checks that will judge every unit's work, sitting outside every write grant.
household-ledger/ the domain: packages/regulator/fixture-finance
categories.json the taxonomy (the person's)
statements/ the bank's exports — protected
2026-08-checking.csv/.json
ledger/2026-07.csv July, closed and categorized
reports/2026-07.md July's close
payments/pending/ a file here is a commitment
payments/executed/ what was paid — protected
lib/ledger.js test/ the ledger's own checks —
outside every write grant
package.json scripts.test = node --test
Next, the capability profile: a grant over declared tool effects, not a persona. The bookkeeper gets no shell, so the grant is complete at the tool boundary. The advice is guidance for judgment calls. Enforcement is the two decisions above it.
profiles/bookkeeper.json
{
"tools": ["read", "write", "edit", "grep", "find", "ls",
"read_conventions", "run_tests", "run_checks",
"remember", "ask_human"], ← no bash
"writablePaths": ["ledger/", "reports/", "payments/pending/"],
"advice": [
"…the statement is the bank's and is never edited…",
"…a merchant you cannot explain stays in the holding
category and is surfaced; do not guess.",
"Writing under payments/pending/ is a commitment: ask_human
with kind=consent … write nothing without a yes."
]
}
Then, the workload: six unit types, naming the profile each runs under and the checks the host will run at closeout. This is the one thing the orchestrator didn't already know how to do: load a workload other than the software one.
workload/personal-finance.json
{
"name": "personal-finance", "version": 1,
"unitTypes": [
{ "name": "ingest", "profile": "bookkeeper",
"checks": ["run_checks", "run_tests", "inherited-tests",
"identity-untouched", "glossary-lint"] },
{ "name": "categorize", "profile": "bookkeeper", "checks": [same] },
{ "name": "reconcile", "profile": "auditor",
"checks": ["run_tests", "inherited-tests", "identity-untouched"] },
{ "name": "report", "profile": "bookkeeper", "checks": [same] },
{ "name": "prepare-payment", "profile": "bookkeeper", "checks": [same] },
{ "name": "close", "profile": "auditor", "checks": [] }
]
}
Finally, a contract: one per unit, and here's the one that touches money. The fixed decision below is the whole of INV-006 in miniature, cited to a person instead of an invariant file because this fixture has no identity fork of its own yet.
contracts/finance/f5-prepare-payment.json
{
"unitType": "prepare-payment",
"objective": "Prepare September's rent … after a person consents.",
"fixed": [
{ "id": "f-consent",
"decision": "No file is written under payments/pending/
until a person answers yes to a consent question that
names the payee, the amount and the due date.
Silence is not consent.",
"authorityRef": "human:alice" } ← INV-006 in your fork
],
"expectedEvidence": [
{ "class": "test", "required": true,
"description": "run_tests: the ledger's suite passes" },
{ "class": "command", "required": true,
"description": "run_checks: protected prefixes untouched" }
]
}
Three more files exist in the real fixture, not shown above:
profiles/auditor.json is the read-only profile: read, grep, find, ls, read_conventions, run_checks. None of them writes.policies/finance.json holds budgets and model routes per unit type: the read-only unit types get one attempt each, and categorizing runs on the cheaper model.contracts/finance/ holds four more contracts, one per remaining unit type, in the same fixed / delegated / unresolved shape as the one above.human:alice) instead of a written rule. If you built a
standalone finance harness of your own, you'd instead write three permanent
invariants into identity/INVARIANTS.md: INV-005 (the bank statement is never edited), INV-006 (nothing
is committed to without consent), INV-007 (what was actually paid is the person's record, not a
unit's). Every contract would then cite the invariant instead of naming Alice. The
mechanism enforcing the rule is identical either way. Only the paperwork changes.
With the declaration from step 2 in place, this is every command from a bare fixture to a closed month, in copy-paste order. Run it against the real fixture, or just read it: each line is annotated with what it does and why.
regulator fixture ~/ledger --finance cd ~/ledger pnpm add -D @metacoding.io/regulator @metacoding.io/regulator-pi # the control plane and the Pi host, installed in the instance regulator init --writable ledger/,reports/,payments/pending/ \ --protected statements/,payments/executed/ --by alice regulator doctor # exit 1 on any problem R=node_modules/@metacoding.io/regulator; L=$R/contracts/finance; P=$R/policies/finance.json # the installed package's definition regulator unit drive $L/f1-ingest.json --policy $P regulator unit drive $L/f2-categorize.json --policy $P regulator unit drive $L/f3-reconcile.json --policy $P regulator unit drive $L/f4-report.json --policy $P regulator unit drive $L/f5-prepare-payment.json --policy $P # asks; nobody answers; pauses regulator obligations # consent: September rent — owed to a person, veto regulator answer <obligation> --by alice --answer yes regulator unit drive $L/f5-prepare-payment.json --policy $P # the next attempt carries the answer regulator-control-room --definition $R --instance ~/ledger # @metacoding.io/regulator-control-room
uncategorized and flags it in its report as unresolved. The ledger still balances to the bank statement byte for byte, and that one row is visibly unresolved instead of silently wrong.alice answers, re-running the same unit finishes the job and writes the payment file.categorize and reconcile). A unit doing something else, like preparing a payment, never sees it.payments/pending/ file that only exists because a named person said yes.
Nothing above required touching the orchestrator's code.
Without a model, packages/regulator/src/finance.test.ts drives exactly this with the scripted bookkeeper and careless units under pnpm check. The screenshots below are the control room over that instance.
In the docs: Getting started · Operating
The control room is a read-only dashboard: a web page that shows you everything the system recorded about the run in step 3, without being able to change any of it. Below are three real screenshots of it, each with a plain-language breakdown of what's on screen.

The next two screenshots both drill into a single unit's full history. It is the same view applied to two different outcomes, so you can see both paths through it: one unit that's waiting on a person, and one that made a mistake and got corrected automatically.


Two more views exist in the same dashboard, shown smaller here since they're less central to this walkthrough:


More of this same dashboard (the full topology, the assurance and lifecycle views, a live instance under drift) is shown on the product page.
In the docs: The control room
Four ways this walkthrough is simpler than a real deployment would be, each stated outright.
You've now followed a household ledger end to end: designed, declared, run, and read back. Here are the same eight moves, generalized, for whatever you're actually building.
inherited-tests so the suite that judges it is the one it inherited.regulator init with the declared prefixes, then doctor, then drive the first unit and read the evidence, not the summary.Nothing on this page was hypothetical, and neither is your domain's version of it. The fixture and every file in step 2 are real, checked-in code. Start by reading them, not from a blank file.
In the docs: The worked example, in full · Definition files