TacidOS
Developers
Documentation

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

Status
accepted
Date
2026-09-06

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. sessions is root:tacid 0755 now, because the executor answers the person as well as the daemon — their own uid has been on its caller list since it was written — and 0750 meant 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 is 0660 owned by the person it answers for, and all 0750 added was hiding the names on a machine where /etc/passwd lists the same people. The scribes stay in a directory of their own at root: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 ActionItem now 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::GitPush stays Ask in 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-sign is explicit: signing wants a key and possibly a passphrase, and GIT_TERMINAL_PROMPT=0 means the alternative is a scribe that hangs. A person’s signature is theirs to give.
  • A person with no ~/.gitconfig gets 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_write was 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-only is 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 if in a program the same repository keeps changing. The whole point of ADR-0035 is that it is not an if.
  • 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.write tool 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 FileWrite that 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. GitQuery in the protocol already takes an argv, 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.