- 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)
11 KiB
ADR-018: Backend trait signatures and the storage error model
Status
Accepted (resolves review 002 R-1/R-2; amends the scope of ADR-012 §1's
A-2 amendment ("all five traits' signatures") and corrects backend.md's
RegistryError over-claim ("every failure the trait family can
produce"))
Context
Review 001 A-2 pinned #[async_trait] on all five backend traits and
its remediation row claims "signatures per ADR-012 §1" — but ADR-012 §1
contains only the registry pair (GitRegistry, GitRegistryStore).
Review 002 (R-1) found traits 3–5 (GitRefs, GitPackGen,
GitPackIngest) have no pinned signatures anywhere in the corpus: no
method names, no shape for the repo parameter, no type for the
prepared-updates handoff that atomic depends on, no representation
for object ids or push options. These are exactly the
implementation-fork hazards review 001's criticals describe: the wire
layer and the gix impls are separate workstreams, every invented shape
silently enters OQ-03's freeze inventory, and the trait signatures are
the first thing both touch.
Review 002 (R-2) found the N-3 pin over-claims in the other direction:
RegistryError's five variants cover the registry family (traits
1–2) well, but not the object-storage traits' characteristic failures
(missing-object aborts, fsck/unpack failures, CAS-denied refs). The
"no catch-all, every failure the trait family can produce" sentence
cannot hold for a family it does not describe.
Decision
1. The registry traits stand as pinned
GitRegistry/GitRegistryStore signatures are ADR-012 §1 as amended;
nothing here changes them. RegistryError is re-scoped as the error
type of the registry family only (see 7).
2. The repo parameter is &RepoRecord on every object-storage method
The wire layer resolves the repo once (ADR-007 step 2), holds the
RepoRecord, and passes it. The record carries storage_root — the
server-side path the gix impls open (gix-discover +
gix_odb::Store::at, backend.md) — so the trait needs no second
registry lookup, no registry handle on the object traits, and no new
handle type. The never-on-the-wire rule (ADR-008) governs wire
surfaces and op responses (N-3's exclusion is an op-response rule); the
serving path is in-process by definition and already flows the record.
3. The object-id representation is gix_hash::ObjectId
gix-hash is always-on in the manifest (the wire layer parses pkt-line
hex into it), so the trait family shares it rather than inventing a
parallel oid type. The type is hash-format-parameterized internally
(the enum carries the format tag), so the representation survives an
OQ-05 policy change; sha1 is the only constructed format in v1.
4. Signatures
#[async_trait]
pub trait GitRefs: Send + Sync {
/// Ref listing — advertisement + ls-refs response data.
/// The full set; ref-prefix filtering is wire-side (client-driven).
async fn list_refs(&self, repo: &RepoRecord) -> Result<Vec<RefLine>, StorageError>;
/// One CAS transaction per call (the atomic-correctness home, ADR-013 §7).
/// Runs the per-ref CAS; per-ref denials are outcomes (RefOutcome), not errors.
async fn apply_updates(&self, repo: &RepoRecord, updates: Vec<RefUpdate>, atomic: bool) -> Result<Vec<RefOutcome>, StorageError>;
}
#[async_trait]
pub trait GitPackGen: Send + Sync {
/// closure(wants) − closure(boundary_haves) → pack streamed into sink
/// (ADR-004's pipeline; missing objects abort per ADR-004 — never a broken pack).
async fn generate(&self, repo: &RepoRecord, wants: Vec<ObjectId>, boundary_haves: Vec<ObjectId>, limits: Limits, sink: Box<dyn io::Write + Send>) -> Result<(), StorageError>;
/// The negotiation ack seam (ADR-014 §5, §2's done-round amendment).
/// A have is recognized iff it exists in the object store; the is-commit
/// refinement is the wire layer's ACK-line rule, not this method's.
async fn common_haves(&self, repo: &RepoRecord, haves: Vec<ObjectId>) -> Result<Vec<ObjectId>, StorageError>;
}
#[async_trait]
pub trait GitPackIngest: Send + Sync {
/// Unpack + index + fsck; prepares the ref updates. Never applies them
/// (the single CAS home is `GitRefs::apply_updates` — ADR-013 §6–7).
async fn prepare(&self, repo: &RepoRecord, pack: Box<dyn io::Read + Send>, push_options: Option<PushOptions>, limits: Limits) -> Result<PreparedPush, StorageError>;
}
Owned-value parameters (Vec, Limits, the boxed streams) are the
spawn_blocking-friendly shape: the gix impls move them into their
internal blocking tasks (backend.md concurrency model — the permit
stays wire-side, the threads stay impl-side). Boxed
Send + 'static streams are the blocking-side ends the substrate
bridges produce (the sideband sink's io::Write end; the request
body's io::Read end — the parked-channel bridges are ADR-005
substrate structure, wire-layer-constructed).
5. The shared object-storage types
/// One listed ref. `oid: None` is the unborn symref line (the N-5 rider's
/// "symref-target with no oid"; the wire layer maps it to the zero-oid
/// advertisement/ls-refs line). Advertisement projects oid+name only —
/// peeled tags and symref targets are ls-refs-arg data.
pub struct RefLine {
pub name: String,
pub oid: Option<ObjectId>,
pub peeled: Option<ObjectId>,
pub symref_target: Option<String>,
}
/// One prepared/applied ref update. `expected: None` = must-not-exist
/// (create); `new: None` = delete. Identical in the prepare handoff and
/// the CAS call — one type across the seam (R-1's fork point).
pub struct RefUpdate {
pub name: String,
pub expected: Option<ObjectId>,
pub new: Option<ObjectId>,
}
/// Per-ref CAS outcome (an outcome, not an error — a stale ref is a
/// business result of receive-pack, ADR-013 §7–8). The reason string is
/// the client-displayed `ng <ref> <reason>` text (server-chosen, verbatim).
pub struct RefOutcome {
pub name: String,
pub result: Result<(), String>,
}
/// The prepare output. `unpack` is the report's unpack leg (ok, or the
/// `unpack ng <reason>` text); pack-level validation failures (parse,
/// index, fsck connectivity) land here as the ng leg, not as Err —
/// the session continues to the report per ADR-013 §8. `Err` is for
/// substrate/infrastructure failure only (7).
pub struct PreparedPush {
pub unpack: Result<(), String>,
pub updates: Vec<RefUpdate>,
}
/// Parsed push-options — `(key, value)` pairs verbatim, un-interpreted
/// (ADR-013 §11; repeated keys preserved). `None` on `prepare` until the
/// config gate opens; opening the gate later is value-additive.
pub struct PushOptions {
pub pairs: Vec<(String, String)>,
}
Ref-name validation is not in these types: the deny-list and the
funny refname mapping live in the transport layer (ADR-013 §9); the
transport consumes PreparedPush.updates, validates names, and builds
the apply_updates call.
6. The atomic flag is the call's, not the list's
apply_updates(..., atomic: bool): non-atomic applies each update and
reports per-ref outcomes; atomic prepares the whole transaction and
commits it only if every update succeeds, mapping the abort to ng <ref> atomic push failure per ADR-013 §10. One flag because both
behaviors share one transaction mechanism (§4 above); a per-update
policy flag would be a trait redesign for a case ADR-013 already
settles.
7. The storage error model: StorageError for traits 3–5
RegistryError (backend.md §Registry types and schemas) is re-scoped:
it is the error type of the registry family (traits 1–2) — the "every
failure the trait family can produce" sentence now reads "every failure
the registry family can produce." The object-storage family gets its
own error type:
/// Object-storage family error (GitRefs / GitPackGen / GitPackIngest).
pub enum StorageError {
/// A requested object does not exist. Generation aborts per ADR-004
/// (never a broken pack); fsck-level missing reachability is reported
/// through `PreparedPush.unpack`, not this variant.
ObjectMissing { oid: ObjectId },
/// Structurally invalid backend input (malformed pack stream reaching
/// the impl, an unborn/misconfigured storage root). Session-op error.
Invalid(String),
/// Backing-store/filesystem failure (message-only — not
/// `std::io::Error`, which is not a stable surface; same rule as
/// `RegistryError::Io`).
Io(String),
}
| Variant | Wire mapping |
|---|---|
ObjectMissing |
sideband error band (fetch) — reason text may include the oid (the client computed it; no oracle risk) |
Invalid |
protocol error band (duplex) or 4xx-class body (stateless) |
Io |
session/op error, substrate-appropriate — same treatment as RegistryError::Io |
Per-ref CAS failures are RefOutcome values, not errors (5 above); fsck
failures and pack-level corruption are PreparedPush.unpack ng legs
(5) — both are protocol reports the session survives, which is exactly
what separates them from the error type.
Consequences
- Positive: the trait surface is fully pinned in one place
(backend.md remains authoritative per ADR-004's amendment note) — the
wire layer, gix impls, and any third-backend embedder now implement
against the same signatures; the prepared-updates handoff and the
atomicmechanism are type-expressible; the freeze inventory gains real shapes instead of prose (OQ-03);PushOptions' additive path is concrete. - Negative: frozen Rust-adjacent shapes grow (the five types and
the method set join OQ-03's inventory — first-publish compat surface);
RefOutcome/unpackedreason strings are the client-visible text surface, which is exactly as ADR-013 §8 planned but is now typed here. - Neutral: the manifest needs no change (
gix-hash,async-trait, and the substrate's channel/parking primitives are all already carried); parameter ownership (boxed streams, owned vecs) is the implementation-fork prevention for the A-6 execution model.
References
- Review 002 R-1, R-2 (the triggers), R-3/R-5/R-6 (the op-surface decisions this ADR's siblings pin in backend.md)
- Review 001 A-2 (the
#[async_trait]amendment whose scope is completed here), A-6 (the execution model these signatures imply), N-4 (thePushOptionsparameter this ADR types) - ADR-012 §1 (the registry signatures — unchanged), §3 (ops over the
store), ADR-004 (the generation/ingestion pipelines these signatures
wrap; backend.md authoritative), ADR-013 §6–9, §11 (prepare/CAS/
atomic/validation/push-options semantics), ADR-014 §2, §5
(
common_haves, done-round boundary), ADR-005 (the substrate bridges the boxed streams terminate), ADR-002 (the session tuples carrying the resolution these methods consume), ADR-008 (never-on-the-wire scoping), ADR-009 (Limitsin the signatures) - backend.md §"The trait family" (mirror), §"Registry types and
schemas" (the re-scoped
RegistryError+StorageErrortable), OQ-03 (freeze inventory these shapes enter) - gitoxide:
gix_hash::ObjectId(the shared oid type),gix-reftransactions (the CASapply_updateswraps)