zuka
zuka/SPEC.md

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/SPEC.md
MDSPEC.md33.9 KBFormattedDownload
1# zuka — technical specification
2
3Status: scoped, not started. Milestones, risks and open decisions live in
4[`ROADMAP.md`](ROADMAP.md); this document is the durable technical reference. Where
5the two disagree, this one wins on *how* and the roadmap wins on *when*.
6
7Product framing, positioning and non-goals: [`README.md`](README.md).
8
9---
10
11## 1. Architecture
12
13### 1.1 Three modes, one binary
14
15```
16standalone Engine only. Repos on local disk, tokens from a local file.
17 No outbound calls. This is what a self-hoster runs. Default.
18
19control Control plane. Owns accounts, provisioning, routing. Holds no
20 repositories. Proxies data-plane requests to tenant containers.
21
22tenant Engine inside an Incus container behind a control plane. Trusts a
23 signed identity assertion instead of verifying tokens itself.
24```
25
26`standalone` and `tenant` share the request-handling path exactly. They differ in
27boot configuration and in the CI isolation model (§6.3) — that is the honest claim;
28"identical but for one function" would not survive §6.3 or §9.
29
30```
31 agent ──MCP───►┌──────────────────────────────┐
32 app ──REST──►│ facades: mcp/ api/ │
33 └──────────────┬───────────────┘
34 │ same core calls
35 ┌──────────────▼───────────────┐
36 │ core: git/ ci/ account/ │
37 └───┬──────────────────────┬───┘
38 git CLI ──────►git/transport.rs │
39 │ │
40 ┌──────▼──────┐ ┌──────▼──────┐
41 │ filesystem │ │ KV │
42 │ bare repos │ │ metadata │
43 └─────────────┘ └─────────────┘
44```
45
46**Facades carry wire-shape translation and client ergonomics. They never carry
47authorization or concurrency rules.** Defaults and argument-filling are allowed in
48`mcp/` (§5.2); access checks and ref preconditions are not, and never appear twice.
49
50### 1.2 Git access
51
52Everything goes through `git` subprocesses, funnelled through `git::exec::Git` —
53the single place this service invokes git. Before that module existed there were five
54wrappers and nine ad-hoc spawns with three different environment policies, so
55`/etc/gitconfig` applied to `git init`, `git config`, `merge-base --is-ancestor` (the
56ref rule) and `count-objects` (quota) but not to the wire protocol. Half a control is
57not a control; `Git` clears the environment and sets `GIT_CONFIG_NOSYSTEM` on every
58invocation.
59
60`gix` is not used. A subprocess per read is a real cost, mitigated by running them on
61a blocking pool rather than a runtime worker; migrating the read path is a
62measurement-driven decision and no measurement has been taken.
63
64**Error mapping matters here.** `Git::query` treats exit 1 as an answer ("no such
65object", "no matches") and anything higher as a fault. An earlier version collapsed
66every non-zero exit into `404`, which meant a corrupt repository reported as empty
67and nothing paged anyone. Existence is gated on `rev-parse --verify --quiet`, which
68exits 1; `cat-file` exits 128 for the same condition and cannot be used for it.
69
70#### Wire protocol
71
72- **Reads and API writes** use `gix`. Refs, trees, blobs, log, and building a
73 tree+commit in-process. No working copy, no subprocess.
74- **The wire protocol** delegates to **`git http-backend`**, the reference CGI. Not
75 `upload-pack --stateless-rpc` directly — `http-backend` already handles gzipped
76 request bodies, `GIT_PROTOCOL` negotiation, framing, and the exact content types
77 git expects. Reimplementing those is the failure this choice exists to avoid.
78
79Naming the binary matters: the two options differ by roughly everything in
80[`ROADMAP.md`](ROADMAP.md) R1.
81
82Required around the CGI regardless: kill the child when the client disconnects (hyper
83drops the body future and the process otherwise spins), set `receive.fsckObjects=true`
84and `receive.maxInputSize`, and hold a semaphore across concurrent `pack-objects` so N
85simultaneous clones cannot exhaust host memory.
86
87`git` becomes a runtime dependency, minimum **2.41** (for `--filter` and protocol v2
88behaviour we rely on). The README states it; "it is on every Linux host" is not a
89version.
90
91### 1.3 Git is the only writer
92
93**Nothing but `git` writes to a repository.** No API creates commits, moves refs or
94edits files. The wire protocol is the write path; the REST and MCP surfaces are
95administration and read-only inspection (§4).
96
97This is the single most load-bearing decision in the design, and it deletes a large
98amount of machinery that an earlier draft of this document specified: no in-process
99commit building, no `base_sha` optimistic-concurrency protocol, no ref
100compare-and-swap against a concurrent `receive-pack`, no idempotency keys on writes,
101no merge story. There is one writer, `receive-pack`, and it already does its own
102locking correctly.
103
104What remains is the ref *policy*: `git::store::check_ref_move` decides whether a move
105is allowed. It has one caller, the `update` hook, which is a one-line `exec` back
106into this binary so the rule is written in Rust rather than shell. Reflogs come from
107git itself (`core.logAllRefUpdates`), not from us.
108
109### 1.4 Stack
110
111`hyper` 1.x + `tokio`, no web framework. `anyhow` internally, a typed `Error` at the
112boundary (§8.3). `serde` on the wire. Structured single-line logs (§10). `edition
1132021`, `opt-level = 3`, `lto = "thin"`, committed `Cargo.lock`.
114
115Beyond that: `gix`, `gix-validate`, `ed25519-dalek`, `sha2`, `hex`, `uuid`, `toml`,
116`base64`, `tokio-util`.
117
118---
119
120## 2. Identity, auth, validation
121
122### 2.1 Flat model
123
124```
125account the unit of ownership. Has repos, tokens.
126token a scoped, expiring credential belonging to one account.
127grant (repo, account, capability) — how a second account gets access.
128capability read | write | admin
129```
130
131No orgs, no teams, no nesting. `admin` implies `write` implies `read`. `admin` may
132manage grants but **may not** delete the repo or grant `admin`; both are owner-only.
133
134**`write` implies code execution** in the repo owner's CI container, because a writer
135can author `.zuka.toml` (§6). This is stated here because it materially changes
136what granting `write` means, and it is repeated at the grant endpoint.
137
138### 2.2 Tokens
139
140`POST /v1/tokens {name, scopes[], repos[]?, expires_in}`. Scopes: `repo:read`,
141`repo:write`, `admin`, `ci`. `expires_in` has a non-infinite default (90 days).
142
143Stored as SHA-256; plaintext returned once, at creation. `TokenRecord` carries
144`last_used_at` so revocation triage is possible.
145
146A single unscoped forever-token is total account compromise, and the same secret gets
147pasted into `git` (landing in `~/.git-credentials` in plaintext), an MCP client
148config, and potentially a CI environment. Scopes and expiry are not optional.
149
150**CI never receives an account token.** A run gets a single-repo, `ci`-scoped,
151run-lifetime credential that cannot mint tokens.
152
153### 2.3 Resolving `Identity`
154
155One function, three implementations, with the failure discipline borrowed from
156`yangu/atlas`: a rejected credential is `401`, an unreachable auth service is `502`,
157and there is never a silent fallback to a lesser identity.
158
159| Mode | Source |
160|---|---|
161| `standalone` (default) | `$DATA_DIR/tokens.json`, SHA-256 compared. No network. |
162| `standalone` (hosted) | `GET {ZUKA_ME_URL}/verify`, opt-in. Positive results cached 60s — otherwise every read blocks on a cross-service round trip. |
163| `tenant` | `X-Zuka-Identity`, Ed25519-verified (§2.4). |
164
165### 2.4 The tenant identity assertion
166
167A shared symmetric key would be readable from inside a tenant container by the CI
168code running there, and would then forge any account against any tenant. So:
169
170- **Ed25519.** Control signs with a private key it never distributes; tenants hold
171 only the public key. Two public keys accepted during rotation overlap.
172- **Signed payload**, canonically serialized (field order fixed, no whitespace):
173 `{account, issued_at, expires_at, nonce, method, path, body_sha256}`.
174- `expires_at - issued_at <= 60s`. Clock skew tolerance ±30s, stated not implied.
175- Nonce cached until expiry; replays rejected.
176- Binding to `method`/`path`/`body_sha256` means a captured assertion authorizes one
177 request, not an identity.
178- Tenants bind to a private interface and reject connections not originating from the
179 control plane.
180
181### 2.5 Validation is normative, not a test case
182
183These are the rules, not suggestions to be covered by a unit test later.
184
185**Account and repo names** — `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, no `..`, no `.lock`
186suffix, unique **case-insensitively** (the KV key is case-sensitive and APFS is not;
187without this, disk and metadata silently disagree). Reserved: `.`, `..`, `.git`.
188
189**Ref names** — delegated to `gix-validate`'s reference-name check. Never hand-rolled.
190Must resolve under `refs/heads/` or `refs/tags/`; after canonicalization the resolved
191filesystem path must be a prefix-child of `$GIT_DIR/refs/`. An unvalidated ref name is
192remote code execution: `../../config` writes git config, and `core.fsmonitor`,
193`core.sshCommand` and `core.pager` are all command-execution keys.
194
195**Tree paths** — reject absolute, empty, `.`, `..`, and any component equal to `.git`
196after Unicode normalization (NFC and NFD) and case folding. Reject `.gitmodules`
197entries whose submodule path escapes (the CVE-2018-11235 family). Cap component count
198and total path length.
199
200**File modes** — `100644` and `100755` only. `120000` (symlink) is rejected on write,
201and `GET .../raw` never follows one. `160000` (gitlink) and `040000` are not
202constructible through the API.
203
204**Cursors** — opaque and HMAC'd. A client-supplied cursor must never become a raw KV
205start-key; denokv's key escaping makes that a silent-correctness risk rather than a
206loud failure.
207
208---
209
210## 3. Path shape
211
212**Decided: `/v1/repos/{account}/{repo}`.** The account is in the path.
213
214The short form `/v1/repos/{repo}` was rejected because it breaks four things at once:
215the control plane cannot route to the *owner's* container without it; grants become
216unaddressable (a grantee has no way to name someone else's repo); the KV keys
217`grant`/`run`/`run_index` collide across accounts; and it disagrees with the git path
218`/{account}/{repo}.git`, so an agent holding a clone URL cannot derive the REST URL.
219
220---
221
222## 4. REST
223
224Base `/v1`. JSON in, JSON out. `{a}` = account, `{r}` = repo.
225
226**This surface administers repositories; it does not modify their contents.** Create,
227delete, list and configure a repository, manage credentials, read what is in a
228repository, watch CI. To change code you use `git`, over HTTP or SSH, exactly as you
229would anywhere else.
230
231The reason is that an agent already knows git. What it cannot do without help is the
232part GitHub bolted on top — provisioning, keys, access, CI. That is what this API is
233for, and keeping writes out of it means there is no second write path to keep
234consistent with the first.
235
236### 4.1 Rules
237
238- `POST` to a collection creates → `201` + `Location`.
239- `DELETE` of an absent resource → `404`. Idempotency is about the effect on the
240 server (RFC 9110 §9.2.2), not the status code; an agent that typos a repo name must
241 not be told it deleted something.
242- `PATCH` is `application/merge-patch+json` (RFC 7396); `null` clears.
243- `Idempotency-Key` accepted on `POST /repos`; `(key → response)` stored 24h and
244 replayed, so an agent whose response is lost can retry a create safely.
245- ETags are quoted strong entity-tags: `ETag: "<sha>"`. `If-None-Match` → `304`.
246- Lists return `{items, truncated}`. **There is no cursor.** An earlier draft
247 specified one and emitted `next_cursor: null` on every list including truncated
248 ones, which told a client the list had ended when it had not. `truncated` says what
249 is true; a real cursor can be added later without having lied in the meantime.
250 Default `limit` 50, max 200, both configurable.
251- Errors are RFC 9457 `application/problem+json` with a real `type` URI from
252 `https://<host>/problems/{slug}` — `ref-moved`, `precondition-required`,
253 `invalid-path`, `invalid-ref`, `quota-exceeded`, `rate-limited`, `payload-too-large`,
254 `storage-full`, `not-found`, `forbidden`. Clients discriminate on `type` and read
255 extension members; never on `detail`.
256- Rate limits per token and per IP. `429` + `Retry-After`.
257
258### 4.2 Repositories
259
260| Method | Path | Notes |
261|---|---|---|
262| `GET` | `/v1/repos` | Owned + granted. `?role=owner\|grantee`. |
263| `POST` | `/v1/repos` | `{name, default_branch, description}` → `201`. Duplicate → `409`. |
264| `GET` | `/v1/repos/{a}/{r}` | Unreadable repo → `404`, not `403` (no existence leak). |
265| `PATCH` | `/v1/repos/{a}/{r}` | `default_branch` must reference an existing branch → else `409`. Updates `HEAD`. |
266| `DELETE` | `/v1/repos/{a}/{r}` | `204`. Disk move first, then KV (§9.2). |
267
268`visibility` is **not** in v1. "Public" would mean anonymous REST reads, anonymous
269clone, or both, and each implies an anonymous principal in §2.3 and an open DoS
270surface. Everything is private until that is designed. See [`ROADMAP.md`](ROADMAP.md)
271D3.
272
273### 4.3 Refs — read only
274
275Full names (`refs/heads/main`), so branches, tags and `refs/notes/*` are all
276addressable.
277
278| Method | Path | Notes |
279|---|---|---|
280| `GET` | `/v1/repos/{a}/{r}/refs` | Paginated. `?type=branch\|tag\|note` filters. |
281| `GET` | `/v1/repos/{a}/{r}/refs/{name}` | `ETag: "<sha>"`. |
282
283Refs are moved with `git push`. There is no `PUT` or `DELETE` here: a second way to
284move a ref is a second place for the fast-forward rule to be wrong, and §1.3 exists
285to avoid exactly that.
286
287### 4.4 Content — read only
288
289Ref is a **query parameter**, never a path segment. `feature/login` and `src/main.rs`
290both contain slashes, so `/raw/{ref}/{path}` has no unique parse — and percent-encoding
291does not save it, because most reverse proxies normalize `%2F` before the handler sees
292it.
293
294| Method | Path | Notes |
295|---|---|---|
296| `GET` | `/v1/repos/{a}/{r}/tree/{path}?ref=` | Paginated. |
297| `GET` | `/v1/repos/{a}/{r}/raw/{path}?ref=` | `HEAD` and `Range` supported. |
298| `GET` | `/v1/repos/{a}/{r}/commits?ref=&path=&cursor=` | |
299| `GET` | `/v1/repos/{a}/{r}/commits/{sha}` | |
300| `GET` | `/v1/repos/{a}/{r}/compare/{base}...{head}` | Diff. |
301| `GET` | `/v1/repos/{a}/{r}/search?q=&ref=&path=` | Bounded content search. |
302
303These exist so a caller can answer "what is in here" without a clone — a dashboard
304rendering a file, an agent checking whether a path exists before it bothers fetching.
305They are a convenience over the git transport, not a substitute for it, and they are
306strictly read-only.
307
308`GET /commits?path=` walks the DAG to find matching commits and is a trivial DoS
309(`?path=nonexistent` walks all history). It carries a walk budget and returns a
310`truncated` flag rather than running unbounded.
311
312### 4.6 Runs, tokens, grants, account
313
314| Method | Path | Notes |
315|---|---|---|
316| `GET` | `/v1/repos/{a}/{r}/runs` | Newest first. `?status=`. |
317| `POST` | `/v1/repos/{a}/{r}/runs` | `{ref}` → `201`. |
318| `GET` | `/v1/repos/{a}/{r}/runs/{id}` | |
319| `PATCH` | `/v1/repos/{a}/{r}/runs/{id}` | `{status:"cancelled"}`. Terminal → `409`. Not `DELETE` — the run still exists after. |
320| `GET` | `/v1/repos/{a}/{r}/runs/{id}/logs` | `text/plain`, complete-so-far, `Range`-capable. |
321| `GET` | `/v1/repos/{a}/{r}/runs/{id}/logs/stream` | SSE. Honours `Last-Event-ID`; event ids are byte offsets, so a dropped connection resumes. |
322| `GET` `POST` | `/v1/tokens` | §2.2. Never returns a secret after creation. |
323| `DELETE` | `/v1/tokens/{id}` | |
324| `GET` | `/v1/repos/{a}/{r}/grants` | |
325| `PUT` `DELETE` | `/v1/repos/{a}/{r}/grants/{account}` | Owner-only. `write` ⇒ code execution (§2.1). |
326| `GET` | `/v1/account` | Identity, scopes, quota usage. |
327| `DELETE` | `/v1/account` | Cascades repos, tokens, grants, container teardown. |
328
329Log content type is fixed per endpoint. Negotiating on *run state* — SSE while
330running, plain text once finished — makes an identical request's content type depend
331on a race with the runner. That is not content negotiation.
332
333### 4.7 Non-REST endpoints
334
335Shape dictated by an external spec, so deliberately outside `/v1`:
336
337```
338GET /{account}/{repo}.git/info/refs?service=…
339POST /{account}/{repo}.git/git-upload-pack
340POST /{account}/{repo}.git/git-receive-pack
341POST /mcp
342GET /mcp SSE channel, per MCP transport
343GET /healthz includes free-disk check
344```
345
346**The git paths have their own error contract and RFC 9457 does not apply to them.**
347`401` must carry `WWW-Authenticate: Basic realm="zuka"` or git never invokes its
348credential helper and the clone fails instead of prompting. Responses use
349`application/x-git-*-result`; `info/refs` sets `Cache-Control: no-cache, max-age=0,
350must-revalidate`. `problem+json` here is noise git will render at the user.
351
352### 4.8 `/raw` is a hostile-content endpoint
353
354Always `application/octet-stream`, `Content-Disposition: attachment`,
355`X-Content-Type-Options: nosniff`, `Content-Security-Policy: sandbox`. Never sniffed,
356never caller-specified. GitHub runs `raw.githubusercontent.com` on a separate origin
357for exactly this reason.
358
359CORS is **disabled by default** — §3 of the README says there is no web UI here, so
360there is no first-party browser origin to serve. Where enabled it is an explicit
361allowlist; `Origin` is never reflected. CORS plus bearer auth plus attacker-controlled
362bytes is the standard cross-origin theft recipe.
363
364Cache: `public, max-age=31536000, immutable` when `ref` is a full sha, `no-cache`
365otherwise.
366
367### 4.9 Limits
368
369Stated, not implied: max request body 10 MiB; max `changes[]` length 1,000; max blob
37032 MiB via API (pushes are bounded by `receive.maxInputSize`); tree and ref pages
371paginated. Over-quota → `413` or `quota-exceeded`. Free disk below the reserve
372threshold → `507` **before** the disk fills, because `receive-pack` interrupted by
373ENOSPC leaves a partial pack.
374
375---
376
377## 5. MCP
378
379`POST /mcp` JSON-RPC 2.0 plus the `GET` SSE channel and session identification the
380current transport revision requires. Auth is a bearer token; whether that satisfies
381the target client's authorization expectations is verified in M4, not assumed.
382
383### 5.1 Tools
384
385Administration and inspection. There is no tool that writes to a repository — an
386agent that wants to change code runs `git`, which it already knows how to do.
387
388| Tool | What it is for |
389|---|---|
390| `repo_create` `repo_list` `repo_get` `repo_delete` | Provisioning. `repo_create` returns both clone URLs and may seed from `import_url`. |
391| `key_add` `key_list` `key_remove` | Register the agent's own SSH key so it can then use git. |
392| `ref_list` `tree_list` `file_read` `search` `commit_log` `commit_get` `compare` `blame` | Read without cloning. |
393| `run_list` `run_get` `run_logs` | Watch CI after a push. |
394
395
396Each maps onto the same core call the REST handler uses.
397
398### 5.2 What the facade may and may not do
399
400Facades may fill defaults and reshape payloads — resolving `ref` to the repository's
401default branch, for instance. They may not carry authorization rules; those live on
402`Identity` so REST, MCP and SSH cannot drift (§1.1).
403
404The intended shape of an agent session is: `repo_create` → `key_add` → then plain
405`git clone`, `git commit`, `git push` in the agent's own workspace → `run_status` to
406see whether CI passed. The MCP surface removes the GitHub-shaped work; git does the
407git-shaped work.
408
409---
410
411## 6. CI
412
413### 6.1 Trigger and hooks
414
415Hooks are **symlinks** into `$DATA_DIR/hooks/`, installed via `init.templateDir` at
416repo creation. Symlinks, not copies, so upgrading a hook is atomic across every
417existing repo rather than leaving pre-change repos on the old version forever.
418
419| Hook | Job |
420|---|---|
421| `update` | Calls `git::store::check_ref_move`, the single implementation of the ref rule. |
422| `post-receive` | Writes a queued run record. Never blocks the push. |
423
424The queue **is** the run records (`ci::run::RunStore::queued`), not a separate
425channel: a push that lands while the server is down is still picked up when it
426returns, and the API and the executor cannot disagree about what is pending. The cost
427is latency — the executor polls, so a run waits up to `POLL` before starting. A socket
428would remove that; durability across a restart mattered more.
429
430Hooks receive `ZUKA_DATA_DIR` and `ZUKA_CI_ENABLED` through
431`Config::hook_env`. Both git spawn sites clear the environment deliberately, so a
432hook would otherwise inherit nothing and resolve the default data directory.
433
434`pre-receive` commit-signature enforcement is **cut from v1**: it needs a keyring, a
435trust model and an API surface, none of which exist. Branch protection beyond
436fast-forward is cut for the same reason. Asserting a feature into existence with a
437table row is how specs lie.
438
439### 6.2 Spec
440
441`.zuka.toml` at the repo root:
442
443```toml
444[run]
445steps = ["cargo test", "cargo build --release"]
446timeout_secs = 600
447branches = ["refs/heads/main"]
448```
449
450**There is no `runtime` key.** An earlier draft specified one, validated it against a
451list of toolchains, and then ignored it — every step ran under `/bin/sh` regardless.
452A knob that appears to select a toolchain and does not is worse than no knob. Steps
453run with whatever is on the host `PATH`; provisioning toolchains is the operator's
454job.
455
456A repository may lower `timeout_secs` but never raise it past the host ceiling,
457otherwise one repository can hold a runner slot indefinitely.
458
459### 6.3 Isolation
460
461**`tenant` mode.** The container is the boundary between accounts. It is *not* a
462boundary between untrusted CI code and the service process sharing that container, so:
463
464- The runner has its own uid. `RLIMIT_NPROC` is per-uid — sharing one with the service
465 means a fork bomb in CI takes down the service.
466- The environment is scrubbed (`env -i`). The service's credentials must not be
467 readable from `/proc/self/environ` or the unit file.
468- The tenant holds a **per-tenant KV credential**, not a shared one. A shared
469 `DENO_KV_ACCESS_TOKEN` inside a container running arbitrary code means every tenant
470 can read every other account's rows — and would make the isolation milestone's proof
471 false.
472- Limits: `RLIMIT_CPU`, `RLIMIT_AS`, `RLIMIT_FSIZE`, `RLIMIT_NOFILE`, wall clock and
473 an output cap. **`RLIMIT_NPROC` is off by default** and this is not timidity: it is
474 per-UID, so with the runner sharing a uid with the service it counts processes we
475 do not control — a useful-looking value makes `fork` fail on the first step. It
476 becomes meaningful only alongside a dedicated runner uid, which is also what makes
477 it safe.
478- Timeout kills the **cgroup**, not the shell — killing the shell orphans the tree.
479- No network: a network namespace with no route out, stated as the mechanism rather
480 than as an intention.
481
482**`standalone` mode.** Same uid separation and rlimits, no container. **This is not a
483security boundary**, CI defaults to **off**, and the README says so in those words.
484
485A step allowlist was considered and rejected as theatre — `sh -c` defeats it in one
486character. Enforcement is at the OS or it is not enforcement. That principle then has
487to be followed through, which is what the bullets above are.
488
489---
490
491## 6A. SSH
492
493Shipped in M1 (pulled forward from M7). A `russh` server in-process — not the host's
494`sshd`, which would need per-host configuration and break the single-binary promise.
495
496**Authentication is public-key only.** Password and keyboard-interactive are rejected
497outright. The offered key is matched against stored keys by raw bytes and the account
498falls out of the match; the SSH username is never read.
499
500**A session may exec exactly three commands.** `git-upload-pack`,
501`git-receive-pack` and `git-upload-archive`, against one repository. There is no
502shell, no pty and no subsystem. The exec string is parsed into a fixed service plus a
503validated repository and is never handed to a shell, so `git-upload-pack '/a/b.git';
504id` is a parse error rather than an execution.
505
506`upload-archive` was refused in an earlier draft on the grounds that it exposes tree
507contents through a separate code path. That was wrong: it exposes exactly what
508`upload-pack` already exposes to a caller who has read access, so refusing it withheld
509a normal git capability without withholding any data. It requires read, not write.
510
511**One environment variable crosses the boundary.** `GIT_PROTOCOL`, capped at 64
512bytes. Forwarding arbitrary client environment into a subprocess is an injection
513primitive: `GIT_CONFIG_*`, `GIT_ALTERNATE_OBJECT_DIRECTORIES` and `LD_PRELOAD` all
514change what the child does.
515
516**Keys carry their own scope.** `read_only` makes a key clone-and-fetch only;
517`repos[]` confines it to named repositories. A key is a credential with the reach of
518a `repo:write` token and is treated as one.
519
520**The host key is generated once** and reused, so clients are not trained to accept a
521changed fingerprint.
522
523Concurrency, the disconnect kill and the ref rule are shared with the HTTP path: the
524same semaphore bounds both, the same process-group kill applies, and a forced
525non-fast-forward is refused by `check_ref_move` regardless of which transport carried
526it.
527
528## 7. Control plane
529
530A tenant never learns its own public address. It sits on a private bridge behind the
531control plane, so the control plane passes `PUBLIC_URL` and `PUBLIC_SSH` down when it
532provisions one. Without that a tenant advertises its bind address and every clone URL
533it returns points at a container the caller cannot reach — which is what it did until
534it was tested on real infrastructure.
535
536### 7.1 Provisioning
537
538Account creation writes state `provisioning` and returns immediately. A reconciler
539drives it to `ready` or `failed`. **Never a synchronous wait on container boot** — the
540non-durable `queueMicrotask` in `yangu/roadmap/mailbox-provisioning.md` is the
541specific mistake not to repeat: no due index, no retry, no failure surface.
542
543Containers stop when idle and start on demand, with a stated cold-start latency
544budget. Always-on would mean one permanently running container per account for users
545who push twice a year.
546
547### 7.2 Proxying
548
549The control plane proxies long-lived, bidirectional, sideband-multiplexed,
550multi-gigabyte git streams, plus SSE. Backpressure must be preserved end to end and
551SSE must not be buffered. Shelling out to `git` at the engine does not help at the
552proxy hop — this is its own risk ([`ROADMAP.md`](ROADMAP.md) R6), not a free
553consequence of §1.2.
554
555---
556
557## 8. Source layout
558
559```
560zuka/
561├── Cargo.toml Cargo.lock README.md openapi.json
562├── src/
563│ ├── main.rs boot, CLI, git hook entry point
564│ ├── brand.rs the one place the product name appears
565│ ├── config.rs Config::from_env(); fails boot on a bad value
566│ ├── error.rs Error -> problem+json
567│ ├── core/ domain operations shared by every facade
568│ ├── git/
569│ │ ├── exec.rs the only place this service invokes git
570│ │ ├── validate.rs names, refs, tree paths, blob modes
571│ │ ├── store.rs repositories, hooks, ref rules, protection
572│ │ ├── discover.rs the read core, shared by REST and MCP
573│ │ ├── transport.rs streaming CGI bridge
574│ │ └── import.rs SSRF-guarded cloning
575│ ├── http/ routing, limits, responses, auth, rate limiting
576│ ├── api/ REST facade + the OpenAPI document
577│ ├── mcp/ MCP facade
578│ ├── ssh/ SSH server and exec-command parser
579│ ├── ci/ spec, run store, runner, executor
580│ ├── jobs/ gc, sweep, fsck
581│ ├── account/ identity, tokens, SSH keys
582│ └── store/ metadata, quotas, change-aware file cache
583└── test/live/ spawned-binary suite driven by real git and ssh
584```
585
586### 8.1 The core layer
587
588`core/` exists because REST and MCP each grew their own copy of repository create and
589delete, and the copies drifted: the MCP one had no rollback (so a failed `git init`
590wedged the name with a permanent `Creating` record), did not support protected refs or
591import, and left run records behind on delete — which meant re-creating a name
592resurrected the previous repository's CI history.
593
594`AppState::open_repo` is the same lesson one layer down: the sequence "resolve names,
595check access, confirm ready, get path" appeared seven times, three of them subtly
596different. `Access::Write` also enforces the disk reserve and the storage quota, so
597no write path can forget them.
598
599### 8.2 Why nested rather than flat
600
601`yangu/atlas` keeps everything in a 1,158-line `main.rs`; `yangu/kazi`'s is 3,050.
602That is a judgment call about **four protocol surfaces** — REST, MCP, git wire,
603control-plane RPC — against atlas's one, not a claim that the flat pattern has been
604measured to fail. Stating it as proven would apply a lower evidentiary standard than
605the convention it deviates from demands.
606
607### 8.3 Scheduled work has its own unit
608
609`jobs/` runs in a separate `zuka-jobs` systemd unit, not the web entrypoint: GC and
610repack, the deleted-repo sweep, the provisioning reconciler, and quota recomputation.
611Running them in the server process would violate the same rule for the same reason it
612exists.
613
614### 8.4 Errors
615
616`anyhow` internally; one enum at the boundary.
617
618```rust
619enum Error {
620 NotFound(&'static str),
621 Conflict { slug: &'static str, detail: String },
622 Unauthorized, Forbidden,
623 Invalid { slug: &'static str, detail: String },
624 Unprocessable(String),
625 MethodNotAllowed,
626 PayloadTooLarge { limit: u64 },
627 QuotaExceeded { limit: u64, used: u64 },
628 RateLimited { retry_after: u32 },
629 StorageFull,
630 Internal(anyhow::Error),
631}
632```
633
634`StorageFull` → `507`, `Internal` → `500` with the detail logged and never returned.
635There is no `Upstream` variant: nothing makes an upstream call, because hosted auth
636refuses to boot rather than being half-implemented.
637
638---
639
640## 9. Data
641
642### 9.1 Disk
643
644```
645$ZUKA_DATA_DIR/
646├── git/{account}/{repo}.git/
647├── hooks/ templateDir; symlink targets
648├── runs/{account}/{repo}/ one JSON record and one log per run
649└── tmp/deleted/ soft-deleted repos, 7-day sweep
650```
651
652### 9.2 KV
653
654| Key | Value |
655|---|---|
656| `["zuka","account",account]` | `AccountRecord` incl. provisioning state |
657| `["zuka","container",account]` | container id + endpoint (control) |
658| `["zuka","repo",account,repo]` | `RepoRecord` |
659| `["zuka","repo_index",account,repo]` | `repo` |
660| `["zuka","token",tokenHash]` | `TokenRecord` |
661| `["zuka","token_id",account,tokenId]` | `tokenHash` |
662| `["zuka","grant",account,repo,grantee]` | `Capability` |
663| `["zuka","run",account,repo,runId]` | `RunRecord` |
664| `["zuka","run_index",account,repo,ts,runId]` | `runId` |
665| `["zuka","idem",tokenId,key]` | cached response, 24h TTL |
666| `["zuka","audit",account,ts,rand]` | `AuditRow`, 90-day TTL |
667
668`token_id → tokenHash` exists so `DELETE /v1/tokens/{id}` can find the canonical row;
669without it the delete has no path to the record. The audit key carries a random suffix
670because two operations in the same millisecond are ordinary for git and would
671otherwise overwrite each other.
672
673Audit writes are fire-and-forget — a failed log row must never block the operation it
674describes.
675
676**Delete ordering: disk move first, then KV.** A crash between them leaves a repo in
677`tmp/deleted/` with live KV rows, which `zuka fsck` reports and can roll back. The
678reverse leaves an unreachable repo and a `500`.
679
680### 9.3 Backup and reconciliation
681
682Repos live on a local disk and metadata lives in a remote KV; they are never
683snapshotted together, so a restore reconciles two stores with different recovery
684points. `zuka fsck` lists orphans in both directions. **Disk is authoritative for
685repository existence; KV is authoritative for access control.** Documented because the
686system enters a split state deliberately on every delete.
687
688### 9.4 Garbage collection
689
690Every API commit writes loose objects; every push leaves a pack; `packed-refs` is
691never rewritten. Without GC, inode count and disk grow without bound.
692
693`jobs/gc` runs `git repack -Ad` + `git prune --expire` with `gc.auto=0`, holding a
694lock coordinated with `git/transport.rs` — unsynchronized GC concurrent with a live
695`upload-pack` hands the client a corrupt clone.
696
697The support case this creates is worth stating: *"the agent committed my API key."*
698Force-moving the ref leaves the blob fetchable by sha until the next GC. Product docs
699must say so rather than implying deletion is immediate.
700
701---
702
703## 10. Config and observability
704
705`std::env::var` with defaults, gathered into `Config::from_env() -> Result<Config>`
706that fails boot loudly. Prefix `ZUKA_` — including `ZUKA_BIND`; only genuinely
707shared infra names (`KV_URL`) go unprefixed.
708
709| Var | Default | Meaning |
710|---|---|---|
711| `ZUKA_BIND` | `127.0.0.1:8790` | Listen address |
712| `ZUKA_MODE` | `standalone` | `standalone` \| `control` \| `tenant` |
713| `ZUKA_DATA_DIR` | `/var/lib/zuka` | |
714| `ZUKA_ME_URL` | *unset* | Opt-in hosted auth. Unset → local token file. |
715| `ZUKA_CI_ENABLED` | `0` | Must be set explicitly in every mode (§6.3) |
716| `ZUKA_CONTROL_PUBKEY` | — | Ed25519 public key; required in `tenant` |
717| `ZUKA_DISK_RESERVE_MB` | `2048` | Below this, writes → `507` |
718| `KV_URL` | — | Per-tenant credential in `tenant` mode |
719
720`ZUKA_ME_URL` deliberately has **no default**. Defaulting it to `worklyn.me` would
721mean a self-hoster who configures nothing gets a binary that phones Worklyn to
722authenticate and returns `502` when it cannot — for the mode described as what a
723self-hoster runs.
724
725**Observability**: structured single-line logs carrying method, route, status,
726duration, account and request id. Counters for repo bytes, object counts, CI queue
727depth and run outcomes — the queue-depth counter is what makes the overflow policy in
728[`ROADMAP.md`](ROADMAP.md) D5 decidable instead of guessed. Whether this emits OTLP to
729`product/observability/` is D6.