Decision 0055
55. A write in a person's repository is a second process, and an action transaction
Architecture decision record 0055: 55. A write in a person's repository is a second process, and an action transaction
Refines: ADR-0006, ADR-0035, ADR-0044
Context
ADR-0044 gave the agent five ways to read a git repository as the person whose it is, and closed with a line that named its own hole: “writes wait for a writable executor.” Reaching for that hole turned up something bigger behind it.
The plan’s Phase 5 item is “git tools with destructive-action classification”. Most of that already
existed and had done since the founding: ActionItem::GitCommit and GitPush are in the protocol,
the classifier judges them, Capability::GitCommit is Auto and GitPush is Ask in the baseline
policy. What was missing was not a classification. It was that nothing on the machine had ever
carried out an action transaction at all.
ADR-0006 describes two kinds in one envelope: a change, which renders Nix and becomes a
generation, and an action, which is done once and is over. The change half was built end to end.
The action half had a type, a classifier and a policy entry, and no path from a model to a
consequence: the daemon never composed one, and the keeper’s apply — which wants a store path and
a generation number — had nothing to do with one. So git writes were not a git feature waiting on a
git tool. They were the first user of an unbuilt half of the transaction API, and building them
meant building it.
The second thing is smaller and decided the shape. ADR-0035’s executor promises that the process
which can read everything a person owns changes none of it, and that promise is a line in a systemd
unit: ProtectHome=read-only. A version of this that added a write method to that executor would
have replaced a kernel-enforced promise with a branch in a Rust program.
Prior decisions
ADR-0006 defines the two kinds. ADR-0035 puts a person’s files behind an executor that runs as them
and reads only. ADR-0044 gave that executor git. ADR-0004 says tacidd requests, the keeper
authorises, the stem executes. ADR-0009 makes reversibility the boundary and says it is computed.
Decision
The scribe. A second executor per person: the same binary with --write, a different unit, a
different socket, a home that is not read-only, one method, and one caller. It is a second process
rather than a flag because the reader’s promise is a unit setting and not a program’s good
intentions. Its socket is group tacid-keeper and it checks the connecting uid as well, because a
group is a mode bit and a uid is the kernel’s answer and neither alone is a reason to write in
somebody’s home. Its path is git and coreutils: it opens no file whose bytes came from outside, so
it needs no parsers, and a program that is not on the path cannot be run. RestrictAddressFamilies
is AF_UNIX and IPAddressDeny is any.
The daemon cannot reach it at all. The daemon is not in that group, so the process that talks to the model cannot open the socket that writes in a home, whatever the model talks it into. The keeper is the caller — the same shape as the stem: consent must not travel through the process it authorises, and neither must the act.
Two errands, not one. executor.git-write was the whole of it while git writes were the only
thing anything wrote; executor.file-write is the second, and it is what the generated development
shell has been waiting for since ADR-0046. Tacid writes a flake.nix for a project that has none
and shows it, because Tacid writes nothing into a project — and a person who reads it and says
“yes, save that” had nowhere for the word to go. Now they do, by the same road: a FileWrite
action, classified, snapshotted where a snapshot covers it, and carried out by the person’s own
process.
It is a second method rather than a branch inside the first because a file write is not a git write and pretending otherwise would put a discriminator inside the one call the whole design says has no discriminator in it. What stays exactly as it was is the half that matters: the reading executor refuses both, by method name, before either is decoded.
The content travels in the action item, and the answer says it. An action has no second and third
level of the preview ADR-0020 describes — nothing is rendered, so there is no option diff, and
nothing is built, so there is no log — which meant the model’s one line was the whole of what a
person saw before it happened. “Write 2 lines into shell.nix” is a signature on a blank page, and
the case this exists for is precisely the one where the person did not type the text. So
ActionItem::detail says what would be in the file, the daemon puts it under the sentence saying
it will, and it is the machine’s own account rather than the model’s. That puts a bound on the
content: the executor refuses more than 64 KiB, not as a resource limit but because an answer
nobody could read is consent nobody gave.
And the scribes are in a directory of their own, /run/tacid/scribes, root:tacid-keeper 0750.
That line was /run/tacid/sessions/<name>.write.sock until a machine was booted with it, and the
booted machine showed what reading the mode bits could not: a socket is reachable only through every
directory above it, and sessions is root:tacid 0750 so that the daemon’s group can walk into
it. tacid-keeper is not in the group tacid and must not be. So the socket’s own group refused
the daemon, exactly as designed, and the directory refused the keeper one level earlier — the only
process permitted to write in a person’s repository could not open the door to it, and every
permission on the socket itself was correct. Two permitted callers, two directories; the daemon
cannot even list this one.
The mode this paragraph cites has since changed, and the decision has not.
sessionsisroot:tacid 0755now, because the executor answers the person as well as the daemon — their own uid has been on its caller list since it was written — and0750meant they could not walk in to reach the socket that answers to them. Nothing had noticed until a key had to open their microphone, because until then everything went through the daemon. What kept one person out of another’s was never that mode: each socket is0660owned by the person it answers for, and all0750added was hiding the names on a machine where/etc/passwdlists the same people. The scribes stay in a directory of their own atroot:tacid-keeper 0750, which is what keeps the daemon from listing them, and that is the half this record was actually about.
keeper.act, apply’s sibling. Same submission, same classifier, same consent, same journal;
it diverges where a change needs a store path and a generation and an action needs neither. It
snapshots first when the classification says the action touches data, which is what makes the word
“reversible” on the line an owner reads mean something (ADR-0054).
An action item nothing on this machine carries out is refused by name, because a transaction the
classifier approves, the owner consents to, the journal records and the machine ignores is the exact
shape of promise this project exists not to make.
Two operations, one on each side of the line. GitWrite::Commit stages everything and commits:
reversible, because git keeps what it replaces. GitWrite::Discard puts paths back to the last
commit: irreversible, because it destroys work git has no record of, so Capability::GitDiscard is
Ask and the reason the owner is shown is DataDeletion. Each is a fixed argument vector, like the
reads. A commit message goes to git on standard input — it is a person’s words, and a person’s
words do not go where a program looks for options. A discard’s paths are checked to be relative,
inside the repository, and not .. or .git, and are passed after --.
Reading is not writing. resolve admits a path against filesystem_read, which is right for
the four things that read and wrong for the one that writes; the policy has always had a second,
deliberately narrower list — ~/projects in the baseline — and the comment beside it said that
when something did write, this is the list it would be held against. Something does. It is checked
after the read list and against the repository rather than against the path asked about,
because a commit stages a whole tree: a grant covering one file inside a repository is not a grant
to commit the rest of it. The first version of this change did not check it at all, and would have
let the agent write anywhere it could read.
Whose home, from the path. The transaction’s principal is the agent, deliberately: the agent
composed the change and the journal should say so. The agent has no home. So the person is worked
out from the repository’s path — under the owner’s home, or <homes>/<name>/… — and the scribe
checks it again from the other side, resolving every path against its own person’s home and
refusing anything outside. Two independent answers to “whose is this”, and a write needs both.
propose_action, beside propose_change, generated from ActionItem the same way. One
vocabulary, two spellings across the two provider families, one shared parser: what an action is
must not differ between them.
Consequences
- The action half of the transaction API exists, and git writes are its first user rather than
its whole purpose. Serial ports, flashing and the rest of
ActionItemnow have a path to walk down; today they walk into a named refusal, which is the honest state and is visible in the code. - A commit by the agent is snapshotted first, so “reversible” is backed by the mechanism ADR-0054 finished rather than by an argument about how git works.
- A file Tacid generated can be saved by the person it was generated for. ADR-0046’s rule is unchanged and is now enforceable rather than merely obeyed: Tacid still writes nothing into a project on its own, and what it writes when asked goes through the classifier, the snapshot and, where the snapshot does not reach, the owner.
~is a declared snapshot root in the baseline, so on a default machine a write in somebody’s home is reversible and does not stop to ask — the snapshot is taken first and the file can be put back. A write outside every declared root asks, and a write in nobody’s home is refused by name rather than attributed to somebody.- Push is not here, and the reason is structural rather than an omission of effort: the scribe
has no network by construction, and a push would have to go through the egress proxy — which for
HTTPS is a proxy variable and for SSH is a different piece of work.
Capability::GitPushstaysAskin the policy and the classifier still marks it a publication; what is missing is the hands. - A commit the machine makes is not signed.
--no-gpg-signis explicit: signing wants a key and possibly a passphrase, andGIT_TERMINAL_PROMPT=0means the alternative is a scribe that hangs. A person’s signature is theirs to give. - A person with no
~/.gitconfiggets git’s own complaint, and that is the right answer. Writing somebody’s name into their commits because Tacid needed one would be the machine deciding who they are. - The two filesystem lists finally differ in effect. Until now nothing wrote, so
filesystem_writewas a setting with no consequence and an owner narrowing it changed nothing. - Two units per person instead of one. The cost is real and it buys the one thing worth buying:
after this change,
ProtectHome=read-onlyis still true of the executor that reads.
Alternatives rejected
- A write method on the reading executor. One process, one socket, much less code — and the
promise that makes the reading executor safe becomes an
ifin a program the same repository keeps changing. The whole point of ADR-0035 is that it is not anif. - The daemon calling the scribe. It already holds executor clients, so this is the shortest path. It also puts the least-trusted process on the machine in charge of writing in homes, with the keeper reduced to something it is expected to have asked first.
- A
git.writetool rather than an action transaction. Tools return synchronously and hold no authority; a tool that needed consent would have had to grow its own approval path beside the one that exists. The transaction lifecycle is the answer to “this needs the owner’s word”, and using it is cheaper than having two. - A
FileWritethat names a handle instead of carrying its content. It would keep transactions small and the journal short. It would also mean the sentence the owner approves and the bytes that land are two different things joined by an identifier, which is the shape of every confused-deputy bug there is. - Letting the model name an argument vector.
GitQueryin the protocol already takes anargv, which is fine for a query nobody runs. For writes the set is closed and each member is a fixed vector, for the same reason the reads are.