alkblobs 0.2.0

Content-addressed blob storage: multi-hash put/get/verify store with pluggable backends, transport- and protocol-agnostic
Documentation
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
use alkcall::registry::spec::ErrorDefinition;
use serde_json::{json, Value};

use crate::keys::Key;
use crate::Error;

/// The domain error codes the JSON ops declare (alkcall ADR-016's
/// operation-level codes — distinct from the protocol-level codes; the
/// dispatch carries the declared code and its schema-conforming
/// `details` to the caller).
pub const BLOB_MISSING: &str = "BLOB_MISSING";
/// `blobs/stat`/`blobs/have`/`blobs/delete`'s ingress rejection: the
/// digest field is not tag-prefixed key hex (lowercase, 42 or 66
/// chars — the physical key encoding, the key layer's
/// rejects-never-guesses posture; ADR-020 §Decision 2).
pub const INVALID_DIGEST: &str = "INVALID_DIGEST";
/// `blobs/have`'s renewal-form arity rejection: a renewing probe
/// carries the token alongside exactly one digest (ADR-012 §2).
pub const INVALID_RENEWAL_FORM: &str = "INVALID_RENEWAL_FORM";
/// `blobs/delete`'s protection refusal (`Error::GcRefuse`): the digest
/// is pinned, liveness-live, or protect-callback-named — nothing
/// deleted.
pub const GC_REFUSED: &str = "GC_REFUSED";
/// `blobs/put`'s canonical-verification rejection (ADR-006; the
/// facade's `Error::Verification`): the stream failed the offered
/// key's own derivation — wrong length, corrupt bytes — nothing
/// committed, zero residue on both tiers. The refusal's message is the
/// stable domain text (review-001 S-4: the store core's `Display`
/// detail stays off the wire).
pub const VERIFICATION_REFUSED: &str = "VERIFICATION_REFUSED";
/// `blobs/put`'s put-time refusal (ADR-012 §6.2's kv-only over-threshold
/// error and other put-time operational failures the store core raised
/// before or during the stream): nothing committed. The refusal's
/// message is the stable domain text (review-001 S-4: the store core's
/// `Display` detail stays off the wire).
pub const PUT_REFUSED: &str = "PUT_REFUSED";
/// The binary data plane's ALPN (`blobs/fetch` and, when it lands,
/// `blobs/put`): the `channel_open` marker's `alk/blobs` — the
/// standard `channels/blobs/sub` op name derives it, so no explicit
/// `channel_open_alpn` rides the spec (ADR-047, the standard shapes).
pub const BLOBS_CHANNEL_ALPN: &str = "alk/blobs";
/// `blobs/fetch`'s open-op id — the standard `channels/<alpn>/sub`
/// shape over [`BLOBS_CHANNEL_ALPN`] (`Sub`-typed per alkcall
/// ADR-021: the server→client streaming op kind).
pub const FETCH_OP_ID: &str = "channels/blobs/sub";
/// `blobs/put`'s open-op id — the standard `channels/<alpn>/pub`
/// shape over [`BLOBS_CHANNEL_ALPN`] (`Pub`-typed per alkcall
/// ADR-047: the client→server streaming op kind).
pub const PUT_OP_ID: &str = "channels/blobs/pub";
/// `blobs/put`'s offered-claim rejection: the offer's `key` is the
/// putter's claim; the derivation under the key's own algorithm is the
/// authority — a derived key that contradicts the claim rejects the put
/// pre-EOF, before the facade's commit (zero residue — the one
/// claim-check moment for every family, ADR-020 §Decision 4). No token
/// mints.
pub const DIGEST_MISMATCH: &str = "DIGEST_MISMATCH";
/// `blobs/put`'s hand-over refusal at the token registry's admission
/// cap (ADR-019): the put's stream committed and verified on this
/// node, but its mint was refused at the registry's cap — no token
/// was issued and no pin custody was taken (non-fleet the entry
/// stands as unreferenced content a sweep may reclaim; fleet the pin
/// row stands and expires by its TTL); the re-put is CAS-idempotent.
/// The stable message names the committed digest (the putter's re-put
/// anchor; the store core's detail-free domain-text discipline,
/// review-001 S-4, carried).
pub const MINT_REFUSED: &str = "MINT_REFUSED";

/// The digest wire form's ingress (see the section doc): the wire
/// speaks the physical key encoding verbatim — `[registry byte] +
/// digest bytes`, hex-encoded — 42 chars for the 21-byte keys
/// (`0x02`/`0x03`), 66 for the 33-byte keys (`0x01`/`0x04`; ADR-020
/// §Decision 2). Lowercase-only acceptance (the schema gate's
/// `[0-9a-f]` posture — the ingress accepts nothing the gate rejects,
/// the S-5 agreement), hex-decoded then parsed by
/// [`Key::from_bytes`]: the same rejects-never-guesses decoder the
/// persistence boundary already trusts — the tag byte validated, the
/// digest length checked per tag, never inferred. **Bare digest hex
/// dies**: no implicit default-family reading survives. Rejections
/// keep the typed [`INVALID_DIGEST`] shape carrying the offending
/// string.
pub(crate) fn digest_ingress(digest_hex: &str) -> Result<Key, CallError> {
    let decode = || -> Result<Key, Error> {
        if !digest_hex
            .bytes()
            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
        {
            return Err(Error::KeyInvalid);
        }
        let bytes = hex::decode(digest_hex).map_err(|_| Error::KeyInvalid)?;
        Key::from_bytes(&bytes)
    };
    match decode() {
        Ok(key) => Ok(key),
        Err(_) => Err(CallError::new(
            INVALID_DIGEST,
            "the digest is not tag-prefixed key hex (lowercase, 42 or 66 chars)",
            false,
        )
        .with_details(json!({ "digest": digest_hex }))),
    }
}

pub(crate) fn envelope_error(request_id: &str, error: CallError) -> ResponseEnvelope {
    ResponseEnvelope::error(request_id.to_owned(), error)
}

pub(crate) fn schema_validation_error(request_id: &str) -> ResponseEnvelope {
    envelope_error(
        request_id,
        CallError::invalid_input("input failed input_schema validation"),
    )
}

/// The store-failure wire settle (review-001 S-4): a stable domain
/// text naming the stage — the store core's `Display` detail (paths,
/// DSN fragments, schema names, instance ids, engine forms) is
/// **dropped at the synthesis point**, never interpolated onto the
/// wire. The disposition was judged in-task: the lean default — no
/// `log`/`tracing` dependency is added for this; the embedder's own
/// ops logging covers its surface, and an operator debugging a store
/// failure reads the stable text on the wire and consults the store's
/// own failure surfaces for the detail. The `error` parameter stays in
/// the signature (the call sites are the store-failure arms' shape)
/// but is deliberately not the wire message's content.
pub(crate) fn store_internal_error(
    request_id: &str,
    stage: &str,
    _error: Error,
) -> ResponseEnvelope {
    envelope_error(
        request_id,
        CallError::internal(format!("the store core failed at {stage}")),
    )
}

pub(crate) fn declared_error(code: &str, description: &str, schema: Value) -> ErrorDefinition {
    ErrorDefinition {
        code: code.to_owned(),
        description: description.to_owned(),
        schema,
        http_status: None,
    }
}

/// The digest-scoped error's `details` schema (the declared error
/// payload: `{digest}`).
pub(crate) fn digest_details_schema() -> Value {
    json!({
        "type": "object",
        "properties": {
            "digest": { "type": "string" }
        },
        "required": ["digest"],
        "additionalProperties": false
    })
}

/// The digest wire form's schema gate: the physical key encoding's
/// widths — 42 hex for the 21-byte keys (`0x02`/`0x03`), 66 for the
/// 33-byte keys (`0x01`/`0x04`) — lowercase only. Gate and ingress
/// ([`digest_ingress`]) agree on every input (the S-5 discipline:
/// nothing the gate passes decodes differently below it, nothing the
/// ingress accepts the gate rejects); **bare 40/64-hex digest strings
/// are gate rejects** — no implicit default-family reading survives
/// (ADR-020 §Decision 2). The put's `key` claim and its response's
/// echoed `key` gate on the same pattern (ADR-020 §Decision 4).
pub(crate) const DIGEST_WIRE_PATTERN: &str = "^[0-9a-f]{42}$|^[0-9a-f]{66}$";

pub(crate) fn namespace_schema() -> Value {
    json!({ "type": "string", "minLength": 1 })
}

pub(crate) fn digest_wire_schema() -> Value {
    json!({ "type": "string", "pattern": DIGEST_WIRE_PATTERN })
}

pub(crate) fn digests_wire_schema() -> Value {
    json!({
        "type": "array",
        "items": digest_wire_schema()
    })
}

/// The pin-token field's wire schema (the have renewal form's `token`;
/// review-001 S-5's unbounded-capability-resource bound): `maxLength`
/// 64 plus the hex pattern, so an over-length or non-hex token string
/// is rejected at the registry's schema gate (`INVALID_INPUT`) — never
/// sized proportionally at the ingress decode below it.
///
/// **Case posture (decided, S-5's design point): the pattern is
/// case-insensitive** (`[0-9a-fA-F]`), matching
/// [`crate::ops::PinToken::from_hex`]'s case-insensitive decode
/// contract exactly — the gate and the ingress agree on every input
/// (nothing the gate passes decodes differently below it). The
/// canonical mint form stays lowercase (`to_hex`; the put response's
/// token pattern is canonical-lowercase because responses are minted,
/// not presented — that strictness was never the ingress's shape).
pub(crate) fn token_wire_schema() -> Value {
    json!({
        "type": "string",
        "maxLength": 64,
        "pattern": "^[0-9a-fA-F]{64}$"
    })
}

pub(crate) fn present_wire_schema() -> Value {
    json!({
        "type": "object",
        "properties": { "present": { "type": "array", "items": { "type": "boolean" } } },
        "required": ["present"],
        "additionalProperties": false
    })
}

pub(crate) fn json_string_field<'a>(input: &'a Value, field: &str) -> Option<&'a str> {
    input.get(field).and_then(Value::as_str)
}

pub(crate) fn json_string_array<'a>(input: &'a Value, field: &str) -> Option<Vec<&'a str>> {
    input.get(field).and_then(Value::as_array).map(|values| {
        values
            .iter()
            .map(Value::as_str)
            .collect::<Option<Vec<&str>>>()
    })?
}

/// A failed put's wire error shapes: the store core's rejection mapped
/// onto the declared put codes (the domain vocabulary — Verification
/// is the canonical-derivation failure, everything else the put-time
/// refusal family), each a **stable domain text** naming the stage
/// (review-001 S-4: the store core's `Display` detail is dropped at
/// the synthesis point — no path, DSN, or engine fragment rides the
/// wire; see [`store_internal_error`]'s disposition note). The offered
/// key hex rides the prefix where the caller offered one — the
/// schema-gated tag-prefixed form the caller itself presented, echoed
/// for its CAS re-put.
pub(crate) fn put_refused_error(stage: &str, offered_hex: Option<&str>, error: Error) -> CallError {
    let prefix = match offered_hex {
        Some(key_hex) => format!("the put of {key_hex} refused at {stage}"),
        None => format!("the put refused at {stage}"),
    };
    match error {
        Error::Verification => CallError::new(
            VERIFICATION_REFUSED,
            format!(
                "{prefix}: the stream failed verification against the offered \
                     key's derivation — nothing committed"
            ),
            false,
        ),
        _other => CallError::new(
            PUT_REFUSED,
            format!("{prefix}: the store core refused the put — nothing committed"),
            false,
        ),
    }
}

/// The offered-claim mismatch's wire error (the claim contradicted by
/// the derivation under the offered key's own algorithm — the
/// derivation is the authority, ADR-020 §Decision 4's posture): the
/// offered key hex and the derived key hex both named, both the wire
/// form (tag-prefixed) — the putter's re-put is CAS-idempotent.
pub(crate) fn digest_mismatch_error(offered_key_hex: &str, derived_key_hex: &str) -> CallError {
    CallError::new(
        DIGEST_MISMATCH,
        format!(
            "the offered key {offered_key_hex} is not the stream's derivation under the \
             key's own algorithm {derived_key_hex} (the offered key is a claim; the \
             derivation under its algorithm is the authority) — nothing handed over"
        ),
        false,
    )
    .with_details(json!({
        "key": offered_key_hex,
        "derived": derived_key_hex,
    }))
}

/// The admission-cap refusal's wire settle (ADR-019): the declared
/// [`MINT_REFUSED`] code over a **committed** put — the stable domain
/// text says exactly what happened (committed; no token; no custody;
/// the eligibility/TTL fates; the CAS-idempotent re-put), and the
/// committed key hex rides the details (the putter's re-put anchor;
/// the details field naming follows the offer's `key` field identity,
/// ADR-020 §Decision 4).
pub(crate) fn mint_refused_error(committed_key_hex: &str) -> CallError {
    CallError::new(
        MINT_REFUSED,
        format!(
            "the put of {committed_key_hex} committed on this node, but its hand-over was \
             refused: the token registry is at its admission cap — no token was issued \
             and no pin custody was taken (the entry stands as unreferenced content a \
             sweep may reclaim; a fleet serving node's pin row stands and expires by \
             its TTL); the re-put is CAS-idempotent"
        ),
        false,
    )
    .with_details(json!({ "key": committed_key_hex }))
}

/// The message-details schema for the put's declared rejections
/// (`VERIFICATION_REFUSED`, `PUT_REFUSED`, `DIGEST_MISMATCH`,
/// `MINT_REFUSED` — the detail fields are the key halves; the
/// messages are the stable
/// domain texts at the synthesis points — review-001 S-4: the store
/// core's `Display` detail is not the wire contract's content). The
/// offered-key field's naming follows the offer's `key` field
/// (ADR-020 §Decision 4); `derived` is the stream's derived key hex.
pub(crate) fn put_message_details_schema() -> Value {
    json!({
        "type": "object",
        "properties": {
            "message": { "type": "string" },
            "key": { "type": "string" },
            "derived": { "type": "string" }
        },
        "required": ["message"],
        "additionalProperties": false
    })
}