alkblobs 0.1.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::hashing::Digest;
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 the canonical digest hex (lowercase, 40 or 64
/// chars — the key layer's rejects-never-guesses posture).
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 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-digest rejection: the offer's `digest` is the
/// putter's claim; the canonical derivation is the authority — a
/// derived key that contradicts the claim rejects the put. On the
/// known-length form the check runs before the facade's EOF (zero
/// residue); on the unknown-length form at the put's completion (the
/// derivation pass is the facade's own). No token mints either way.
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 key
/// layer's hex parse — lowercase 40/64-char digest hex, **the blob
/// family only** (ADR-018 §Decision 2: no length-based family
/// guessing; a git-object oid answers the miss for its
/// blob-family-shaped key) — wrapped into the typed [`INVALID_DIGEST`]
/// error carrying the offending string.
pub(crate) fn digest_ingress(digest_hex: &str) -> Result<Key, CallError> {
    match Digest::from_git_oid_hex(digest_hex) {
        Ok(digest) => Ok(Key::from(digest)),
        Err(_) => Err(CallError::new(
            INVALID_DIGEST,
            "the digest is not canonical digest hex (lowercase, 40 or 64 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
    })
}

const DIGEST_WIRE_PATTERN: &str = "^[0-9a-f]{40}$|^[0-9a-f]{64}$";

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
/// digest hex rides the prefix where the caller offered one — the
/// schema-gated canonical 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(digest_hex) => format!("the put of {digest_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 canonical \
                     derivation — nothing committed"
            ),
            false,
        ),
        _other => CallError::new(
            PUT_REFUSED,
            format!("{prefix}: the store core refused the put — nothing committed"),
            false,
        ),
    }
}

/// The offered-digest mismatch's wire error (the claim contradicted by
/// the canonical derivation — the derivation is the authority, ADR-012
/// §2's posture): the offered hex and the derived hex both named — the
/// putter's re-put is CAS-idempotent.
pub(crate) fn digest_mismatch_error(offered_hex: &str, derived_hex: &str) -> CallError {
    CallError::new(
        DIGEST_MISMATCH,
        format!(
            "the offered digest {offered_hex} is not the canonical derivation {derived_hex} \
             (the offered digest is a claim; the derivation is the authority) — nothing \
             handed over"
        ),
        false,
    )
    .with_details(json!({
        "digest": offered_hex,
        "derived": derived_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 digest rides the details (the putter's re-put anchor).
pub(crate) fn mint_refused_error(committed_hex: &str) -> CallError {
    CallError::new(
        MINT_REFUSED,
        format!(
            "the put of {committed_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!({ "digest": committed_hex }))
}

/// The message-details schema for the put's declared rejections
/// (`VERIFICATION_REFUSED`, `PUT_REFUSED`, `DIGEST_MISMATCH`,
/// `MINT_REFUSED` — the detail fields are the digest 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).
pub(crate) fn put_message_details_schema() -> Value {
    json!({
        "type": "object",
        "properties": {
            "message": { "type": "string" },
            "digest": { "type": "string" },
            "derived": { "type": "string" }
        },
        "required": ["message"],
        "additionalProperties": false
    })
}