Decision 0058
58. A budget belongs in the process the bytes pass through
Architecture decision record 0058: 58. A budget belongs in the process the bytes pass through
Status: accepted. Completes ADR-0009’s third budget, in a different process from the other two.
Context
ADR-0009 gives the owner three numbers: transactions per hour, closure delta, and mebibytes of egress per day. Two of them worked. The third was a field.
SessionLedger held all three. The keeper fed the first two at the moment it applied a change,
which is where it knows what happened, and both refuse. Nothing anywhere called record_egress,
so egress_today_mib was always zero and BudgetBreach::Egress was a variant no machine could
ever construct. The code said so — a nine-line doc comment confessing that the limit could not be
reached — and PLAN.md repeated the confession in its own words. An owner reading
egressMiBPerDay = 200 in their genome had every reason to believe it meant something.
Two things kept it unbuilt, and both were right as far as they went.
The first: the only count worth trusting is the proxy’s, because the proxy is the process the bytes go through. Having the daemon report its own traffic would be a budget kept by the process it exists to bound — one line of arithmetic away from a captured agent reporting zero.
The second: the ledger is per session, and the proxy does not know whose session a connection belongs to. Closing that appeared to need the daemon to label its requests, which is a header the daemon could get wrong and an attacker could get wrong on purpose.
The second objection dissolves on inspection. The budget is a day’s worth of bytes crossing a
machine’s boundary; its own refusal message said “has left this machine today”. A machine is not a session,
SessionLedger was already one per keeper rather than one per session, and no per-session
attribution is needed to bound how much a captured loop can send. The work was never “teach the
proxy about sessions”. It was “put the budget where the bytes are”.
The first objection is not an obstacle at all once the budget moves: the proxy counting its own traffic is not a process reporting on itself to an authority, it is the authority. The daemon is never asked.
There is also a place where a budget checked by the keeper could not have worked even if it were fed. The keeper judges transactions. A model call is not a transaction. A keeper that has reached its egress ceiling would turn the next configuration change into a question while the loop that spent the day’s bytes carried on sending, which is refusing the wrong thing.
Decision
The day’s egress budget is kept by the egress proxy, over the proxy’s own ledger, and refused before a connection is opened.
-
One declaration, two enforcers. The owner still writes all three budgets in one place,
tacid.policy.budgets. The system layer rendersegressMiBPerDayinto the proxy’s unit environment asTACID_EGRESS_BUDGET_MIB, from the same Nix option that renders the keeper’s policy document, so the two cannot disagree. This is the pattern the allow-list already uses and for the same reason: how much a machine may say is not a secret, and putting it in the credentials file — the one file nobody else may read — would put it beyond review. -
Counted from the ledger, not beside it. The proxy already writes one hash-chained record per request, refused ones included, with the bytes. The budget is a window over exactly those records: what the budget knows is what the machine can show it wrote down. At startup the proxy reads the last day back out of its own ledger, so the count survives a restart — a budget a runaway loop can clear by crashing the process that keeps it is not a budget. A proxy given a ceiling that cannot read its ledger does not start.
-
Refused before the connection.
Refusal::Budgetsits beside the airgap check, before the allow-list, because both are answers about the machine rather than about the request: one that has said its day’s worth should stop talking, not work out who it was about to talk to first. The status is 429 rather than 403 — the same request tomorrow is fine, and that is the one status that says so. -
Overshot by at most one exchange, and said out loud. The check is over what has already left, so the request that crosses the line is the one that goes through. The proxy cannot know what an answer will weigh before asking for it, and a design that pretended otherwise would be worse than one that says how far it can be exceeded: one request body, at most
MAX_BODY, plus whatever the far end replies with. -
An owner cannot declare a ceiling of nothing, and a typo is one. The option is a positive integer, because “nothing leaves this machine” is already something Tacid can say and it says it with the airgap; a second way to say one thing is a second thing to keep true. Absent — a development run, a test — means no ceiling. A declaration that is not a number is read as a ceiling of nothing and shouted about, because a machine whose budget is a typo must not decide for itself which way the typo meant, and of the two directions only one is safe.
-
The keeper’s ledger loses the variant.
BudgetBreach::Egress,record_egressandegress_today_mibare gone rather than left as a second enforcement point for one number. One test remains in their place, asserting that a ceiling of nothing produces no breach from that ledger, so a counter cannot quietly reappear where it could only ever be fed by the process it was meant to bound.
Consequences
- The third budget is real. An owner who writes
egressMiBPerDay = 200gets a machine that stops at roughly 200 MiB a day, whatever the agent is doing, and a 429 in its ledger saying so. - Per-session egress attribution is not built and is not needed for this. Should the ledger ever want to answer “what did this conversation send”, the daemon labelling its own requests is fine for that purpose — a label it can get wrong is a labelling problem, not a budget one, since it can still not hide a byte from the process counting them.
- The proxy reads its whole ledger once at startup when a budget is set. On a machine whose ledger has grown for a year that is a file read at boot, which is acceptable and will stop being so; retention on the egress ledger is the answer when it does, not a second count kept elsewhere.
- Cloud spend, the fourth number ADR-0009 mentions and the one nothing declares, is still not built. It is not on this road: spend is a provider’s arithmetic over tokens, not a count of bytes, and it belongs to the model router.
- A proxy that cannot read its own ledger at startup, with a budget set, refuses to start. That is the same judgement as the ledger it cannot write: a machine that cannot account for what it sends must not send.
Amendment, 2026-09-07: what it counts is both directions
Three sentences here and in SECURITY.md said the budget was a day’s worth of bytes leaving.
The proxy has always counted sent + received. That is a promise stated about one number and kept
about another — the shape ADR-0069
named — and it was found by asking a question with nothing to do with budgets: could this machine
ever download a four-gigabyte model under a two-hundred-mebibyte day?
The check is the one that stayed. A ceiling counting only what left would not bound a loop that
pulls, and a security ceiling is not loosened to make a feature possible. What changed is the
sentences, in all four places, and a tacid-egress test now sends nothing and is answered with
two mebibytes, so the meaning cannot drift back.
What follows for a model download is that it needs a path of its own rather than a bigger number: an owner-approved transfer of a known size to a known host with a known hash. That is not built.