docs(architecture): ADR-018 — trait signatures + storage error model (review 002 R-1/R-2)

- Pin the object-storage trait signatures (GitRefs.list_refs/apply_updates,
  GitPackGen.generate/common_haves, GitPackIngest.prepare) — the A-2
  amendment's unfinished half (review 001 pinned only the registry pair)
- repo parameter is &RepoRecord (record carries storage_root; no handle
  type, no second lookup)
- Shared seam types pinned: RefLine (unborn-symref shape for the N-5
  rider), RefUpdate, RefOutcome, PreparedPush, PushOptions
- StorageError for traits 3-5; RegistryError re-scoped to the registry
  family (the 'every failure the trait family can produce' claim corrected)
- backend.md: pinned-signatures mirror section, concurrency-model
  ownership bridge, boxed-stream parameter-ownership rule

Verification: cargo doc/test/clippy/fmt clean (docs-only change)
This commit is contained in:
glm-5.3-flash committed 2026-09-30 05:30:09 +00:00
1 parent b6040d36a9
commit 0f7d5c7309
2 files changed
+352 -6

No files matched your search

+112 -6
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-09-29
status: reviewed
last_updated: 2026-09-30
---
# Backend traits: the storage seam
@@ -68,6 +68,47 @@ the three object-storage traits (3–5: `GitRefs`, `GitPackGen`,
`GitPackIngest`); a downstream with its own object store implements 3–5
and reuses 1–2 (`GitRegistry`/`GitRegistryStore`), or none of it.
### Pinned signatures (ADR-018; the freeze-inventory shapes)
The registry pair is ADR-012 §1 as amended. The object-storage family
(ADR-018 §4 — pinned there; this section is the mirror the decomposer
implements from):
- **`GitRefs`**: `list_refs(&RepoRecord) -> Vec<RefLine>` (the full
set — ref-prefix filtering is wire-side, client-driven per ls-refs
grammar) and `apply_updates(&RepoRecord, Vec<RefUpdate>, atomic:
bool) -> Vec<RefOutcome>` (one CAS transaction per call — the
atomic-correctness home, ADR-013 §7).
- **`GitPackGen`**: `generate(&RepoRecord, wants, boundary_haves,
limits, sink: Box<dyn io::Write + Send>)` (ADR-004's pipeline) and
`common_haves(&RepoRecord, haves) -> Vec<ObjectId>` (the ADR-014
ack/done-round seam; existence is the operative predicate here — the
is-commit refinement is the wire layer's ACK-line rule).
- **`GitPackIngest`**: `prepare(&RepoRecord, pack: Box<dyn io::Read +
Send>, push_options: Option<PushOptions>, limits) ->
PreparedPush` (prepare-only; the CAS application lives in
`GitRefs::apply_updates` — ADR-013 §6–7).
- The `repo` parameter is `&RepoRecord` (ADR-018 §2): the wire layer
resolves once (ADR-007 step 2) and the record carries
`storage_root` — the gix impl opens the odb from it
(`gix-discover` + `gix_odb::Store::at`) with no second lookup and no
handle type.
- Shared types (ADR-018 §5): `RefLine` (`oid: None` = the unborn
symref line, the N-5 rider's shape), `RefUpdate`
(`expected: None` = create, `new: None` = delete), `RefOutcome`
(the per-ref CAS result — an outcome, not an error; the reason string
is the client-displayed `ng` text), `PreparedPush`
(`unpack: Result<(), String>` + the updates; pack-level validation
failures land as the unpack ng leg, not `Err`), `PushOptions` (the
verbatim `(key, value)` pairs — ADR-013 §11 / N-4's additive pin).
- Object ids are `gix_hash::ObjectId` — always-on in the manifest;
hash-format-parameterized internally, so OQ-05's policy change
survives without a trait change.
- Errors: traits 1–2 → `RegistryError` (re-scoped below); traits 3–5 →
`StorageError` (ADR-018 §7 — the missing-object abort, malformed
input, and io variants; CAS and fsck outcomes are values, not
errors).
## Feature model (ADR-012 §4, amends ADR-010's single-`gix` story)
Two independent seams, two default-on features:
@@ -127,6 +168,11 @@ Two independent seams, two default-on features:
registries, network object stores) implements them natively; the
gix impl's blocking work is an internal detail, not part of the
seam.
- Bridge to the signatures (ADR-018 §4): parameter ownership is the
fork-prevention — the wire layer constructs the boxed
`Send + 'static` stream ends and owned vecs inside the permit, and
the impls move them into their internal `spawn_blocking` tasks.
Nothing is borrowed across the seam.
## Public API surface
@@ -207,10 +253,14 @@ inventory as compat surface.
ADR-016's open-op params; extensions replace, not accumulate, before
publish).
### `RegistryError` (the variant set)
### `RegistryError` (the registry-family error set)
`thiserror` enum, five variants — every failure the trait family can
produce, no catch-all:
The error type of traits 1–2 (`GitRegistry`/`GitRegistryStore`) —
the scope is the registry family, not the whole trait family (the
object-storage traits carry `StorageError`, ADR-018 §7, after the
"every failure the trait family can produce" claim was found to
over-reach — review 002 R-2). Five variants — every failure the
registry family can produce, no catch-all:
| Variant | Meaning | Wire mapping |
|---|---|---|
@@ -224,13 +274,21 @@ produce, no catch-all:
so ops can report precise JSON errors (`{"error": "already_exists", …}`)
while the trait surface stays `Result<_, RegistryError>` per ADR-012 §1.
`StorageError` (ADR-018 §7) is the object-storage sibling — traits 3–5:
`ObjectMissing { oid }` (the ADR-004 generation abort), `Invalid(String)`
(malformed/backend input), `Io(String)` (same stability rule as
`RegistryError::Io`). CAS-stale and fsck outcomes are deliberately
*not* variants: they are `RefOutcome`/`PreparedPush.unpack` values
(protocol reports the session survives), which is what keeps the
`no-catch-all` claim true for both error types.
### `git/repo/*` op JSON schemas (request/response)
| Op | Request | Response | Errors |
|---|---|---|---|
| `git/repo/create` | `{repo_id, visibility}` — storage root assigned by the store's naming rules (ADR-012 §2); grants seeded `{read, write, manage}` for the caller (ADR-015 §4); an explicit grants field is an admin-shape extension, NOT v1 | `{repo_id, visibility}` | `already_exists`, `invalid` |
| `git/repo/delete` | `{repo_id}` | `{}` | `unauthorized` (the single wire code for unknown-repo ≡ not-authorized — N-2's collapse rule; `io`) |
| `git/repo/update` | `{repo_id, visibility?, grants?}` — both optional, full-record replace of the provided fields; grant mutation is the only grant-write path (ADR-015 §3) | `{repo_id, visibility, grants}` (post-write record, no storage root) | `unauthorized` (collapsed, N-2), `invalid`, `io` |
| `git/repo/update` | `{repo_id, visibility?, grants?}` — both optional; PATCH semantics (omitted fields unchanged; present fields replaced wholesale — see the update-semantics note below); grant mutation is the only grant-write path (ADR-015 §3) | `{repo_id, visibility, grants}` (post-write record, no storage root) | `unauthorized` (collapsed, N-2), `invalid`, `io` |
| `git/repo/get` | `{repo_id}` (single read; list is a v2 additive op) | `{repo_id, visibility, grants}` (no storage root — ADR-008) | `unauthorized` (collapsed, N-2), `io` |
- Error codes are the wire `code` strings in the table (snake_case,
@@ -242,6 +300,24 @@ while the trait surface stays `Result<_, RegistryError>` per ADR-012 §1.
unauthorized, ADR-008 + review 001 N-2), so no variant leaks an
existence oracle; `AlreadyExists` is create-side only (the caller
knows the id — discloseable without an oracle).
- **Update semantics are PATCH** (review 002 R-3 — the
"full-record replace of the provided fields" qualifier was
parseable either way, and the wrong reading is destructive:
a visibility-only update clearing grants locks the grant-holder
out): omitted fields are left unchanged, present fields are
replaced wholesale (`grants`, when present, replaces the whole
grant map — partial grant edits are read-modify-write at the
caller). The response echoes the post-write record so the caller
confirms what landed, including what was left unchanged.
- **`already_exists` disclosure posture** (review 002 R-6, recorded so
a later agent does not "fix" it into an upstream-incompatible
denial): a `git:repo:create`-scoped identity probing arbitrary ids
*can* learn which exist — that is accepted. Create is a
trusted, scope-gated, low-population surface; upstream gitea
behaves the same; the position is that id-probing at create-scope
is inside the trust boundary the scope already grants, while
resolve-side disclosure (the fetch/push path) stays collapsed per
ADR-008/N-2.
- `update`'s response echoes the post-write record so grant edits
confirm what landed (the last-writer-race posture is ADR-012
§Consequences, unchanged).
@@ -249,6 +325,35 @@ while the trait surface stays `Result<_, RegistryError>` per ADR-012 §1.
alksocks `{}`-precedent — same fail-closed extension rule as
ADR-016's open-op params).
### Repo-id grammar (review 002 R-5)
`repo_id` is the registry key on every wire and op surface, so its
grammar is pinned once here:
- **Shape**: one or two path segments, `owner/name` — lowercase
alphanumerics and `-`/`_`, segments non-empty and at most 100 bytes
total (the `owner/name` convention matches self-hosted git and the
OQ-16 namespacing direction; a single segment is a valid legacy/
local shape).
- **Wire handling** (ADR-008 governs): the id is an opaque registry
key — parsed against this grammar for *rejection* only, never
decomposed (the `owner` segment confers nothing; the registry index
is flat), never joined/normalized into paths.
- **Error mapping**: a grammar-invalid id is `unauthorized` on the
serving paths (collapsed with unknown-repo, ADR-008 — the id shape
leaks nothing) and `invalid` on the op paths (create/update — the
caller needs the correction).
- **The `registry-file` store's on-disk naming** derives from the id
by percent-encoding the `/` (`alkdev/alkgit` → `alkdev%2Falkgit`
under the roots dir): flat directory, no traversal, deterministic
round-trip on reload. Implementation detail (ADR-012 §2's naming
rules), stated here because the id grammar forces the choice.
- **Http mounting note** (for doors.md consumers): two-segment ids
need a two-placeholder route (`/{owner}/{repo}/info/refs` with
single-segment ids allowed too) — the door crate pins its own route
grammar when the alkhttp `git` feature lands; alkgit's contract is
only "the full id string arrives resolution-ready."
## Design Decisions
| ADR | Decision | Summary |
@@ -265,6 +370,7 @@ while the trait surface stays `Result<_, RegistryError>` per ADR-012 §1.
| [014](decisions/014-v2-negotiation-ack-loop.md) | Negotiation | ack loop, `common_haves` seam, no `ready` |
| [016](decisions/016-native-session-preamble.md) | Native session preamble | `{repo, service}` open-op params, request-line preamble, service in the tuple |
| [017](decisions/017-consumer-half-git-session.md) | Consumer half | `GitSession` typed client (`ls_refs`/`fetch`/`push`), custom alkcall Transport + gix-protocol, hand-rolled push |
| [018](decisions/018-backend-trait-signatures-and-storage-error-model.md) | Trait signatures + storage errors | object-storage trait signatures pinned, `StorageError` for traits 3–5, `&RepoRecord` repo param (`RegistryError` re-scoped to traits 1–2) |
## Open Questions