looply

006 rails

Rails

The interlocks that stop a round before any check runs, why an abort is not a red gate, and the narrow door that lets one item cross one rail once.

Rails are the boundaries looply holds around the coding agent's work. They are declared in .looply/config.yaml, they are checked by the gate executor before a single gate step runs, and when one trips the round stops with an abort rather than a failing check. This page covers what can be declared, what happens when one trips, and the one narrow way a rail can be crossed on purpose.

Where the rails are declared

rails:                          # hard interlocks — abort, not red
  forbidden_paths: ["prisma/", "src/common/guards/", "src/modules/payments/"]
  prod_patterns:   [herokuapp, amazonaws, ".render.com", "supabase.co", ".neon.tech"]
  prod_env_files:  [".env"]
  exemptions:      ask          # ask | never

scope:
  free:     ["web/src/**", "admin/src/**"]
  additive: ["src/**"]          # new files only
Key What it holds
rails.forbidden_pathsGlobs. Any changed path matching one aborts the round with code 92. The parser default is an empty list, which means nothing in the repo is off limits; looply doctor and looply run both print rails.forbidden_paths is empty — nothing in this repo is off limits before they start.
rails.prod_patternsSubstrings, matched case-insensitively. A hit aborts with code 91. The parser default is an empty list, which disables the check entirely — but looply init writes the five patterns above into the file, so a freshly initialised repo does not run on that default.
rails.prod_env_filesWhich files are read and searched for those substrings. Defaults to [".env"]. A file that does not exist is skipped, not an error.
rails.exemptionsask or never. Defaults to ask. Validated at load; see the last section for what reads it, which is nothing yet.
scope.freeGlobs the agent is told it may edit. Defaults to an empty list.
scope.additiveGlobs where the agent is told to add new files only. Defaults to an empty list.

The two interlocks

runGate calls checkInterlocks before its first step and returns immediately if it produces anything. The production check runs before the path check, and both run before any gate step. The same function backs looply doctor, which calls it with an empty diff and no agent call — so doctor can prove the production half in ninety seconds without a run existing.

looply doctor

# doctor  : running interlocks and one full gate pass (no agent call)
# ABORT   : a database URL points at a remote host (matched "amazonaws") (code 91)

The production check collects the full text of every file in prod_env_files that exists, plus every non-empty value in the process environment, and asks whether any of those strings contains any prod_patterns entry, case-insensitively. The abort reason names the pattern that matched. It does not name the file or the variable it matched in.

Each env file is one string, not a set of parsed variables. A commented-out # DATABASE_URL=postgres://…amazonaws.com/… still contains amazonaws, so it still aborts. That is the intended direction of the error, but it is the surprise people hit first.

The forbidden-path check takes the changed paths from git status --porcelain=v1 -z, minus anything under .looply — the runner's own state writes must not read as the agent changing something — and matches each against forbidden_paths with looply's own small glob matcher: * stops at a separator, ** crosses any depth, ? matches one character, character classes work, and a trailing / means this directory and everything under it. Rename and copy entries contribute both ends, since both changed as far as a rail is concerned. Every matching path is reported, not just the first.

Abort code What tripped and what is recorded
91 (ABORT_PROD)A production pattern matched an env value. Reason: a database URL points at a remote host (matched "herokuapp").
92 (ABORT_RAILS)The diff touched railed paths. Reason: the round touched paths the rails forbid, with paths listing every violating path.

91 and 92 are constants in gate.ts recorded on the gate.result event as abortCode. They are not process exit codes. looply doctor prints the code in its ABORT line and returns 1.

An abort is not a red gate

Abort — an interlock tripped Red — a check failed
No gate step runs. steps is empty.Steps run in configured order and stop at the first failure.
gate.result carries abortCode 91 or 92.gate.result carries no abortCode.
An alert event is emitted with code gate-abort and severity critical.No alert.
On a paired device that alert becomes a push, governed by device.notify.onAlert — on by default, and the one kind exempt from quiet hours.No push at all. The server notifies on three event kinds — question.asked, run.ended and a non-info alert — and round.ended is not one of them, so the discard is only counted in the next periodic report. A device.notify.onRoundDiscarded setting exists and is off by default, but nothing sends that kind yet.
The item is requeued with lastFailure beginning gate abort:.The item is requeued with lastFailure beginning gate red: step.

Both end the round the same way: the working tree is discarded, the item's cached evidence is invalidated, the item goes back to ready with its attempt count raised, and the round outcome is discarded. The difference is the alarm and the diagnosis. A red step means the change was wrong. An abort means something that should have been impossible happened — which may mean the gate itself is wrong — and somebody should hear tonight rather than at review.

scope.free and scope.additive

Both are parsed, validated as lists of strings, and then read by two prompts and nothing else: the implementer prompt, which lists them under a ## Scope heading, and the scan lens prompt, where the two lists are concatenated into its Look in: line. They are instructions to an agent, not interlocks. Nothing in the gate compares the diff against them, and no abort code exists for crossing one.

## Scope

You may edit: web/src/**, admin/src/**
Additive only (new files): src/**
Never touch: prisma/, src/common/guards/, src/modules/payments/

If the task cannot be done without touching a railed path, do not touch it —
report it as blocked with the path and the reason, and change nothing.

The third line of that prompt is also enforced; the first two are not. If a path must not be edited, it belongs in rails.forbidden_paths, where a violation aborts. scope.additive in particular describes an add-only rule that nothing checks — an agent that edits an existing file under it produces an ordinary round.

The exemption door

A hard boundary with no door produces a graveyard of permanently blocked items. So there is a door, and it is narrow: one item, one path, one round, with an expiry. The narrowness is structural rather than a rule a caller has to remember — every widening fails isExemptionValid, and the gate consults grants only through that function.

Check Rule
One itemexemption.itemId must equal the item this round picked. A grant for a neighbouring item opens nothing.
One pathString equality only. A grant for prisma/schema.prisma does not open prisma/other.sql, and a grant for prisma/ does not open anything under it.
One roundusedInRound must still be null. Once spent, the grant never opens again.
Expirynow < Date.parse(expiresAt). At or past the expiry it is shut, and an expiry that does not parse is shut.
ContextThe interlock needs both an item id and a clock. Missing either, no grant is considered at all — no clock means the expiry is unknowable, so the door stays closed.

The request comes from the agent, not from looply. When the agent returns outcome: "blocked" with a path and a reason, the runner emits exemption.requested and item.blocked, discards the tree, moves the item to blocked, and picks the next item. It never waits for an answer; the question outlives the round. A grant is made by POST /v1/exemptions with a project, an item, a path, an expiry and an actor. Only an actor of the form human:<deviceId> may grant. keeper, runner, prover and system are refused with 403 and the attempt is recorded as a row with decision: 'refused' — the loop opening its own rails is the hole the rails exist to close. An expiry that is already past is refused outright, because a grant born expired would read as a success and open nothing.

A grant is spent on commit, not on check. A discarded round leaves the door open for the retry; burning it on a failure would strand the next attempt outside its own door. After the commit exists, markExemptionUsed stamps the round number, and the commit itself carries the opening in its trailer, between the proof line and any co-author line. A silently exempted rail is indistinguishable in the log from a rail that never fired.

Checks: tsc ok 4.2s · jest ok 61s · build:admin skipped (no match)
Proof:  proven (0.90) — "adds the tenant column and a migration test"
Exempt: prisma/schema.prisma (granted by human:george)

exemptions: ask versus never

ask means the agent may report a task as blocked on a railed path and have that surface as a request you grant or deny. never means it does not ask. What is built today is narrower than that. The value is parsed, validated as one of those two, and exposed as the project.rails.exemptions setting, but no code branches on it — an agent that reports a block produces an exemption.requested event under either value. And looply run wires neither a source of grants nor a sink to record them as spent, so no grant reaches a gate from a run. The gate's validity check, the core model, the server's grant and list routes, and the trailer line are all built and tested; what is missing is the CLI-side delivery of a grant into a round. The runner refuses to start with a grant source wired and no sink for spent grants, so that link cannot be half-built into a door that never closes. Nothing else about a run waits on this: rounds pick, gate, prove and commit without it. Until it exists, a railed path is closed, and a task that needs one stays blocked.