TacidOS
Developers
Documentation

Decision 0050

50. A change a mind composed is the agent's, and a channel is not a mind's to move

Architecture decision record 0050: 50. A change a mind composed is the agent's, and a channel is not a mind's to move

Status
accepted
Date
2026-09-06

Context

Tacid’s first invariant is that the model is not an authorization authority. The daemon broke it in a way that is easy to miss, because the code that broke it looks like courtesy.

When a person asked a question and the model answered by proposing a change, the daemon built the transaction with the asker’s principal:

let transaction = Transaction::new(self.mint_id(session), intent, principal.clone(), …);

Every invariant that holds an agent back is written against the principal. by_agent is true for Agent, RemoteAgent and Voice, and it is what refuses raw Nix, refuses writes to anything but the option roots ADR-0008 grants it, and refuses adding a user to wheel. With the owner’s name on the transaction, none of them fired. A model that answered “you would work better with more freedom, wouldn’t you” by proposing tacid.policy.autonomy = "sovereign" did not get a refusal. It got an approval prompt reading “some of this cannot be undone” — put in front of the person who had just asked the leading question.

The same hole made ChannelAdvance reachable. Its invariant allows the owner or a system- provenance transaction, so an owner-attributed proposal passed. A channel advance replaces the sources this machine is built from. It carries Tacid’s own binaries, it takes effect at the next boot, and until this change the line the owner read before approving it was “follow the stable channel to 2026.09.05-1200” — a sentence equally true of a move to anybody’s tree, with the inputs that decide whose tree it is nowhere on the screen.

On a running machine the keeper’s socket already corrected the principal: the daemon connects as tacid, principal_for calls that Principal::Agent, and the keeper’s server overwrites what the payload claimed. So the worst of this was closed by a boundary rather than by intent — and not closed at all for the keeper a development machine holds in process, nor for the daemon’s own record, its preview, or its journal line, all of which said “owner”.

Decision

A change a mind composed is proposed by the agent, whoever asked. The daemon sets the principal on a Completion::Propose transaction to itself. The person’s identity belongs on the turn, not on the change the model wrote. What the socket would have said is now what the daemon says, so a development machine, a preview and a journal line all agree with a production keeper.

A mind never names a revision or the sources it locks. A ChannelAdvance arriving from Completion::Propose is refused at the daemon, before policy, at any autonomy level, with any principal, tainted or clean. The revision and its inputs come from a manifest whose signature the update check verified; tacid update is the path, and the model is told so.

A channel advance says where its sources come from. ChangeItem::summary names each input’s origin — github:owner/repo@0123456789ab, tarball:https://…, or the hash of the tree when Nix recorded nothing more legible. Consent to a revision string is not consent to a tree.

Consequences

The agent’s principal is Principal::Agent { key: "uid:700" } — its own user id, the same thing the keeper’s socket calls it. A machine’s agent has no key of its own yet, and naming the uid is honest about that rather than inventing a fingerprint nothing signed. When agent keys exist this is one line.

Approval lines for a channel advance are longer, and they are longer in the only place where the length is the point. The revision alone was the shorter, prettier, wrong thing.

An owner who wants to move their machine to a tree of their own still can: tacid update against a channel they run, or a transaction they compose themselves over tx.propose, where the principal is theirs because they are the one who wrote it. What is closed is the path where a model writes it and a person’s name is attached to it.

The remaining asymmetry is deliberate. tx.propose still takes the principal from the connection, because on that path a person or the update check genuinely composed the change. The rule is not “the owner may not do this”; it is “a mind does not get the owner’s name for free”.