A deterministic gatekeeper for AI agents: one writer and a pure decide() (Lonca series, part 1)

In part 0 I described Lonca as a whole. This post takes the first of its parts, the one the others stand on: a single process writes to the repository, and the function it decides with is pure. Put shortly, Lonca is a deterministic gatekeeper for AI agents. Below I explain what that means, show it on a real log, and then write down what it costs.

Every output in this post comes from Lonca 0.2.3 on my machine. I ran the small demo that is in the repository (docs/demo/demo.sh): two agents and one Rust file.

Why one writer

When I started, the question I could not answer was this: what was this agent allowed to do, and did it do only that? With plain Git the rules live in many places. A hook checks one thing, CI another, branch protection a third, and each of them sees the repository at a different moment. An agent that runs with my token can go around most of them.

So I took an old idea from operating system security, the reference monitor: one small gate, and every write goes through it. In Lonca that gate is the daemon. Agents and humans send their requests to it over a Unix socket. Nobody else writes to the repository. The lonca commands I type are clients of it too.

One writer also gives an order without any extra work. Every request gets a sequence number. When two agents ask for the same function at the same moment, one of them becomes #3 and the other #4, and #4 is decided knowing #3. I don’t have to think about races, because there is no second writer.

Decide, sign, append, apply

Diagram of Lonca as a deterministic gatekeeper for AI agents: state, request, time, policy, facts and evidence go into the pure decide() function, which returns allow, deny or escalate; the daemon then signs, appends to the log and applies.
Everything decide() reads is recorded, and the log is written before the repository changes.

For every request the daemon does four things, always in this order:

  1. Decide. It runs decide() on the request.
  2. Sign. It signs the entry, the decision included, with its own key.
  3. Append. It adds the entry to the operation log (refs/lonca/oplog). Each entry names the hash of the one before it.
  4. Apply. Only now it moves trunk, records the lease or opens the session.

The order matters. The log is written before the repository changes, so there can be a record whose effect is missing, but never an effect without a record. If the daemon dies between steps 3 and 4, it later compares the refs with the log and moves them forward to where the log says. A ref that somebody else changed is another matter: that is recorded as an operation of its own and undone, or the repository is frozen, depending on the policy.

Denied requests are entries too. This is the log of the demo:

Terminal
$ lonca log --repo demo
0 repo.init         allow    human:ayse
1 session.start     allow    human:ayse
2 session.start     allow    human:ayse
3 lease.acquire     allow    agent:claude/ses_c59154aa6d582afa
4 lease.acquire     deny     agent:codex/ses_b0cdba22a46c1b51   lease_held:src/billing.rs#Client.charge:ses_c59154aa6d582afa:reject bad amounts
5 lease.acquire     allow    agent:codex/ses_b0cdba22a46c1b51
6 change.propose    deny     agent:codex/ses_b0cdba22a46c1b51   claim_mismatch:edits, scope_path:README.md, lease_missing:README.md#<file>
7 change.propose    escalate agent:codex/ses_b0cdba22a46c1b51   unobserved_session, approvals=1
8 change.propose    escalate agent:claude/ses_c59154aa6d582afa  unobserved_session, approvals=1
9 approve           allow    human:ayse                         released
10 change.integrate  allow    daemon
11 approve           allow    human:ayse                         released
12 change.integrate  allow    daemon

Entry 4 is the second agent asking for a method the first one already holds. Entry 6 is a proposal that also changed README.md, which is outside the session’s paths. Neither of them changed anything, and both are in the log with their reasons. So the log is also the record of what was tried.

What makes it a deterministic gatekeeper for AI agents

This is the signature of the decision function, as it is in the code:

decide.rs
pub fn decide(
    state: &State,
    actor: &Actor,
    op: &Op,
    human: Option<&HumanSignature>,
    ctx: &Ctx,
) -> Decision
  • state is what the log so far adds up to: which humans are enrolled, which sessions are open, who holds which lease, where trunk is.
  • actor and op are who asks, and for what.
  • human is a human’s signature, when the operation needs one.
  • ctx has the time the daemon gave the entry, the policy in force, and the facts and evidence recorded with the request.

The result is one of three: allow, deny or escalate, with the reasons. A denial wins over an escalation, and an escalation wins over allow. What the function does not know is refused: an operation type it has never heard of, a path outside the scope, a policy rule that fails to evaluate.

The function reads nothing else. It does not look at the clock, open a file or a socket, ask for a random number or call a model. Here is entry 4 as it is stored in Git, with the long hashes cut:

op.json
{
  "entry": {
    "format": 29,
    "seq": 4,
    "prev": "0ff881def7b3d891…",
    "time_ms": 1794556779015,
    "actor": { "kind": "agent", "name": "codex", "session": "ses_b0cdba22a46c1b51" },
    "op": {
      "op": "lease.acquire",
      "set": "lse_b4895b831a4e3fd4",
      "items": [ { "symbol": "src/billing.rs#Client.charge", "kind": "edit" } ],
      "intent": "logging",
      "ttl_ms": 600000
    },
    "evidence": [],
    "facts": null,
    "decision": {
      "outcome": "deny",
      "reasons": [ "lease_held:src/billing.rs#Client.charge:ses_c59154aa6d582afa:reject bad amounts" ]
    },
    "human": null
  },
  "hash": "dfc72046a30673d5…",
  "sig": "dc024ad1b8861d11…"
}

Everything decide() needed to refuse this request is either in this entry or in the entries before it. That is all “pure” means here: same inputs, same decision, on any machine, at any time.

Time is an input

The clock is the easiest way to lose this. Sessions expire, a lease has a lifetime (the ttl_ms above), and some rules count inside a window, like too many reverts of one function in a few minutes. If decide() read the clock itself, a session that was valid on the day would look expired to somebody who replays the log a year later.

So the daemon reads the clock once for each request and writes it into the entry (time_ms). decide() compares everything with that recorded time. The daemon never gives an entry an earlier time than the entry before it, and a log where time goes backwards does not pass the replay.

Evidence is recorded, facts are recomputed

Not everything around a change is deterministic. A build depends on the compiler, and a test can be flaky. So Lonca keeps two things apart. Builds and tests run in a sandbox, and their result is written into the entry as evidence; decide() looks only at that record, and nobody computes it again later. What a change really touched (which functions, which files) and how it merges are deterministic, so those are computed again from the Git objects whenever the log is audited.

Why there is no AI in the decision

I built Lonca with an AI agent, and its users are AI agents. So it is fair to ask why the gatekeeper itself has none. I have three reasons.

  • It could not be replayed. The same model can answer the same question differently tomorrow, and next year that model may be gone. A decision I can’t run again is a decision I have to take on trust.
  • It would read the text of the one it judges. A proposal arrives with a description, comments and file contents, all written by the agent. A model at the gate would read them, and prompt injection would simply move from the agent to the gatekeeper. A function that compares paths and leases can’t be talked into anything.
  • A refusal needs a reason that stays the same. scope_path:README.md says exactly what was wrong, to me and to the agent, and it says the same thing every time.

When the rules can’t settle a request, decide() does not guess. It escalates to a human. You can see this in the log above: entries 7 and 8 are proposals marked escalate, entries 9 and 11 are ayse’s signed approvals, and 10 and 12 are the daemon integrating the two changes. (In the demo every proposal is escalated because no harness observes the sessions. That belongs to part 3.)

AI does have a place, but at the outside. There is an optional advisor that summarises a change for the human who reviews it. Its output is shown to the human and never reaches decide(). And whatever an agent produces, a suggested merge included, is a proposal like any other and goes through the same gate.

What it buys: anyone can check

Terminal
$ lonca-audit verify ~/.lonca/repos/demo.git
operations: 13
head:       35c26915815b18018d7f661a1531d01682e1e039df4cc563fd293ca271b0ef2b
ok

lonca-audit is a separate program. It reads the log, runs decide() again for every entry with the recorded inputs, and compares its answer with the recorded decision. If the two differ, that entry was not produced by this code with these rules. It needs nothing from the daemon, so it can also run in CI, on the copy that was pushed.

Part 4 will be about the auditor. Here I only want to show that it is possible at all, and it is possible because decide() is pure. The tests lean on the same thing: every decision scenario in the test suite ends by replaying its own log, and one of the fuzz targets builds logs and has the auditor replay them.

What it costs

  • One writer is one queue. Decisions are made one at a time. A build or a test run does not hold the writer, but a proposal does while its commit is read. That fits what 0.x is for: one machine with ten or twenty agents. A team server is designed, not built.
  • Pure does not mean correct. If decide() has a bug, the replay repeats the bug faithfully. The auditor is a separate binary and a separate process, but it uses the same decide() code. A second, independent implementation is still an open question.
  • Old entries must replay forever. When I change a rule, the entries written before the change still have to give their old answer. So every entry carries a format version (29 today, as you see above), and decide() keeps the old behaviour for old versions. This is the price I pay most often: a rule change is never only a rule change.
  • One writer holds only if nobody else can write. In cooperative mode the agents run as my user and could write the store’s files directly. That is detected, not prevented. Hardened mode runs the daemon as a separate system user. The three modes are in the table in part 0.

Next in the series

Part 2 will be about symbol leases and the structural merge: how two agents work on the same file at the same time, which is what the demo above did without my saying much about it.

Every post of the series is under the Lonca category. The code is on GitHub, the documentation is on Lonca’s site. Questions and ideas go to Discussions, bugs to issues.

Yorum yapın