Guide 4

The ladder

A constraint has a rung: the mechanism that carries it. Write "money is Decimal" in CLAUDE.md and you have rung 0, which is prose, and prose is a request. Write a hook that refuses the write and you have rung 2, which is a guarantee. Same rule, same words, different thing entirely.

Everything else in ghola is a pipeline you could write yourself. This is the idea worth taking.

There are two ladders

A constraint withholds. A capability grants. They climb in opposite directions, they meet at the grant, and ladder::list returns both sides in one answer. A reader who takes only the constraint half has taken half the idea, so both are below and both are the point.

flowchart TB
    k4["rung 4 · a tool, every agent by name"]
    k3["rung 3 · the org, everybody"]
    k2["rung 2 · the team, every repo it owns"]
    grant["rung 1 · THE GRANT
what this phase may call"] c2["rung 2 · a hook on the tool call"] c3["rung 3 · a callback inside the turn"] c4["rung 4 · the delivery gate, over the diff"] c5["rung 5 · CI, after everybody left"] k0["rung 0 · prose. A capability nobody wired"] c0["rung 0 · prose. A rule nothing enforces"] k4 --> k3 --> k2 --> grant grant --> c2 --> c3 --> c4 --> c5 k0 -. "asks" .-> k2 c0 -. "asks" .-> c2 classDef cap fill:#1A100A,stroke:#A4470F,color:#E8A33D classDef con fill:#23150C,stroke:#6E2E13,color:#E7C982 classDef hinge fill:#3A2112,stroke:#E7C982,color:#E7C982,stroke-width:2px classDef prose fill:#0F0A06,stroke:#3B2515,color:#AB9070 class k4,k3,k2 cap class c2,c3,c4,c5 con class grant hinge class k0,c0 prose
capability climbs toward wider reach, a constraint climbs out of reach, and rung 1 is the hinge both are measured from

The constraint ladder

Six rungs. What changes as you climb is not how strict the rule is, but what the mechanism carrying it can see.

RungCarried byWhat it can see
0prose in the charternothing. It asks
1the grant: what the phase may callfunction ids, before any call
2a hook on the tool callthe arguments, before the write lands
3a callback inside the turnthe call, and it may hold it for a person
4the delivery gatethe finished diff, before the commit
5CIthe merged tree, after everybody left

Two things follow, and both are the point.

A rung is a place, not a strictness. Rung 3 sees a function call, and a shell command is one call whose contents it does not read. Rung 4 sees the diff and never sees the call. Neither can see what the other sees, so a rule that matters names both.

Climbing costs something. Rung 0 is free and enforces nothing. Rung 2 needs a predicate somebody writes and maintains. Rung 5 catches everything, and it catches it after everybody has gone home. Pick the cheapest rung that can actually see the thing your rule is about.

The capability ladder

Capability climbs too, and in the opposite direction. A constraint gets stronger as it moves out of the agent's reach. A capability gets stronger as it reaches further, from one repository out to every agent everywhere.

RungWhere it livesWho gets it
0described in prosewhoever reads it
1the projectthis repository
2the teamevery repository the team owns
3the orgeverybody
4a toolevery agent, everywhere, by name

The two ladders join at rung 1. A constraint withholds a function; a capability grants one. ladder::list returns both, plus withheld, which is the list the factory subtracts from a phase's grant before the turn starts.

So the grant is the one place you have to hold both ladders at once. It is the top of neither and the hinge of both. What a phase may call is the sum of the capabilities that reached it, minus everything a constraint took back.

A primitive is two files with one name

team/rules/no-secrets.md is the rule. team/rules/no-secrets.py is the predicate. The .md alone gives you rung 0. Add the .py and the same primitive turns mechanical, and the ladder works out the rest:

  • kind comes from the directory. rules/ is a rule, skills/ is a skill.
  • side comes from the kind. A rule constrains, and a skill grants, so one primitive shape serves both ladders.
  • layer is the one thing you declare: project, team, or org.
  • rung follows from the layer and from whether a script exists beside it.
  • direction follows too. A rule with a predicate runs before the write and is feedforward. One without it can only be read afterwards.
flowchart LR
    md["no-secrets.md
the rule"] --> prim py["no-secrets.py
the predicate"] --> prim prim["ONE PRIMITIVE"] prim --> kind["kind ← the directory"] prim --> side["side ← the kind"] prim --> layer["layer ← you declare this"] prim --> rung["rung ← layer, and is there a script"] prim --> dir["direction ← is there a script"] classDef file fill:#1A100A,stroke:#6E2E13,color:#E8A33D classDef hinge fill:#3A2112,stroke:#E7C982,color:#E7C982,stroke-width:2px classDef derived fill:#23150C,stroke:#3B2515,color:#AB9070 classDef declared fill:#23150C,stroke:#A4470F,color:#E7C982 class md,py file class prim hinge class kind,side,rung,dir derived class layer declared
one declared field, and the file layout decides the rest

So a primitive declares one field. Where you put the files decides the rest, which is what stops a rule and its enforcement drifting apart. They are the same primitive.

Three mechanisms, three different questions

Do not confuse these. They sit in front of the same call and answer different things.

  • ladder decides what a rule says. Deterministic, and it refuses in the rule's own words.
  • approval-gate decides what a person says. It holds the call and waits.
  • opengantry decides what a machine can prove. It verifies rather than judging.
sequenceDiagram
    autonumber
    participant T as the turn
    participant L as ladder
    participant A as approval-gate
    participant O as opengantry
    participant F as the function

    T->>L: I want to call this
    L-->>T: refused, in the rule's own words
    Note over L: deterministic. The dial never turns this off
    T->>A: the calls the ladder allowed
    A-->>T: held, until a person answers
    Note over A: how much of this runs is the oversight dial
    T->>O: the calls a person allowed
    O-->>T: unproven, so not allowed
    O->>F: proven
    
three mechanisms in front of one call, answering three different questions

The oversight dial in settings/oversight.yaml changes how much of the second one runs. It never turns the first one off, and ask never becomes allow at any level.

Running it

The ladder is a worker rather than a library, and it ships inside ghola at workers/ghola-ladder. make up starts it with everything else:

make up
make call FN=ladder::list JSON='{"repo":"/path/to/repo"}'
What I tried first

It runs as a host process rather than as a sandboxed package worker, which is what path:// says in worker-compose.yaml. I tried the sandboxed form first, and it puts a worker in a microVM that mounts only the worker's own source. The target repository does not exist inside that sandbox. So the ladder read a .claude/settings.json that was not there, reported a repository with no permissions, and looked exactly like a ladder enforcing nothing. Anything that inspects a target repository has to run where that repository is.

It is a copy, and it is meant to be swappable

The rung is the one idea here worth having without the rest of ghola, so the ladder also lives on its own at tacoda/ladder. That is where it becomes a worker other projects install. The copy in ghola exists so one clone is the whole thing.

Point LADDER at a checkout and that checkout serves instead:

git clone https://github.com/tacoda/ladder.git ../ladder
make up LADDER=../ladder
make status                  # names the provider that is serving

The seam is the function id rather than an import. Nothing in ghola imports the ladder package. Every caller triggers ladder::list, ladder::evaluate, ladder::move or ladder::explain over the bus, and ladder::gate binds itself to the harness's pre-trigger hook. So exactly one provider registers those ids, and no call site changes when you swap, because there is no call site to change.

Keep that true if you edit either copy. A shortcut that imports the ladder directly would weld the two together and take the swap away.

What to ask it

make call FN=ladder::list     JSON='{"repo":"..."}'   # everything, both sides
make call FN=ladder::explain  JSON='{"repo":"...","id":"no-secrets"}'
make call FN=ladder::evaluate JSON='{"repo":"...","path":"x.py","content":"...","rung":3}'
make call FN=ladder::move     JSON='{"repo":"...","id":"no-secrets","move":"promote","to":3}'

list answers both ladders at once. So its output has three parts: a constraint side, a capability side, and the withheld list that reconciles them.

explain answers the question people get wrong. Not "is this strict enough" but "can the mechanism carrying it see the thing it is about".

move changes a file and commits nothing. A change to a primitive is a change to a repository, and it goes through whatever that repository does with changes. It serves the whole lifecycle for both sides: promote, demote, add and remove.

Reading what it tells you

ladder::list reports measured_share: the fraction of primitives carried somewhere a machine records what they caught. That number does not make feedforward reliable. It makes the gap visible and counted, which is the whole job.

Two numbers to watch in make audit:

  • Refusals per rung. One rung catching everything is a signal about how your turns write, not an argument for tightening anything.
  • Rules that never fire. A rule carried mechanically that has never fired is either settled or theatre. A rule carried at rung 0 cannot fire at all, so its silence tells you nothing. ghola's improve lane reports the two cases separately for exactly that reason.