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.md37.1 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 `private` (the default) or `public`. A record written before the
269field existed reads as private: absence meaning public would have published every
270repository on a host at the first upgrade that understood it.
271
272Public grants **anonymous read and nothing else** — the browser views (§4.9) and
273`git clone`. It never grants a write: `git-receive-pack` demands an identity before
274anything else, and there is no way to spell an anonymous write to have to refuse it.
275Anonymous access is granted in exactly one function, `AppState::open_public_repo`;
276every other path in the service requires an `Identity`, so a handler that forgets it
277produces a `401`, not a disclosure.
278
279A credential that is presented and rejected stays rejected on every surface. It never
280degrades to an anonymous visitor, because that turns an expired token into a success
281on public repositories and a bare `404` on private ones.
282
283On the git paths an anonymous read that is refused answers `401` with a challenge,
284not `404` — git only runs its credential helper on a challenge. The answer is `401`
285whether the repository is private or absent, so existence is still not disclosed. The
286browser surface answers `404` instead, because a challenge there raises a login
287dialog no visitor can satisfy.
288
289### 4.8a Account management is operator-only
290
291`/v1/accounts*` on the control plane creates, lists and destroys tenants. Creating one
292provisions a container; deleting one destroys its repositories. None of that is a
293tenant operation, so it is not reachable with an ordinary account's credential — a
294tenant admin token must not be able to delete a different tenant.
295
296The operator is named by `<PREFIX>OPERATOR` and must present an admin-scoped token
297for that account, unconfined by a repository list. When the variable is unset every
298account endpoint is refused: the alternative default was a host on which a stranger
299could enumerate every tenant and delete them.
300
301### 4.9 Browser views
302
303A read-only, server-rendered HTML surface: repository home with the README, tree
304browsing, file view, commit list, commit detail and refs. No session, no cookie, no
305form, and no write of any kind.
306
307Server-rendered from the binary with no bundler, no CDN and no client framework — the
308product ships as one file a stranger drops on a VM, and a viewer needing `npm
309install` at release time would end that. It reads through `git::discover`, the same
310code the REST API and the MCP tools use, so the browser cannot disagree with the API.
311
312URLs are `/{account}/{repo}`, exactly the clone URL without `.git`, plus
313`/tree/{ref}/{path}`, `/blob/{ref}/{path}`, `/commits/{ref}`, `/commit/{sha}` and
314`/refs`. `/` redirects to the repository named by `ZUKA_HOME_REPO`, so every page has
315one address; serving the same repository at two prefixes would make `/blob/x`
316impossible to tell from an account named `blob`.
317
318Every interpolated value is escaped. Markdown drops raw HTML and rewrites link
319schemes to an allow-list, because a README is written by anyone who can push — a
320stranger, on a public repository. The response carries
321`content-security-policy: default-src 'none'` with no script source at all, so an
322injection that survived the escaping would still have nowhere to execute.
323
324The control plane proxies this surface to tenants **without signing an assertion**
325for anonymous visitors. An assertion is the control plane stating "this request is
326that account"; minting one for a stranger would hand them that account's private
327repositories.
328
329### 4.3 Refs — read only
330
331Full names (`refs/heads/main`), so branches, tags and `refs/notes/*` are all
332addressable.
333
334| Method | Path | Notes |
335|---|---|---|
336| `GET` | `/v1/repos/{a}/{r}/refs` | Paginated. `?type=branch\|tag\|note` filters. |
337| `GET` | `/v1/repos/{a}/{r}/refs/{name}` | `ETag: "<sha>"`. |
338
339Refs are moved with `git push`. There is no `PUT` or `DELETE` here: a second way to
340move a ref is a second place for the fast-forward rule to be wrong, and §1.3 exists
341to avoid exactly that.
342
343### 4.4 Content — read only
344
345Ref is a **query parameter**, never a path segment. `feature/login` and `src/main.rs`
346both contain slashes, so `/raw/{ref}/{path}` has no unique parse — and percent-encoding
347does not save it, because most reverse proxies normalize `%2F` before the handler sees
348it.
349
350| Method | Path | Notes |
351|---|---|---|
352| `GET` | `/v1/repos/{a}/{r}/tree/{path}?ref=` | Paginated. |
353| `GET` | `/v1/repos/{a}/{r}/raw/{path}?ref=` | `HEAD` and `Range` supported. |
354| `GET` | `/v1/repos/{a}/{r}/commits?ref=&path=&cursor=` | |
355| `GET` | `/v1/repos/{a}/{r}/commits/{sha}` | |
356| `GET` | `/v1/repos/{a}/{r}/compare/{base}...{head}` | Diff. |
357| `GET` | `/v1/repos/{a}/{r}/search?q=&ref=&path=` | Bounded content search. |
358
359These exist so a caller can answer "what is in here" without a clone — a dashboard
360rendering a file, an agent checking whether a path exists before it bothers fetching.
361They are a convenience over the git transport, not a substitute for it, and they are
362strictly read-only.
363
364`GET /commits?path=` walks the DAG to find matching commits and is a trivial DoS
365(`?path=nonexistent` walks all history). It carries a walk budget and returns a
366`truncated` flag rather than running unbounded.
367
368### 4.6 Runs, tokens, grants, account
369
370| Method | Path | Notes |
371|---|---|---|
372| `GET` | `/v1/repos/{a}/{r}/runs` | Newest first. `?status=`. |
373| `POST` | `/v1/repos/{a}/{r}/runs` | `{ref}` → `201`. |
374| `GET` | `/v1/repos/{a}/{r}/runs/{id}` | |
375| `PATCH` | `/v1/repos/{a}/{r}/runs/{id}` | `{status:"cancelled"}`. Terminal → `409`. Not `DELETE` — the run still exists after. |
376| `GET` | `/v1/repos/{a}/{r}/runs/{id}/logs` | `text/plain`, complete-so-far, `Range`-capable. |
377| `GET` | `/v1/repos/{a}/{r}/runs/{id}/logs/stream` | SSE. Honours `Last-Event-ID`; event ids are byte offsets, so a dropped connection resumes. |
378| `GET` `POST` | `/v1/tokens` | §2.2. Never returns a secret after creation. |
379| `DELETE` | `/v1/tokens/{id}` | |
380| `GET` | `/v1/repos/{a}/{r}/grants` | |
381| `PUT` `DELETE` | `/v1/repos/{a}/{r}/grants/{account}` | Owner-only. `write` ⇒ code execution (§2.1). |
382| `GET` | `/v1/account` | Identity, scopes, quota usage. |
383| `DELETE` | `/v1/account` | Cascades repos, tokens, grants, container teardown. |
384
385Log content type is fixed per endpoint. Negotiating on *run state* — SSE while
386running, plain text once finished — makes an identical request's content type depend
387on a race with the runner. That is not content negotiation.
388
389### 4.7 Non-REST endpoints
390
391Shape dictated by an external spec, so deliberately outside `/v1`:
392
393```
394GET /{account}/{repo}.git/info/refs?service=…
395POST /{account}/{repo}.git/git-upload-pack
396POST /{account}/{repo}.git/git-receive-pack
397POST /mcp
398GET /mcp SSE channel, per MCP transport
399GET /healthz includes free-disk check
400```
401
402**The git paths have their own error contract and RFC 9457 does not apply to them.**
403`401` must carry `WWW-Authenticate: Basic realm="zuka"` or git never invokes its
404credential helper and the clone fails instead of prompting. Responses use
405`application/x-git-*-result`; `info/refs` sets `Cache-Control: no-cache, max-age=0,
406must-revalidate`. `problem+json` here is noise git will render at the user.
407
408### 4.8 `/raw` is a hostile-content endpoint
409
410Always `application/octet-stream`, `Content-Disposition: attachment`,
411`X-Content-Type-Options: nosniff`, `Content-Security-Policy: sandbox`. Never sniffed,
412never caller-specified. GitHub runs `raw.githubusercontent.com` on a separate origin
413for exactly this reason.
414
415CORS is **disabled by default** — §3 of the README says there is no web UI here, so
416there is no first-party browser origin to serve. Where enabled it is an explicit
417allowlist; `Origin` is never reflected. CORS plus bearer auth plus attacker-controlled
418bytes is the standard cross-origin theft recipe.
419
420Cache: `public, max-age=31536000, immutable` when `ref` is a full sha, `no-cache`
421otherwise.
422
423### 4.9 Limits
424
425Stated, not implied: max request body 10 MiB; max `changes[]` length 1,000; max blob
42632 MiB via API (pushes are bounded by `receive.maxInputSize`); tree and ref pages
427paginated. Over-quota → `413` or `quota-exceeded`. Free disk below the reserve
428threshold → `507` **before** the disk fills, because `receive-pack` interrupted by
429ENOSPC leaves a partial pack.
430
431---
432
433## 5. MCP
434
435`POST /mcp` JSON-RPC 2.0 plus the `GET` SSE channel and session identification the
436current transport revision requires. Auth is a bearer token; whether that satisfies
437the target client's authorization expectations is verified in M4, not assumed.
438
439### 5.1 Tools
440
441Administration and inspection. There is no tool that writes to a repository — an
442agent that wants to change code runs `git`, which it already knows how to do.
443
444| Tool | What it is for |
445|---|---|
446| `repo_create` `repo_list` `repo_get` `repo_delete` | Provisioning. `repo_create` returns both clone URLs and may seed from `import_url`. |
447| `key_add` `key_list` `key_remove` | Register the agent's own SSH key so it can then use git. |
448| `ref_list` `tree_list` `file_read` `search` `commit_log` `commit_get` `compare` `blame` | Read without cloning. |
449| `run_list` `run_get` `run_logs` | Watch CI after a push. |
450
451
452Each maps onto the same core call the REST handler uses.
453
454### 5.2 What the facade may and may not do
455
456Facades may fill defaults and reshape payloads — resolving `ref` to the repository's
457default branch, for instance. They may not carry authorization rules; those live on
458`Identity` so REST, MCP and SSH cannot drift (§1.1).
459
460The intended shape of an agent session is: `repo_create` → `key_add` → then plain
461`git clone`, `git commit`, `git push` in the agent's own workspace → `run_status` to
462see whether CI passed. The MCP surface removes the GitHub-shaped work; git does the
463git-shaped work.
464
465---
466
467## 6. CI
468
469### 6.1 Trigger and hooks
470
471Hooks are **symlinks** into `$DATA_DIR/hooks/`, installed via `init.templateDir` at
472repo creation. Symlinks, not copies, so upgrading a hook is atomic across every
473existing repo rather than leaving pre-change repos on the old version forever.
474
475| Hook | Job |
476|---|---|
477| `update` | Calls `git::store::check_ref_move`, the single implementation of the ref rule. |
478| `post-receive` | Writes a queued run record. Never blocks the push. |
479
480The queue **is** the run records (`ci::run::RunStore::queued`), not a separate
481channel: a push that lands while the server is down is still picked up when it
482returns, and the API and the executor cannot disagree about what is pending. The cost
483is latency — the executor polls, so a run waits up to `POLL` before starting. A socket
484would remove that; durability across a restart mattered more.
485
486Hooks receive `ZUKA_DATA_DIR` and `ZUKA_CI_ENABLED` through
487`Config::hook_env`. Both git spawn sites clear the environment deliberately, so a
488hook would otherwise inherit nothing and resolve the default data directory.
489
490`pre-receive` commit-signature enforcement is **cut from v1**: it needs a keyring, a
491trust model and an API surface, none of which exist. Branch protection beyond
492fast-forward is cut for the same reason. Asserting a feature into existence with a
493table row is how specs lie.
494
495### 6.2 Spec
496
497`.zuka.toml` at the repo root:
498
499```toml
500[run]
501steps = ["cargo test", "cargo build --release"]
502timeout_secs = 600
503branches = ["refs/heads/main"]
504```
505
506**There is no `runtime` key.** An earlier draft specified one, validated it against a
507list of toolchains, and then ignored it — every step ran under `/bin/sh` regardless.
508A knob that appears to select a toolchain and does not is worse than no knob. Steps
509run with whatever is on the host `PATH`; provisioning toolchains is the operator's
510job.
511
512A repository may lower `timeout_secs` but never raise it past the host ceiling,
513otherwise one repository can hold a runner slot indefinitely.
514
515### 6.3 Isolation
516
517**`tenant` mode.** The container is the boundary between accounts. It is *not* a
518boundary between untrusted CI code and the service process sharing that container, so:
519
520- The runner has its own uid. `RLIMIT_NPROC` is per-uid — sharing one with the service
521 means a fork bomb in CI takes down the service.
522- The environment is scrubbed (`env -i`). The service's credentials must not be
523 readable from `/proc/self/environ` or the unit file.
524- The tenant holds a **per-tenant KV credential**, not a shared one. A shared
525 `DENO_KV_ACCESS_TOKEN` inside a container running arbitrary code means every tenant
526 can read every other account's rows — and would make the isolation milestone's proof
527 false.
528- Limits: `RLIMIT_CPU`, `RLIMIT_AS`, `RLIMIT_FSIZE`, `RLIMIT_NOFILE`, wall clock and
529 an output cap. **`RLIMIT_NPROC` is off by default** and this is not timidity: it is
530 per-UID, so with the runner sharing a uid with the service it counts processes we
531 do not control — a useful-looking value makes `fork` fail on the first step. It
532 becomes meaningful only alongside a dedicated runner uid, which is also what makes
533 it safe.
534- Timeout kills the **cgroup**, not the shell — killing the shell orphans the tree.
535- No network: a network namespace with no route out, stated as the mechanism rather
536 than as an intention.
537
538**`standalone` mode.** Same uid separation and rlimits, no container. **This is not a
539security boundary**, CI defaults to **off**, and the README says so in those words.
540
541A step allowlist was considered and rejected as theatre — `sh -c` defeats it in one
542character. Enforcement is at the OS or it is not enforcement. That principle then has
543to be followed through, which is what the bullets above are.
544
545---
546
547## 6A. SSH
548
549Shipped in M1 (pulled forward from M7). A `russh` server in-process — not the host's
550`sshd`, which would need per-host configuration and break the single-binary promise.
551
552**Authentication is public-key only.** Password and keyboard-interactive are rejected
553outright. The offered key is matched against stored keys by raw bytes and the account
554falls out of the match; the SSH username is never read.
555
556**A session may exec exactly three commands.** `git-upload-pack`,
557`git-receive-pack` and `git-upload-archive`, against one repository. There is no
558shell, no pty and no subsystem. The exec string is parsed into a fixed service plus a
559validated repository and is never handed to a shell, so `git-upload-pack '/a/b.git';
560id` is a parse error rather than an execution.
561
562`upload-archive` was refused in an earlier draft on the grounds that it exposes tree
563contents through a separate code path. That was wrong: it exposes exactly what
564`upload-pack` already exposes to a caller who has read access, so refusing it withheld
565a normal git capability without withholding any data. It requires read, not write.
566
567**One environment variable crosses the boundary.** `GIT_PROTOCOL`, capped at 64
568bytes. Forwarding arbitrary client environment into a subprocess is an injection
569primitive: `GIT_CONFIG_*`, `GIT_ALTERNATE_OBJECT_DIRECTORIES` and `LD_PRELOAD` all
570change what the child does.
571
572**Keys carry their own scope.** `read_only` makes a key clone-and-fetch only;
573`repos[]` confines it to named repositories. A key is a credential with the reach of
574a `repo:write` token and is treated as one.
575
576**The host key is generated once** and reused, so clients are not trained to accept a
577changed fingerprint.
578
579Concurrency, the disconnect kill and the ref rule are shared with the HTTP path: the
580same semaphore bounds both, the same process-group kill applies, and a forced
581non-fast-forward is refused by `check_ref_move` regardless of which transport carried
582it.
583
584## 7. Control plane
585
586A tenant never learns its own public address. It sits on a private bridge behind the
587control plane, so the control plane passes `PUBLIC_URL` and `PUBLIC_SSH` down when it
588provisions one. Without that a tenant advertises its bind address and every clone URL
589it returns points at a container the caller cannot reach — which is what it did until
590it was tested on real infrastructure.
591
592### 7.1 Provisioning
593
594Account creation writes state `provisioning` and returns immediately. A reconciler
595drives it to `ready` or `failed`. **Never a synchronous wait on container boot** — the
596non-durable `queueMicrotask` in `yangu/roadmap/mailbox-provisioning.md` is the
597specific mistake not to repeat: no due index, no retry, no failure surface.
598
599Containers stop when idle and start on demand, with a stated cold-start latency
600budget. Always-on would mean one permanently running container per account for users
601who push twice a year.
602
603### 7.2 Proxying
604
605The control plane proxies long-lived, bidirectional, sideband-multiplexed,
606multi-gigabyte git streams, plus SSE. Backpressure must be preserved end to end and
607SSE must not be buffered. Shelling out to `git` at the engine does not help at the
608proxy hop — this is its own risk ([`ROADMAP.md`](ROADMAP.md) R6), not a free
609consequence of §1.2.
610
611---
612
613## 8. Source layout
614
615```
616zuka/
617├── Cargo.toml Cargo.lock README.md openapi.json
618├── src/
619│ ├── main.rs boot, CLI, git hook entry point
620│ ├── brand.rs the one place the product name appears
621│ ├── config.rs Config::from_env(); fails boot on a bad value
622│ ├── error.rs Error -> problem+json
623│ ├── core/ domain operations shared by every facade
624│ ├── git/
625│ │ ├── exec.rs the only place this service invokes git
626│ │ ├── validate.rs names, refs, tree paths, blob modes
627│ │ ├── store.rs repositories, hooks, ref rules, protection
628│ │ ├── discover.rs the read core, shared by REST and MCP
629│ │ ├── transport.rs streaming CGI bridge
630│ │ └── import.rs SSRF-guarded cloning
631│ ├── http/ routing, limits, responses, auth, rate limiting
632│ ├── api/ REST facade + the OpenAPI document
633│ ├── mcp/ MCP facade
634│ ├── ssh/ SSH server and exec-command parser
635│ ├── ci/ spec, run store, runner, executor
636│ ├── jobs/ gc, sweep, fsck
637│ ├── account/ identity, tokens, SSH keys
638│ └── store/ metadata, quotas, change-aware file cache
639└── test/live/ spawned-binary suite driven by real git and ssh
640```
641
642### 8.1 The core layer
643
644`core/` exists because REST and MCP each grew their own copy of repository create and
645delete, and the copies drifted: the MCP one had no rollback (so a failed `git init`
646wedged the name with a permanent `Creating` record), did not support protected refs or
647import, and left run records behind on delete — which meant re-creating a name
648resurrected the previous repository's CI history.
649
650`AppState::open_repo` is the same lesson one layer down: the sequence "resolve names,
651check access, confirm ready, get path" appeared seven times, three of them subtly
652different. `Access::Write` also enforces the disk reserve and the storage quota, so
653no write path can forget them.
654
655### 8.2 Why nested rather than flat
656
657`yangu/atlas` keeps everything in a 1,158-line `main.rs`; `yangu/kazi`'s is 3,050.
658That is a judgment call about **four protocol surfaces** — REST, MCP, git wire,
659control-plane RPC — against atlas's one, not a claim that the flat pattern has been
660measured to fail. Stating it as proven would apply a lower evidentiary standard than
661the convention it deviates from demands.
662
663### 8.3 Scheduled work has its own unit
664
665`jobs/` runs in a separate `zuka-jobs` systemd unit, not the web entrypoint: GC and
666repack, the deleted-repo sweep, the provisioning reconciler, and quota recomputation.
667Running them in the server process would violate the same rule for the same reason it
668exists.
669
670### 8.4 Errors
671
672`anyhow` internally; one enum at the boundary.
673
674```rust
675enum Error {
676 NotFound(&'static str),
677 Conflict { slug: &'static str, detail: String },
678 Unauthorized, Forbidden,
679 Invalid { slug: &'static str, detail: String },
680 Unprocessable(String),
681 MethodNotAllowed,
682 PayloadTooLarge { limit: u64 },
683 QuotaExceeded { limit: u64, used: u64 },
684 RateLimited { retry_after: u32 },
685 StorageFull,
686 Internal(anyhow::Error),
687}
688```
689
690`StorageFull` → `507`, `Internal` → `500` with the detail logged and never returned.
691There is no `Upstream` variant: nothing makes an upstream call, because hosted auth
692refuses to boot rather than being half-implemented.
693
694---
695
696## 9. Data
697
698### 9.1 Disk
699
700```
701$ZUKA_DATA_DIR/
702├── git/{account}/{repo}.git/
703├── hooks/ templateDir; symlink targets
704├── runs/{account}/{repo}/ one JSON record and one log per run
705└── tmp/deleted/ soft-deleted repos, 7-day sweep
706```
707
708### 9.2 KV
709
710| Key | Value |
711|---|---|
712| `["zuka","account",account]` | `AccountRecord` incl. provisioning state |
713| `["zuka","container",account]` | container id + endpoint (control) |
714| `["zuka","repo",account,repo]` | `RepoRecord` |
715| `["zuka","repo_index",account,repo]` | `repo` |
716| `["zuka","token",tokenHash]` | `TokenRecord` |
717| `["zuka","token_id",account,tokenId]` | `tokenHash` |
718| `["zuka","grant",account,repo,grantee]` | `Capability` |
719| `["zuka","run",account,repo,runId]` | `RunRecord` |
720| `["zuka","run_index",account,repo,ts,runId]` | `runId` |
721| `["zuka","idem",tokenId,key]` | cached response, 24h TTL |
722| `["zuka","audit",account,ts,rand]` | `AuditRow`, 90-day TTL |
723
724`token_id → tokenHash` exists so `DELETE /v1/tokens/{id}` can find the canonical row;
725without it the delete has no path to the record. The audit key carries a random suffix
726because two operations in the same millisecond are ordinary for git and would
727otherwise overwrite each other.
728
729Audit writes are fire-and-forget — a failed log row must never block the operation it
730describes.
731
732**Delete ordering: disk move first, then KV.** A crash between them leaves a repo in
733`tmp/deleted/` with live KV rows, which `zuka fsck` reports and can roll back. The
734reverse leaves an unreachable repo and a `500`.
735
736### 9.3 Backup and reconciliation
737
738Repos live on a local disk and metadata lives in a remote KV; they are never
739snapshotted together, so a restore reconciles two stores with different recovery
740points. `zuka fsck` lists orphans in both directions. **Disk is authoritative for
741repository existence; KV is authoritative for access control.** Documented because the
742system enters a split state deliberately on every delete.
743
744### 9.4 Garbage collection
745
746Every API commit writes loose objects; every push leaves a pack; `packed-refs` is
747never rewritten. Without GC, inode count and disk grow without bound.
748
749`jobs/gc` runs `git repack -Ad` + `git prune --expire` with `gc.auto=0`, holding a
750lock coordinated with `git/transport.rs` — unsynchronized GC concurrent with a live
751`upload-pack` hands the client a corrupt clone.
752
753The support case this creates is worth stating: *"the agent committed my API key."*
754Force-moving the ref leaves the blob fetchable by sha until the next GC. Product docs
755must say so rather than implying deletion is immediate.
756
757---
758
759## 10. Config and observability
760
761`std::env::var` with defaults, gathered into `Config::from_env() -> Result<Config>`
762that fails boot loudly. Prefix `ZUKA_` — including `ZUKA_BIND`; only genuinely
763shared infra names (`KV_URL`) go unprefixed.
764
765| Var | Default | Meaning |
766|---|---|---|
767| `ZUKA_BIND` | `127.0.0.1:8790` | Listen address |
768| `ZUKA_MODE` | `standalone` | `standalone` \| `control` \| `tenant` |
769| `ZUKA_DATA_DIR` | `/var/lib/zuka` | |
770| `ZUKA_ME_URL` | *unset* | Opt-in hosted auth. Unset → local token file. |
771| `ZUKA_CI_ENABLED` | `0` | Must be set explicitly in every mode (§6.3) |
772| `ZUKA_CONTROL_PUBKEY` | — | Ed25519 public key; required in `tenant` |
773| `ZUKA_DISK_RESERVE_MB` | `2048` | Below this, writes → `507` |
774| `KV_URL` | — | Per-tenant credential in `tenant` mode |
775
776`ZUKA_ME_URL` deliberately has **no default**. Defaulting it to `worklyn.me` would
777mean a self-hoster who configures nothing gets a binary that phones Worklyn to
778authenticate and returns `502` when it cannot — for the mode described as what a
779self-hoster runs.
780
781**Observability**: structured single-line logs carrying method, route, status,
782duration, account and request id. Counters for repo bytes, object counts, CI queue
783depth and run outcomes — the queue-depth counter is what makes the overflow policy in
784[`ROADMAP.md`](ROADMAP.md) D5 decidable instead of guessed. Whether this emits OTLP to
785`product/observability/` is D6.