TacidOS
Developers
Documentation

Decision 0071

0071. A budget per conversation needs a conversation that names itself

Architecture decision record 0071: 0071. A budget per conversation needs a conversation that names itself

Status
accepted
Date
2026-09-07

Status: accepted. Completes the set ADR-0058 began.

Context

PLAN.md §3 promises four per-session budgets: transactions an hour, closure growth, egress bytes, and cloud spend. Two were kept by the keeper’s session ledger. The third moved to the egress proxy in ADR-0058, on the argument that a budget kept by the process it exists to bound is theatre.

The fourth was never built, and SECURITY.md said why:

the proxy does not know whose session a connection is, and a budget kept by the process it exists to bound is theatre with a number in it.

Both halves of that were true. The proxy could count bytes exactly and had nobody to charge them to; the keeper knew whose session it was and could not see a byte leave.

Decision

A session names itself in a header, and the proxy charges the bytes to it.

x-tacid-session, beside x-tacid-secret and x-tacid-classes, set by the daemon from the session the turn belongs to. Prompt carries it because that type is already the per-session envelope — tools and services are both “what this session may use” — and because the alternative was threading a second parameter through every provider for a value the model must never see. It goes into a header the proxy reads and strips, never into a request body: a session identifier handed to somebody else’s model is a fact about this machine’s internals given away for nothing.

Attribution, not authority. The proxy believes what it is told. Everything that can reach that socket is already inside the machine’s trust boundary, and a session name is not a permission — naming a different session buys nothing but a wrong line in a ledger. What it is is data in an append-only file a person reads and a terminal renders, so it is stripped to a plain identifier and bounded at forty characters.

The ceiling is a share of the day, not a lifetime. egressMiBPerSession bounds what one conversation has sent today, because the ledger the proxy reads is the day’s. “Since this conversation began” would need the proxy to be told when that was, which is a second thing to keep true for no gain. What the two ceilings mean is different and the refusals say so: the day’s stops the machine talking, and a session’s stops that conversation while the machine carries on. Refusal::SessionBudget is its own variant for that reason — they are different news to whoever reads them.

A request with no session named is subject to the machine’s ceiling and not to this one. The update check is the machine talking about itself; giving it a share of a conversation’s budget would be inventing a conversation.

Cloud spend stays reported and unenforced, and that is the honest reading of the promise. ADR-0016 says cost visibility is never authoritative — a cost this machine computes is an estimate from a token count and a price list it was told. Enforcing a ceiling on an estimate is enforcing on a guess, and a machine that stops talking because its guess crossed a line is worse than one that says what it thinks it has spent. Bytes are the part that can be counted exactly, so bytes are the part that is enforced.

Consequences

  • Entry::Egress gains an optional session, skipped when absent. Every record already on a disk is byte-identical to what it was, including the one this project pins to a literal digest — which is the migration ADR-0067 makes ordinary: a field nobody set is a field nobody wrote. The frozen digests did not move.
  • The ledger can answer “which conversation spent this”, which it could not before. That is worth as much as the ceiling: an owner reading a day’s egress could see how much and to where, and not which of the things they were doing was responsible.
  • Egress::record takes the attempt rather than seven of its fields, which is what clippy asked for at eight arguments and is better anyway. The one path that had no attempt — a refusal before one was built — now builds a minimal one, so every ledger line has the same shape and no reader has to know that path exists.
  • The proxy’s day-window count carries the session with each spend, so the two ceilings are read from one pass over one window.

Alternatives rejected

Have the keeper keep it. The keeper knows whose session it is, and this is exactly the shape ADR-0058 refused: it cannot see a byte leave, so what it would bound is what the daemon told it about the bytes — a budget kept by the process it exists to bound.

Derive the session from the connection instead of a header. The daemon opens one connection per request to the proxy and SO_PEERCRED gives a uid, not a conversation. Every session on a machine runs as the same daemon.

Refuse a session name the machine did not mint. It reads as defence and is not: the caller is inside the trust boundary, so a forged name is a name a trusted process chose to write down wrongly. Checking it would mean the proxy holding a list of live sessions, which is a second copy of the daemon’s state, kept in the process furthest from it.

Enforce a cost ceiling too. See the decision. A price list this machine was told is not a fact about what anything cost.