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