Decision 0057
57. A change nobody was asked about is kept until somebody has been told
Architecture decision record 0057: 57. A change nobody was asked about is kept until somebody has been told
Status: accepted. Completes ADR-0009’s definition of level 2, using the stream ADR-0052 built.
Context
ADR-0009 defines the default autonomy level in six words: “acts with notification when the classifier proves reversibility”. The acting half has been true since the keeper existed and is tested from several directions. The notifying half was never implemented and nothing anywhere would have failed if it never were.
It was easy not to notice, because most of the time it looks satisfied. A person asks for something, the machine does it, and the answer to what they asked says what happened; their other surfaces hear the same thing over the event stream (ADR-0052). That is a notification, and it arrives without anybody having built one.
The case it is not is the case the whole design is for. tacid-update proposes a channel advance
on a timer. The keeper allows it — a channel advance is reversible, and asking would make
self-updating a thing you approve at four in the morning. It is applied, it is boot-class, the
machine reboots into it. Nobody asked and nobody was told. The change is in the journal, and the
only way to learn of it is to suspect it and go and read. ADR-0052 named this in its own
consequences as the thing the stream unblocked, and then it stayed unbuilt.
The obvious shape — the keeper works out for itself which changes nobody saw — does not work and
should not be made to. The keeper’s server overwrites transaction.principal with the connecting
peer, deliberately, because policy is evaluated against a process and not against a name in a
payload; every proposal therefore arrives as the agent. A keeper reading that field to decide who
was present would conclude that a person typing into their own terminal was not there.
Decision
Every change the keeper allows without asking is kept, until somebody says it has been shown to somebody.
- The keeper keeps it. On
Applied, for a transaction whose verdict wasAllow, the keeper records what the change was in the owner’s words, when, which generation it produced, and its own sentence for why nobody was asked. A change the keeper asked about was shown to somebody by the asking, so it is never on the list; a change that was rolled back never happened. - Being told is an act, and it is journalled.
Keeper::seentakes who and which, writesEntry::Seen, and lets go. There is no way to clear the list without leaving a line in the append-only record with a name on it. - The daemon says it for the answer it is about to write, and only when the peer credentials
on that socket are a person’s.
tacid askandtx.proposefrom a person’s script both count; the update timer’s connection does not, so what it did stays. - It survives a reboot, which is the case it exists for. The keeper reads the list back out of its own journal when it starts, rather than keeping a second record that could disagree with the first, and a test holds what a keeper accumulated against what a fresh one reads back.
- Every surface says it, and reading is what clears it.
tacid statusprints it and, when its output is a terminal rather than a pipe, tells the keeper it has been read. The interface prints it into the transcript as it opens. The bar counts it; the panel writes it out and clears it.
Why the default is “not told”
The direction is the whole decision. Starting from “told” and requiring evidence that nobody saw a change makes the silent path the suppressing path: a daemon that says nothing suppresses a notification, and saying nothing leaves no trace anywhere. Starting from “not told” makes suppression a call. A compromised daemon can still make that call — it can claim a person was shown what the machine did overnight — but it has to make it, and it appears in the journal as the agent claiming so, beside the change it is claiming about.
Why on the keeper and not the daemon
The daemon knows who owns which session; it would have been the shorter path. It is also the
process that made the change, and a machine’s account of what it did without being asked must not
be relayed by the process it did it for — the same rule that puts consent on the keeper’s socket
and activation behind the stem. So the list is the keeper’s, unattended.list and
unattended.seen sit beside approval.pending, and every surface that already asks the keeper
what is waiting asks it this in the same breath.
And only a person can be told
The keeper is the one that says so, not only the daemon that calls it. The daemon already refuses
to report a change to anything but a person, and the daemon is the least-trusted process on the
machine and the one the model talks to: a guarantee that lives only in its code is a guarantee the
model is one bug away from. The keeper’s socket is open to every account on the machine by design —
it decides by peer credentials rather than by mode bits — so without a check of its own, any daemon
on the machine could call unattended.seen and empty a queue nobody had read.
“A person” is the line Principal::may_approve already draws, and it is drawn here for the same
reason: a numbered account, the owner, or a voice in the room. nginx is not somebody. Neither, at
a socket, is uid 0 — it is the administrator at a console and it is also every unit on the machine,
and a connection cannot tell those apart. The cost is real and small: somebody working as root
dismisses nothing and logs in as the owner to do it. What it buys is the direction of the failure.
The bar, and the surface that is still refused
README.md refuses a notification surface of Tacid’s own on the grounds that what this machine has
to say fits in a word on a strip that is already on the screen. This is the second word. It is not
amber — nothing is wrong and nothing is being asked — and it is a count rather than a summary,
because a bar that tried to say what changed would be a bar that truncated it. The panel is where
it is written out, and opening the panel is being told.
Other programs’ notifications, over the freedesktop protocol, remain the case that would justify a surface, and remain open.
Consequences
- Autonomy level 2’s definition is now a thing that can fail. Three tests at the daemon layer hold the pair of it: the same change, proposed the same way, is told about when a person proposed it and kept when a timer did, and a person asking for something of their own does not clear what the machine got up to overnight.
- The journal gains a line per change made in a session, saying the person who asked was shown the answer. That is not noise: whether the machine kept level 2’s promise is exactly the sort of thing an append-only record of its authority should carry.
Principal::is_a_personexists, and is asked of a connection by the process answering it — never of a transaction, where the answer is always the peer. Voice counts; a remote agent does not.- What is not covered. Anything the machine does that is not a transaction. Generation
collection runs on a timer through
generations.collect, which has no transaction and therefore no entry here; it is bounded by a retention the owner declared and it removes nothing the owner asked to keep, but a machine that says what it changed should eventually say that too. - What is not proven. That a person who was away for a week reads a list of forty changes and is better off. The list is unbounded by design — ageing it out would silently drop exactly the notification somebody missed — and if it turns out to need shape, shape is a rendering decision and not a reason to forget.
Alternatives rejected
- Let the keeper infer who was present. From the transaction’s principal it cannot: the server overwrites that with the peer, on purpose. From the session it cannot either: every proposal carries one, the update timer’s included, because the daemon mints a session for a direct proposal so that its events reach whoever asked.
- A claim in
keeper.submitabout who is behind the session. It would work, and it would make suppression free and silent again — the daemon would assert attendance rather than assert having told, and an assertion made at submission time leaves nothing behind when the change is applied. - Age the list out after an hour, or a day. No number is defensible, and every one of them fails in exactly the case the mechanism exists for: the person who was away longer than the number is the person who most needed telling.
- A transient notification surface. A layer surface mapped only when there is something to say is not the always-mapped rectangle the README refuses. It is also a second place to render what the panel already renders, on a machine whose whole claim is one design system and one protocol, for a message that fits on the strip.
tacid unattended, a verb of its own. A verb is a place to hide it. The point is to reach somebody who did not know to ask.