# PRD 0027: Agent-level git user identity

- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-28
- **Issue:** #94

## Summary

Let an **agent** file declare `git.user` (name / email). At launch
the agent's `git.user` overlays the referenced bottle's `git.user`
per-field (agent wins on non-empty fields), mirroring the
`extends:` overlay from PRD 0025. `git.remotes` stays bottle-only.

Solves the "I need a whole separate bottle just to change the commit
name" coupling: today commit attribution — a purpose/presentation
concern — can only be varied by authoring a new security boundary
(a bottle). After this change, two agents (e.g. `implementer` and
`reviewer`) can share one bottle and still commit under distinct
identities.

## Problem

`git.user.name` / `git.user.email` is a bottle-only field. Agent
frontmatter is validated against a strict allowlist (`_AGENT_KEYS`
in `manifest.py`); a `git:` block in an agent file dies at parse
with "unknown frontmatter key(s)". So the only way to give an agent
a distinct commit identity is to give it a distinct *bottle*.

That couples identity to the trust boundary. Bottles are home-only
and define security (egress, credentials, provider). Forcing a new
bottle per commit-name means either (a) duplicating a bottle just to
flip one string, or (b) leaning on PRD 0025 `extends:` to make a
near-clone whose only delta is `git.user`. Both are heavier than the
concern deserves.

## Why this is safe (trust analysis)

Allowing agent files — which can live in `$CWD/.bot-bottle/agents/`
and therefore be supplied by a cloned repo — to set `git.user` does
**not** weaken the security model, because git author identity is
not a credential or a capability:

- **Push auth is separate.** Authentication to a remote is the
  bottle's `git.remotes` IdentityFile / token. `user.name` /
  `user.email` grant zero access.
- **It's already forgeable.** The agent inside the bottle can run
  `git config user.email <anything>` or `git commit --author=...`
  at runtime regardless of the manifest. The manifest field is only
  a *default*; allowing agents to set it adds no capability that
  isn't already reachable one layer down.
- **Authorship was never a trust anchor.** If attribution integrity
  matters, that is a commit-*signing* concern (SSH/GPG), which a
  name/email field cannot provide either way.

The one residual is cosmetic impersonation (a cloned repo's agent
file could set an identity that reads like a real person's). Commits
still push under the bottle's credentials, and the author string was
never vouched-for, so this is presentation, not escalation. We
document that an agent identity is *claimed, not vouched*.

`git.remotes` is explicitly **not** lifted to the agent layer — that
block carries credentials and host trust (IdentityFile, KnownHostKey)
and stays a bottle-only, home-only concern.

## Goals / Success Criteria

- Add an optional `git.user` block to the agent frontmatter schema
  (name and/or email), reusing the existing `GitUser` validator.
- An agent's declared `git.user` overlays the referenced bottle's
  `git.user` per-field; each non-empty agent field wins, empties
  fall through to the bottle. Identical overlay semantics to the
  bottle `extends:` merge (PRD 0025).
- `git.remotes` in an agent file is rejected at parse with a clear
  "bottle-only" pointer.
- The overlay is applied at `Manifest.bottle_for()` — the single
  point both backends call to resolve an agent's bottle — so the
  docker and smolmachines provisioners need no changes.
- Existing agent files continue to parse identically — `git.user`
  is opt-in.
- **Identity provenance is surfaced.** The y/N preflight (both
  backends) and `cli.py info <agent>` print the effective git
  identity with a per-field `(agent)` / `(bottle)` annotation so the
  operator can see which level each field came from.
- **The example collapses to demonstrate the pattern.** The bundled
  example replaces the identity-only-bottle shape with one shared
  bottle + per-agent `git.user`, showing the intended usage.

## Non-goals

- **No agent-level `git.remotes`.** Credentials and host trust stay
  bottle-only and home-only.
- **No other agent-level bottle fields.** This PRD lifts `git.user`
  and nothing else; agents do not gain `egress`, `env`,
  `agent_provider`, etc. (that is the issue #88 design PRD 0025
  deliberately rejected).
- **No commit signing.** Attribution integrity via SSH/GPG is a
  separate concern, out of scope here.
- **No CWD-vs-HOME identity gating.** Because the field is
  non-enforcing (see trust analysis), CWD and HOME agents are
  treated the same. If a future change makes identity load-bearing,
  revisit.

## Design

### Schema

A new optional `git:` block on agent files, carrying only `user`:

```yaml
---
bottle: claude-dev
git:
  user:
    name: claude-implementer
    email: eric+claude-implementer@dideric.is
---

You are a feature-implementation agent ...
```

- `git.user` accepts `name` and/or `email` (string-or-die; at least
  one non-empty), validated by the **existing** `GitUser.from_dict`.
- `git.remotes` (or any `git` key other than `user`) in an agent
  file dies at parse: "git.remotes is bottle-only".
- `git` is added to `_AGENT_KEYS`; the agent md-loader threads the
  raw `git` block into the dict `Agent.from_dict` consumes.

### Merge rule

When `Manifest.bottle_for(agent)` resolves the agent's bottle, it
returns the bottle with `git_user` overlaid by the agent's
`git_user`:

| Field             | Merge                                          |
|-------------------|------------------------------------------------|
| `git_user.name`   | agent's if non-empty, else bottle's            |
| `git_user.email`  | agent's if non-empty, else bottle's            |

This is the same per-field overlay `_merge_bottles` already applies
for `extends:` (`manifest.py:1212`). Empty string = "not set", the
same predicate the provisioner's `is_empty()` uses. All other bottle
fields are returned unchanged.

### Where the overlay lives

```
def bottle_for(self, agent_name) -> Bottle:
    agent = self.agents[agent_name]
    bottle = self.bottles[agent.bottle]
    if agent.git_user.is_empty():
        return bottle
    merged = GitUser(
        name=agent.git_user.name or bottle.git_user.name,
        email=agent.git_user.email or bottle.git_user.email,
    )
    return dataclasses.replace(bottle, git_user=merged)
```

Both `provision/git.py` paths (docker + smolmachines) already call
`bottle_for(agent_name)` and read `bottle.git_user`, so they pick up
the merged identity with no edits. The dashboard / `info` / preflight
surfaces that print bottle config go through the same resolution.

### Provenance display

A sibling `Manifest.git_identity_summary(agent_name) -> str | None`
returns the effective identity annotated per field, e.g.:

```
identity        : name=claude-implementer (agent), email=eric+claude-implementer@dideric.is (bottle)
```

`(agent)` when the agent's `git.user` supplied that field, `(bottle)`
when it fell through to the referenced bottle. Returns `None` when no
identity is set at either level (callers omit the line). Both
`bottle_plan.py` preflights and `cli/info.py` print it; `info` today
prints `git remotes` but not the identity, so this adds the line.

### `Agent` dataclass

`Agent` gains `git_user: GitUser = GitUser()`. `Agent.from_dict`
parses the optional `git.user` and rejects non-`user` git keys.

## Implementation chunks

1. **PRD (this commit).** Sets the design.
2. **Schema + overlay + tests.**
   - Add `"git"` to `_AGENT_KEYS`.
   - Add `git_user: GitUser = GitUser()` to `Agent`; parse it in
     `Agent.from_dict`, reusing `GitUser.from_dict`; reject any
     `git` key other than `user` with a bottle-only message.
   - Thread `fm.get("git")` through `_load_agents_from_dir`'s
     `agent_dict`.
   - Apply the per-field overlay in `Manifest.bottle_for()`.
   - Add `Manifest.git_identity_summary()` and print it in both
     `bottle_plan.py` preflights and `cli/info.py`.
   - Unit tests: agent name+email overlay; agent name-only (email
     falls through to bottle); agent email-only; agent identity with
     a bottle that declares none; agent `git.remotes` dies; agent
     with no `git` block unchanged; bottle-only behavior preserved;
     provenance summary returns the right `(agent)`/`(bottle)` tags
     and `None` when unset.
3. **Docs + example.** README manifest section: note `git.user` is
   allowed on agents and overlays the bottle; `git.remotes` stays
   bottle-only. Collapse the example — `examples/agents/implementer.md`
   carries its own `git.user` against the shared `dev` bottle,
   demonstrating per-agent identity without an identity-only bottle.

## Testing strategy

- **Unit (must):** the overlay matrix above, the parse-time reject
  for agent `git.remotes`, and confirmation that `bottle_for` leaves
  every non-`git_user` bottle field untouched.
- **No integration changes needed:** provisioners consume the
  already-merged `Bottle` via `bottle_for`; existing docker /
  smolmachines git-provisioning tests cover the consumption path.

## Open questions

- **Should a `git.user`-declaring agent be loadable as a Claude Code
  subagent file too?** The CC-passthrough keys (`name`, `model`, …)
  let one file double as `~/.claude/agents/*.md`; `git` is not a CC
  key, so a file carrying it won't round-trip as a CC subagent. Not a
  blocker (bot-bottle ignores unknown CC keys; CC ignores `git`), but
  worth noting. Out of scope.
