# zuka — technical specification

Status: scoped, not started. Milestones, risks and open decisions live in
[`ROADMAP.md`](ROADMAP.md); this document is the durable technical reference. Where
the two disagree, this one wins on *how* and the roadmap wins on *when*.

Product framing, positioning and non-goals: [`README.md`](README.md).

---

## 1. Architecture

### 1.1 Three modes, one binary

```
standalone   Engine only. Repos on local disk, tokens from a local file.
             No outbound calls. This is what a self-hoster runs. Default.

control      Control plane. Owns accounts, provisioning, routing. Holds no
             repositories. Proxies data-plane requests to tenant containers.

tenant       Engine inside an Incus container behind a control plane. Trusts a
             signed identity assertion instead of verifying tokens itself.
```

`standalone` and `tenant` share the request-handling path exactly. They differ in
boot configuration and in the CI isolation model (§6.3) — that is the honest claim;
"identical but for one function" would not survive §6.3 or §9.

```
 agent ──MCP───►┌──────────────────────────────┐
 app   ──REST──►│  facades:  mcp/    api/      │
                └──────────────┬───────────────┘
                               │ same core calls
                ┌──────────────▼───────────────┐
                │  core:  git/  ci/  account/  │
                └───┬──────────────────────┬───┘
 git CLI ──────►git/transport.rs           │
                    │                      │
             ┌──────▼──────┐        ┌──────▼──────┐
             │ filesystem  │        │     KV      │
             │ bare repos  │        │  metadata   │
             └─────────────┘        └─────────────┘
```

**Facades carry wire-shape translation and client ergonomics. They never carry
authorization or concurrency rules.** Defaults and argument-filling are allowed in
`mcp/` (§5.2); access checks and ref preconditions are not, and never appear twice.

### 1.2 Git access

Everything goes through `git` subprocesses, funnelled through `git::exec::Git` —
the single place this service invokes git. Before that module existed there were five
wrappers and nine ad-hoc spawns with three different environment policies, so
`/etc/gitconfig` applied to `git init`, `git config`, `merge-base --is-ancestor` (the
ref rule) and `count-objects` (quota) but not to the wire protocol. Half a control is
not a control; `Git` clears the environment and sets `GIT_CONFIG_NOSYSTEM` on every
invocation.

`gix` is not used. A subprocess per read is a real cost, mitigated by running them on
a blocking pool rather than a runtime worker; migrating the read path is a
measurement-driven decision and no measurement has been taken.

**Error mapping matters here.** `Git::query` treats exit 1 as an answer ("no such
object", "no matches") and anything higher as a fault. An earlier version collapsed
every non-zero exit into `404`, which meant a corrupt repository reported as empty
and nothing paged anyone. Existence is gated on `rev-parse --verify --quiet`, which
exits 1; `cat-file` exits 128 for the same condition and cannot be used for it.

#### Wire protocol

- **Reads and API writes** use `gix`. Refs, trees, blobs, log, and building a
  tree+commit in-process. No working copy, no subprocess.
- **The wire protocol** delegates to **`git http-backend`**, the reference CGI. Not
  `upload-pack --stateless-rpc` directly — `http-backend` already handles gzipped
  request bodies, `GIT_PROTOCOL` negotiation, framing, and the exact content types
  git expects. Reimplementing those is the failure this choice exists to avoid.

Naming the binary matters: the two options differ by roughly everything in
[`ROADMAP.md`](ROADMAP.md) R1.

Required around the CGI regardless: kill the child when the client disconnects (hyper
drops the body future and the process otherwise spins), set `receive.fsckObjects=true`
and `receive.maxInputSize`, and hold a semaphore across concurrent `pack-objects` so N
simultaneous clones cannot exhaust host memory.

`git` becomes a runtime dependency, minimum **2.41** (for `--filter` and protocol v2
behaviour we rely on). The README states it; "it is on every Linux host" is not a
version.

### 1.3 Git is the only writer

**Nothing but `git` writes to a repository.** No API creates commits, moves refs or
edits files. The wire protocol is the write path; the REST and MCP surfaces are
administration and read-only inspection (§4).

This is the single most load-bearing decision in the design, and it deletes a large
amount of machinery that an earlier draft of this document specified: no in-process
commit building, no `base_sha` optimistic-concurrency protocol, no ref
compare-and-swap against a concurrent `receive-pack`, no idempotency keys on writes,
no merge story. There is one writer, `receive-pack`, and it already does its own
locking correctly.

What remains is the ref *policy*: `git::store::check_ref_move` decides whether a move
is allowed. It has one caller, the `update` hook, which is a one-line `exec` back
into this binary so the rule is written in Rust rather than shell. Reflogs come from
git itself (`core.logAllRefUpdates`), not from us.

### 1.4 Stack

`hyper` 1.x + `tokio`, no web framework. `anyhow` internally, a typed `Error` at the
boundary (§8.3). `serde` on the wire. Structured single-line logs (§10). `edition
2021`, `opt-level = 3`, `lto = "thin"`, committed `Cargo.lock`.

Beyond that: `gix`, `gix-validate`, `ed25519-dalek`, `sha2`, `hex`, `uuid`, `toml`,
`base64`, `tokio-util`.

---

## 2. Identity, auth, validation

### 2.1 Flat model

```
account       the unit of ownership. Has repos, tokens.
token         a scoped, expiring credential belonging to one account.
grant         (repo, account, capability) — how a second account gets access.
capability    read | write | admin
```

No orgs, no teams, no nesting. `admin` implies `write` implies `read`. `admin` may
manage grants but **may not** delete the repo or grant `admin`; both are owner-only.

**`write` implies code execution** in the repo owner's CI container, because a writer
can author `.zuka.toml` (§6). This is stated here because it materially changes
what granting `write` means, and it is repeated at the grant endpoint.

### 2.2 Tokens

`POST /v1/tokens {name, scopes[], repos[]?, expires_in}`. Scopes: `repo:read`,
`repo:write`, `admin`, `ci`. `expires_in` has a non-infinite default (90 days).

Stored as SHA-256; plaintext returned once, at creation. `TokenRecord` carries
`last_used_at` so revocation triage is possible.

A single unscoped forever-token is total account compromise, and the same secret gets
pasted into `git` (landing in `~/.git-credentials` in plaintext), an MCP client
config, and potentially a CI environment. Scopes and expiry are not optional.

**CI never receives an account token.** A run gets a single-repo, `ci`-scoped,
run-lifetime credential that cannot mint tokens.

### 2.3 Resolving `Identity`

One function, three implementations, with the failure discipline borrowed from
`yangu/atlas`: a rejected credential is `401`, an unreachable auth service is `502`,
and there is never a silent fallback to a lesser identity.

| Mode | Source |
|---|---|
| `standalone` (default) | `$DATA_DIR/tokens.json`, SHA-256 compared. No network. |
| `standalone` (hosted) | `GET {ZUKA_ME_URL}/verify`, opt-in. Positive results cached 60s — otherwise every read blocks on a cross-service round trip. |
| `tenant` | `X-Zuka-Identity`, Ed25519-verified (§2.4). |

### 2.4 The tenant identity assertion

A shared symmetric key would be readable from inside a tenant container by the CI
code running there, and would then forge any account against any tenant. So:

- **Ed25519.** Control signs with a private key it never distributes; tenants hold
  only the public key. Two public keys accepted during rotation overlap.
- **Signed payload**, canonically serialized (field order fixed, no whitespace):
  `{account, issued_at, expires_at, nonce, method, path, body_sha256}`.
- `expires_at - issued_at <= 60s`. Clock skew tolerance ±30s, stated not implied.
- Nonce cached until expiry; replays rejected.
- Binding to `method`/`path`/`body_sha256` means a captured assertion authorizes one
  request, not an identity.
- Tenants bind to a private interface and reject connections not originating from the
  control plane.

### 2.5 Validation is normative, not a test case

These are the rules, not suggestions to be covered by a unit test later.

**Account and repo names** — `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, no `..`, no `.lock`
suffix, unique **case-insensitively** (the KV key is case-sensitive and APFS is not;
without this, disk and metadata silently disagree). Reserved: `.`, `..`, `.git`.

**Ref names** — delegated to `gix-validate`'s reference-name check. Never hand-rolled.
Must resolve under `refs/heads/` or `refs/tags/`; after canonicalization the resolved
filesystem path must be a prefix-child of `$GIT_DIR/refs/`. An unvalidated ref name is
remote code execution: `../../config` writes git config, and `core.fsmonitor`,
`core.sshCommand` and `core.pager` are all command-execution keys.

**Tree paths** — reject absolute, empty, `.`, `..`, and any component equal to `.git`
after Unicode normalization (NFC and NFD) and case folding. Reject `.gitmodules`
entries whose submodule path escapes (the CVE-2018-11235 family). Cap component count
and total path length.

**File modes** — `100644` and `100755` only. `120000` (symlink) is rejected on write,
and `GET .../raw` never follows one. `160000` (gitlink) and `040000` are not
constructible through the API.

**Cursors** — opaque and HMAC'd. A client-supplied cursor must never become a raw KV
start-key; denokv's key escaping makes that a silent-correctness risk rather than a
loud failure.

---

## 3. Path shape

**Decided: `/v1/repos/{account}/{repo}`.** The account is in the path.

The short form `/v1/repos/{repo}` was rejected because it breaks four things at once:
the control plane cannot route to the *owner's* container without it; grants become
unaddressable (a grantee has no way to name someone else's repo); the KV keys
`grant`/`run`/`run_index` collide across accounts; and it disagrees with the git path
`/{account}/{repo}.git`, so an agent holding a clone URL cannot derive the REST URL.

---

## 4. REST

Base `/v1`. JSON in, JSON out. `{a}` = account, `{r}` = repo.

**This surface administers repositories; it does not modify their contents.** Create,
delete, list and configure a repository, manage credentials, read what is in a
repository, watch CI. To change code you use `git`, over HTTP or SSH, exactly as you
would anywhere else.

The reason is that an agent already knows git. What it cannot do without help is the
part GitHub bolted on top — provisioning, keys, access, CI. That is what this API is
for, and keeping writes out of it means there is no second write path to keep
consistent with the first.

### 4.1 Rules

- `POST` to a collection creates → `201` + `Location`.
- `DELETE` of an absent resource → `404`. Idempotency is about the effect on the
  server (RFC 9110 §9.2.2), not the status code; an agent that typos a repo name must
  not be told it deleted something.
- `PATCH` is `application/merge-patch+json` (RFC 7396); `null` clears.
- `Idempotency-Key` accepted on `POST /repos`; `(key → response)` stored 24h and
  replayed, so an agent whose response is lost can retry a create safely.
- ETags are quoted strong entity-tags: `ETag: "<sha>"`. `If-None-Match` → `304`.
- Lists return `{items, truncated}`. **There is no cursor.** An earlier draft
  specified one and emitted `next_cursor: null` on every list including truncated
  ones, which told a client the list had ended when it had not. `truncated` says what
  is true; a real cursor can be added later without having lied in the meantime.
  Default `limit` 50, max 200, both configurable.
- Errors are RFC 9457 `application/problem+json` with a real `type` URI from
  `https://<host>/problems/{slug}` — `ref-moved`, `precondition-required`,
  `invalid-path`, `invalid-ref`, `quota-exceeded`, `rate-limited`, `payload-too-large`,
  `storage-full`, `not-found`, `forbidden`. Clients discriminate on `type` and read
  extension members; never on `detail`.
- Rate limits per token and per IP. `429` + `Retry-After`.

### 4.2 Repositories

| Method | Path | Notes |
|---|---|---|
| `GET` | `/v1/repos` | Owned + granted. `?role=owner\|grantee`. |
| `POST` | `/v1/repos` | `{name, default_branch, description}` → `201`. Duplicate → `409`. |
| `GET` | `/v1/repos/{a}/{r}` | Unreadable repo → `404`, not `403` (no existence leak). |
| `PATCH` | `/v1/repos/{a}/{r}` | `default_branch` must reference an existing branch → else `409`. Updates `HEAD`. |
| `DELETE` | `/v1/repos/{a}/{r}` | `204`. Disk move first, then KV (§9.2). |

`visibility` is `private` (the default) or `public`. A record written before the
field existed reads as private: absence meaning public would have published every
repository on a host at the first upgrade that understood it.

Public grants **anonymous read and nothing else** — the browser views (§4.9) and
`git clone`. It never grants a write: `git-receive-pack` demands an identity before
anything else, and there is no way to spell an anonymous write to have to refuse it.
Anonymous access is granted in exactly one function, `AppState::open_public_repo`;
every other path in the service requires an `Identity`, so a handler that forgets it
produces a `401`, not a disclosure.

A credential that is presented and rejected stays rejected on every surface. It never
degrades to an anonymous visitor, because that turns an expired token into a success
on public repositories and a bare `404` on private ones.

On the git paths an anonymous read that is refused answers `401` with a challenge,
not `404` — git only runs its credential helper on a challenge. The answer is `401`
whether the repository is private or absent, so existence is still not disclosed. The
browser surface answers `404` instead, because a challenge there raises a login
dialog no visitor can satisfy.

### 4.8a Account management is operator-only

`/v1/accounts*` on the control plane creates, lists and destroys tenants. Creating one
provisions a container; deleting one destroys its repositories. None of that is a
tenant operation, so it is not reachable with an ordinary account's credential — a
tenant admin token must not be able to delete a different tenant.

The operator is named by `<PREFIX>OPERATOR` and must present an admin-scoped token
for that account, unconfined by a repository list. When the variable is unset every
account endpoint is refused: the alternative default was a host on which a stranger
could enumerate every tenant and delete them.

### 4.9 Browser views

A read-only, server-rendered HTML surface, written for people who have never used a
code host. No session, no cookie, no form, and no write of any kind; all state lives
in the URL.

**The front page of a repository is its README rendered as a document** — the
Markdoc idea applied to a repository — with the file listing one tab away. A
repository without a README falls back to the listing. Every repository page keeps
one row of tabs (Overview · Files · History · Branches) and every tab carries the
current ref, so switching views never resets the reader to the default branch; the
branch menu is a native `<details>` element and preserves the reader's path on the
branch it switches to.

Markdown carries a small block-tag dialect, a subset of Markdoc's syntax:
`{% hero %}`, `{% band %}`, `{% big %}`, `{% callout %}`, `{% cards %}`/`{% card %}`
and `{% details %}`, each alone on a line at column zero, closed with `{% /name %}`.
Recognised tags become styled wrappers with escaped attributes and never a URL;
unknown tags are dropped while their content renders; tags inside code fences are
shown literally. Styling attributes (`tone` from a six-colour palette, `align`,
`columns`) are allow-lists that emit fixed classes — a document chooses from the
palette and can never invent a style, so expressiveness adds no injection surface.
The repository front page renders full-bleed: prose keeps a readable measure while
heroes and bands paint to the viewport edge; the generated `llms.txt` documents the
dialect so any agent discovers it at creation time.
Relative links resolve against the document's directory and stay inside the
repository (`..` clamps at the root): another markdown file opens as a rendered
page, an image serves through `/raw/`, a directory opens as a listing. Headings get
generated anchor ids.

Server-rendered from the binary with no bundler, no CDN and no client framework — the
product ships as one file a stranger drops on a VM, and a viewer needing `npm
install` at release time would end that. It reads through `git::discover`, the same
code the REST API and the MCP tools use, so the browser cannot disagree with the API.

URLs are `/{account}/{repo}`, exactly the clone URL without `.git`, plus
`/tree/{ref}/{path}`, `/blob/{ref}/{path}` (`?plain=1` shows a markdown file as
text), `/raw/{ref}/{path}` (bytes, attachment, sandbox CSP — the REST raw endpoint
requires a credential and this surface deliberately has none), `/commits/{ref}`
(`?from={sha}` pages older; the boundary must parse as an object id before it may
become a git argument), `/commit/{sha}` and `/refs`. `/` redirects to the repository
named by `ZUKA_HOME_REPO`, so every page has one address; serving the same
repository at two prefixes would make `/blob/x` impossible to tell from an account
named `blob`.

Every repository also answers `/{account}/{repo}/llms.txt` — a committed `llms.txt`
served verbatim, else a generated summary (name, description, documents, clone URL,
API and MCP access) — because on an agent-first host "no file" must not mean "no
front door". The root `/llms.txt` follows the same rule as `/`: it redirects to the
featured repository's, or describes the service itself when none is named. Access
follows the page rule; a private repository's `llms.txt` is not readable
anonymously.

A missing page is answered with a friendly HTML 404 rather than `problem+json` —
identical for "absent" and "private", so the copy leaks nothing the status code
hides. A presented-but-rejected credential stays a 401: an expired token must never
quietly read as "this repository does not exist".

Every interpolated value is escaped. Markdown drops raw HTML and rewrites link
schemes to an allow-list, because a README is written by anyone who can push — a
stranger, on a public repository. The response carries
`content-security-policy: default-src 'none'` with no script source at all, so an
injection that survived the escaping would still have nowhere to execute.

The control plane proxies this surface to tenants **without signing an assertion**
for anonymous visitors. An assertion is the control plane stating "this request is
that account"; minting one for a stranger would hand them that account's private
repositories.

### 4.3 Refs — read only

Full names (`refs/heads/main`), so branches, tags and `refs/notes/*` are all
addressable.

| Method | Path | Notes |
|---|---|---|
| `GET` | `/v1/repos/{a}/{r}/refs` | Paginated. `?type=branch\|tag\|note` filters. |
| `GET` | `/v1/repos/{a}/{r}/refs/{name}` | `ETag: "<sha>"`. |

Refs are moved with `git push`. There is no `PUT` or `DELETE` here: a second way to
move a ref is a second place for the fast-forward rule to be wrong, and §1.3 exists
to avoid exactly that.

### 4.4 Content — read only

Ref is a **query parameter**, never a path segment. `feature/login` and `src/main.rs`
both contain slashes, so `/raw/{ref}/{path}` has no unique parse — and percent-encoding
does not save it, because most reverse proxies normalize `%2F` before the handler sees
it.

| Method | Path | Notes |
|---|---|---|
| `GET` | `/v1/repos/{a}/{r}/tree/{path}?ref=` | Paginated. |
| `GET` | `/v1/repos/{a}/{r}/raw/{path}?ref=` | `HEAD` and `Range` supported. |
| `GET` | `/v1/repos/{a}/{r}/commits?ref=&path=&cursor=` | |
| `GET` | `/v1/repos/{a}/{r}/commits/{sha}` | |
| `GET` | `/v1/repos/{a}/{r}/compare/{base}...{head}` | Diff. |
| `GET` | `/v1/repos/{a}/{r}/search?q=&ref=&path=` | Bounded content search. |

These exist so a caller can answer "what is in here" without a clone — a dashboard
rendering a file, an agent checking whether a path exists before it bothers fetching.
They are a convenience over the git transport, not a substitute for it, and they are
strictly read-only.

`GET /commits?path=` walks the DAG to find matching commits and is a trivial DoS
(`?path=nonexistent` walks all history). It carries a walk budget and returns a
`truncated` flag rather than running unbounded.

### 4.6 Runs, tokens, grants, account

| Method | Path | Notes |
|---|---|---|
| `GET` | `/v1/repos/{a}/{r}/runs` | Newest first. `?status=`. |
| `POST` | `/v1/repos/{a}/{r}/runs` | `{ref}` → `201`. |
| `GET` | `/v1/repos/{a}/{r}/runs/{id}` | |
| `PATCH` | `/v1/repos/{a}/{r}/runs/{id}` | `{status:"cancelled"}`. Terminal → `409`. Not `DELETE` — the run still exists after. |
| `GET` | `/v1/repos/{a}/{r}/runs/{id}/logs` | `text/plain`, complete-so-far, `Range`-capable. |
| `GET` | `/v1/repos/{a}/{r}/runs/{id}/logs/stream` | SSE. Honours `Last-Event-ID`; event ids are byte offsets, so a dropped connection resumes. |
| `GET` `POST` | `/v1/tokens` | §2.2. Never returns a secret after creation. |
| `DELETE` | `/v1/tokens/{id}` | |
| `GET` | `/v1/repos/{a}/{r}/grants` | |
| `PUT` `DELETE` | `/v1/repos/{a}/{r}/grants/{account}` | Owner-only. `write` ⇒ code execution (§2.1). |
| `GET` | `/v1/account` | Identity, scopes, quota usage. |
| `DELETE` | `/v1/account` | Cascades repos, tokens, grants, container teardown. |

Log content type is fixed per endpoint. Negotiating on *run state* — SSE while
running, plain text once finished — makes an identical request's content type depend
on a race with the runner. That is not content negotiation.

### 4.7 Non-REST endpoints

Shape dictated by an external spec, so deliberately outside `/v1`:

```
GET  /{account}/{repo}.git/info/refs?service=…
POST /{account}/{repo}.git/git-upload-pack
POST /{account}/{repo}.git/git-receive-pack
POST /mcp
GET  /mcp                     SSE channel, per MCP transport
GET  /healthz                 includes free-disk check
```

**The git paths have their own error contract and RFC 9457 does not apply to them.**
`401` must carry `WWW-Authenticate: Basic realm="zuka"` or git never invokes its
credential helper and the clone fails instead of prompting. Responses use
`application/x-git-*-result`; `info/refs` sets `Cache-Control: no-cache, max-age=0,
must-revalidate`. `problem+json` here is noise git will render at the user.

### 4.8 `/raw` is a hostile-content endpoint

Always `application/octet-stream`, `Content-Disposition: attachment`,
`X-Content-Type-Options: nosniff`, `Content-Security-Policy: sandbox`. Never sniffed,
never caller-specified. GitHub runs `raw.githubusercontent.com` on a separate origin
for exactly this reason.

CORS is **disabled by default** — §3 of the README says there is no web UI here, so
there is no first-party browser origin to serve. Where enabled it is an explicit
allowlist; `Origin` is never reflected. CORS plus bearer auth plus attacker-controlled
bytes is the standard cross-origin theft recipe.

Cache: `public, max-age=31536000, immutable` when `ref` is a full sha, `no-cache`
otherwise.

### 4.9 Limits

Stated, not implied: max request body 10 MiB; max `changes[]` length 1,000; max blob
32 MiB via API (pushes are bounded by `receive.maxInputSize`); tree and ref pages
paginated. Over-quota → `413` or `quota-exceeded`. Free disk below the reserve
threshold → `507` **before** the disk fills, because `receive-pack` interrupted by
ENOSPC leaves a partial pack.

---

## 5. MCP

`POST /mcp` JSON-RPC 2.0 plus the `GET` SSE channel and session identification the
current transport revision requires. Auth is a bearer token; whether that satisfies
the target client's authorization expectations is verified in M4, not assumed.

### 5.1 Tools

Administration and inspection. There is no tool that writes to a repository — an
agent that wants to change code runs `git`, which it already knows how to do.

| Tool | What it is for |
|---|---|
| `repo_create` `repo_list` `repo_get` `repo_delete` | Provisioning. `repo_create` returns both clone URLs and may seed from `import_url`. |
| `key_add` `key_list` `key_remove` | Register the agent's own SSH key so it can then use git. |
| `ref_list` `tree_list` `file_read` `search` `commit_log` `commit_get` `compare` `blame` | Read without cloning. |
| `run_list` `run_get` `run_logs` | Watch CI after a push. |


Each maps onto the same core call the REST handler uses.

### 5.2 What the facade may and may not do

Facades may fill defaults and reshape payloads — resolving `ref` to the repository's
default branch, for instance. They may not carry authorization rules; those live on
`Identity` so REST, MCP and SSH cannot drift (§1.1).

The intended shape of an agent session is: `repo_create` → `key_add` → then plain
`git clone`, `git commit`, `git push` in the agent's own workspace → `run_status` to
see whether CI passed. The MCP surface removes the GitHub-shaped work; git does the
git-shaped work.

---

## 6. CI

### 6.1 Trigger and hooks

Hooks are **symlinks** into `$DATA_DIR/hooks/`, installed via `init.templateDir` at
repo creation. Symlinks, not copies, so upgrading a hook is atomic across every
existing repo rather than leaving pre-change repos on the old version forever.

| Hook | Job |
|---|---|
| `update` | Calls `git::store::check_ref_move`, the single implementation of the ref rule. |
| `post-receive` | Writes a queued run record. Never blocks the push. |

The queue **is** the run records (`ci::run::RunStore::queued`), not a separate
channel: a push that lands while the server is down is still picked up when it
returns, and the API and the executor cannot disagree about what is pending. The cost
is latency — the executor polls, so a run waits up to `POLL` before starting. A socket
would remove that; durability across a restart mattered more.

Hooks receive `ZUKA_DATA_DIR` and `ZUKA_CI_ENABLED` through
`Config::hook_env`. Both git spawn sites clear the environment deliberately, so a
hook would otherwise inherit nothing and resolve the default data directory.

`pre-receive` commit-signature enforcement is **cut from v1**: it needs a keyring, a
trust model and an API surface, none of which exist. Branch protection beyond
fast-forward is cut for the same reason. Asserting a feature into existence with a
table row is how specs lie.

### 6.2 Spec

`.zuka.toml` at the repo root:

```toml
[run]
steps = ["cargo test", "cargo build --release"]
timeout_secs = 600
branches = ["refs/heads/main"]
```

**There is no `runtime` key.** An earlier draft specified one, validated it against a
list of toolchains, and then ignored it — every step ran under `/bin/sh` regardless.
A knob that appears to select a toolchain and does not is worse than no knob. Steps
run with whatever is on the host `PATH`; provisioning toolchains is the operator's
job.

A repository may lower `timeout_secs` but never raise it past the host ceiling,
otherwise one repository can hold a runner slot indefinitely.

### 6.3 Isolation

**`tenant` mode.** The container is the boundary between accounts. It is *not* a
boundary between untrusted CI code and the service process sharing that container, so:

- The runner has its own uid. `RLIMIT_NPROC` is per-uid — sharing one with the service
  means a fork bomb in CI takes down the service.
- The environment is scrubbed (`env -i`). The service's credentials must not be
  readable from `/proc/self/environ` or the unit file.
- The tenant holds a **per-tenant KV credential**, not a shared one. A shared
  `DENO_KV_ACCESS_TOKEN` inside a container running arbitrary code means every tenant
  can read every other account's rows — and would make the isolation milestone's proof
  false.
- Limits: `RLIMIT_CPU`, `RLIMIT_AS`, `RLIMIT_FSIZE`, `RLIMIT_NOFILE`, wall clock and
  an output cap. **`RLIMIT_NPROC` is off by default** and this is not timidity: it is
  per-UID, so with the runner sharing a uid with the service it counts processes we
  do not control — a useful-looking value makes `fork` fail on the first step. It
  becomes meaningful only alongside a dedicated runner uid, which is also what makes
  it safe.
- Timeout kills the **cgroup**, not the shell — killing the shell orphans the tree.
- No network: a network namespace with no route out, stated as the mechanism rather
  than as an intention.

**`standalone` mode.** Same uid separation and rlimits, no container. **This is not a
security boundary**, CI defaults to **off**, and the README says so in those words.

A step allowlist was considered and rejected as theatre — `sh -c` defeats it in one
character. Enforcement is at the OS or it is not enforcement. That principle then has
to be followed through, which is what the bullets above are.

---

## 6A. SSH

Shipped in M1 (pulled forward from M7). A `russh` server in-process — not the host's
`sshd`, which would need per-host configuration and break the single-binary promise.

**Authentication is public-key only.** Password and keyboard-interactive are rejected
outright. The offered key is matched against stored keys by raw bytes and the account
falls out of the match; the SSH username is never read.

**A session may exec exactly three commands.** `git-upload-pack`,
`git-receive-pack` and `git-upload-archive`, against one repository. There is no
shell, no pty and no subsystem. The exec string is parsed into a fixed service plus a
validated repository and is never handed to a shell, so `git-upload-pack '/a/b.git';
id` is a parse error rather than an execution.

`upload-archive` was refused in an earlier draft on the grounds that it exposes tree
contents through a separate code path. That was wrong: it exposes exactly what
`upload-pack` already exposes to a caller who has read access, so refusing it withheld
a normal git capability without withholding any data. It requires read, not write.

**One environment variable crosses the boundary.** `GIT_PROTOCOL`, capped at 64
bytes. Forwarding arbitrary client environment into a subprocess is an injection
primitive: `GIT_CONFIG_*`, `GIT_ALTERNATE_OBJECT_DIRECTORIES` and `LD_PRELOAD` all
change what the child does.

**Keys carry their own scope.** `read_only` makes a key clone-and-fetch only;
`repos[]` confines it to named repositories. A key is a credential with the reach of
a `repo:write` token and is treated as one.

**The host key is generated once** and reused, so clients are not trained to accept a
changed fingerprint.

Concurrency, the disconnect kill and the ref rule are shared with the HTTP path: the
same semaphore bounds both, the same process-group kill applies, and a forced
non-fast-forward is refused by `check_ref_move` regardless of which transport carried
it.

## 7. Control plane

A tenant never learns its own public address. It sits on a private bridge behind the
control plane, so the control plane passes `PUBLIC_URL` and `PUBLIC_SSH` down when it
provisions one. Without that a tenant advertises its bind address and every clone URL
it returns points at a container the caller cannot reach — which is what it did until
it was tested on real infrastructure.

### 7.1 Provisioning

Account creation writes state `provisioning` and returns immediately. A reconciler
drives it to `ready` or `failed`. **Never a synchronous wait on container boot** — the
non-durable `queueMicrotask` in `yangu/roadmap/mailbox-provisioning.md` is the
specific mistake not to repeat: no due index, no retry, no failure surface.

Containers stop when idle and start on demand, with a stated cold-start latency
budget. Always-on would mean one permanently running container per account for users
who push twice a year.

### 7.2 Proxying

The control plane proxies long-lived, bidirectional, sideband-multiplexed,
multi-gigabyte git streams, plus SSE. Backpressure must be preserved end to end and
SSE must not be buffered. Shelling out to `git` at the engine does not help at the
proxy hop — this is its own risk ([`ROADMAP.md`](ROADMAP.md) R6), not a free
consequence of §1.2.

---

## 8. Source layout

```
zuka/
├── Cargo.toml  Cargo.lock  README.md  openapi.json
├── src/
│   ├── main.rs             boot, CLI, git hook entry point
│   ├── brand.rs            the one place the product name appears
│   ├── config.rs           Config::from_env(); fails boot on a bad value
│   ├── error.rs            Error -> problem+json
│   ├── core/               domain operations shared by every facade
│   ├── git/
│   │   ├── exec.rs         the only place this service invokes git
│   │   ├── validate.rs     names, refs, tree paths, blob modes
│   │   ├── store.rs        repositories, hooks, ref rules, protection
│   │   ├── discover.rs     the read core, shared by REST and MCP
│   │   ├── transport.rs    streaming CGI bridge
│   │   └── import.rs       SSRF-guarded cloning
│   ├── http/               routing, limits, responses, auth, rate limiting
│   ├── api/                REST facade + the OpenAPI document
│   ├── mcp/                MCP facade
│   ├── ssh/                SSH server and exec-command parser
│   ├── ci/                 spec, run store, runner, executor
│   ├── jobs/               gc, sweep, fsck
│   ├── account/            identity, tokens, SSH keys
│   └── store/              metadata, quotas, change-aware file cache
└── test/live/              spawned-binary suite driven by real git and ssh
```

### 8.1 The core layer

`core/` exists because REST and MCP each grew their own copy of repository create and
delete, and the copies drifted: the MCP one had no rollback (so a failed `git init`
wedged the name with a permanent `Creating` record), did not support protected refs or
import, and left run records behind on delete — which meant re-creating a name
resurrected the previous repository's CI history.

`AppState::open_repo` is the same lesson one layer down: the sequence "resolve names,
check access, confirm ready, get path" appeared seven times, three of them subtly
different. `Access::Write` also enforces the disk reserve and the storage quota, so
no write path can forget them.

### 8.2 Why nested rather than flat

`yangu/atlas` keeps everything in a 1,158-line `main.rs`; `yangu/kazi`'s is 3,050.
That is a judgment call about **four protocol surfaces** — REST, MCP, git wire,
control-plane RPC — against atlas's one, not a claim that the flat pattern has been
measured to fail. Stating it as proven would apply a lower evidentiary standard than
the convention it deviates from demands.

### 8.3 Scheduled work has its own unit

`jobs/` runs in a separate `zuka-jobs` systemd unit, not the web entrypoint: GC and
repack, the deleted-repo sweep, the provisioning reconciler, and quota recomputation.
Running them in the server process would violate the same rule for the same reason it
exists.

### 8.4 Errors

`anyhow` internally; one enum at the boundary.

```rust
enum Error {
    NotFound(&'static str),
    Conflict { slug: &'static str, detail: String },
    Unauthorized, Forbidden,
    Invalid { slug: &'static str, detail: String },
    Unprocessable(String),
    MethodNotAllowed,
    PayloadTooLarge { limit: u64 },
    QuotaExceeded { limit: u64, used: u64 },
    RateLimited { retry_after: u32 },
    StorageFull,
    Internal(anyhow::Error),
}
```

`StorageFull` → `507`, `Internal` → `500` with the detail logged and never returned.
There is no `Upstream` variant: nothing makes an upstream call, because hosted auth
refuses to boot rather than being half-implemented.

---

## 9. Data

### 9.1 Disk

```
$ZUKA_DATA_DIR/
├── git/{account}/{repo}.git/
├── hooks/                        templateDir; symlink targets
├── runs/{account}/{repo}/        one JSON record and one log per run
└── tmp/deleted/                  soft-deleted repos, 7-day sweep
```

### 9.2 KV

| Key | Value |
|---|---|
| `["zuka","account",account]` | `AccountRecord` incl. provisioning state |
| `["zuka","container",account]` | container id + endpoint (control) |
| `["zuka","repo",account,repo]` | `RepoRecord` |
| `["zuka","repo_index",account,repo]` | `repo` |
| `["zuka","token",tokenHash]` | `TokenRecord` |
| `["zuka","token_id",account,tokenId]` | `tokenHash` |
| `["zuka","grant",account,repo,grantee]` | `Capability` |
| `["zuka","run",account,repo,runId]` | `RunRecord` |
| `["zuka","run_index",account,repo,ts,runId]` | `runId` |
| `["zuka","idem",tokenId,key]` | cached response, 24h TTL |
| `["zuka","audit",account,ts,rand]` | `AuditRow`, 90-day TTL |

`token_id → tokenHash` exists so `DELETE /v1/tokens/{id}` can find the canonical row;
without it the delete has no path to the record. The audit key carries a random suffix
because two operations in the same millisecond are ordinary for git and would
otherwise overwrite each other.

Audit writes are fire-and-forget — a failed log row must never block the operation it
describes.

**Delete ordering: disk move first, then KV.** A crash between them leaves a repo in
`tmp/deleted/` with live KV rows, which `zuka fsck` reports and can roll back. The
reverse leaves an unreachable repo and a `500`.

### 9.3 Backup and reconciliation

Repos live on a local disk and metadata lives in a remote KV; they are never
snapshotted together, so a restore reconciles two stores with different recovery
points. `zuka fsck` lists orphans in both directions. **Disk is authoritative for
repository existence; KV is authoritative for access control.** Documented because the
system enters a split state deliberately on every delete.

### 9.4 Garbage collection

Every API commit writes loose objects; every push leaves a pack; `packed-refs` is
never rewritten. Without GC, inode count and disk grow without bound.

`jobs/gc` runs `git repack -Ad` + `git prune --expire` with `gc.auto=0`, holding a
lock coordinated with `git/transport.rs` — unsynchronized GC concurrent with a live
`upload-pack` hands the client a corrupt clone.

The support case this creates is worth stating: *"the agent committed my API key."*
Force-moving the ref leaves the blob fetchable by sha until the next GC. Product docs
must say so rather than implying deletion is immediate.

---

## 10. Config and observability

`std::env::var` with defaults, gathered into `Config::from_env() -> Result<Config>`
that fails boot loudly. Prefix `ZUKA_` — including `ZUKA_BIND`; only genuinely
shared infra names (`KV_URL`) go unprefixed.

| Var | Default | Meaning |
|---|---|---|
| `ZUKA_BIND` | `127.0.0.1:8790` | Listen address |
| `ZUKA_MODE` | `standalone` | `standalone` \| `control` \| `tenant` |
| `ZUKA_DATA_DIR` | `/var/lib/zuka` | |
| `ZUKA_ME_URL` | *unset* | Opt-in hosted auth. Unset → local token file. |
| `ZUKA_CI_ENABLED` | `0` | Must be set explicitly in every mode (§6.3) |
| `ZUKA_CONTROL_PUBKEY` | — | Ed25519 public key; required in `tenant` |
| `ZUKA_DISK_RESERVE_MB` | `2048` | Below this, writes → `507` |
| `KV_URL` | — | Per-tenant credential in `tenant` mode |

`ZUKA_ME_URL` deliberately has **no default**. Defaulting it to `worklyn.me` would
mean a self-hoster who configures nothing gets a binary that phones Worklyn to
authenticate and returns `502` when it cannot — for the mode described as what a
self-hoster runs.

**Observability**: structured single-line logs carrying method, route, status,
duration, account and request id. Counters for repo bytes, object counts, CI queue
depth and run outcomes — the queue-depth counter is what makes the overflow policy in
[`ROADMAP.md`](ROADMAP.md) D5 decidable instead of guessed. Whether this emits OTLP to
`product/observability/` is D6.
