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