Source code for cadrumo.application.ledger.evidence_input
"""In-memory evidence-input representation for on-host LLM reading.
A transient, in-process container for the decrypted bytes of a transaction's
attached evidence (a purchase invoice or a linked attachment), read from secure
storage so an on-host reader -- the in-tree text-layer or a local vision model --
can consume it.
CRITICAL (``sensitive-financial-data-secure-storage-only``): this object holds
decrypted ``FINANCIAL`` bytes in process memory ONLY. It MUST NEVER be persisted,
serialized to disk, written to a temp file, embedded in a persisted document, or
logged. It carries no JSON serializer; ``EvidenceInput.model_dump`` and
``EvidenceInput.model_dump_json`` are overridden to raise so a stray
persistence call fails loudly rather than leaking bytes out of secure storage. The
raw ``data`` field is excluded from ``repr`` for the same reason.
"""
from __future__ import annotations
from typing import Never, Self, SupportsIndex, override
from pydantic import BaseModel, Field, model_serializer, model_validator
from ...core.document_shape import DocumentShape
from ...core.errors.hierarchy import pydantic_validation_boundary
from ...core.hashing import sha256_hex
from ...core.identity.digest import ContentDigest
from ...core.models import STRICT_FROZEN_CONFIG
from ...domain.attachments.protocols import AttachmentStoreProtocol
from .evidence import PurchaseInvoiceEvidence
from .evidence_errors import PurchaseInvoiceEvidenceInputError
from .evidence_input_ports import EvidenceInputPorts
from .preconditions import LedgerPreconditionCondition, ledger_no_recovery_verdict
__all__ = [
"EvidenceInput",
"resolve_attachment_evidence_input",
"resolve_purchase_invoice_evidence_input",
]
_REFUSAL_MESSAGE = (
"EvidenceInput must never be serialized, iterated, or persisted; it holds decrypted "
"FINANCIAL bytes in memory only (sensitive-financial-data-secure-storage-only)."
)
[docs]
class EvidenceInput(BaseModel):
"""Transient in-memory evidence bytes plus the provenance needed to cite them.
Built by the resolver from evidence already held in secure storage. The bytes
live only for the duration of a single on-host read; this model is never
persisted (see the module docstring and the overridden dump methods).
Attributes:
mime_type: Concrete MIME type as recorded on the storage manifest (e.g.
``application/pdf``). Carried as provenance and for the vision
reader, which needs a concrete image MIME. It is the producer's
LABEL and decides nothing about how the bytes are read; that is
:attr:`document_shape`, which is probed from the bytes themselves.
document_shape: Content-derived document shape, computed by the required
evidence-input capability before this DTO is created.
data: The decrypted evidence bytes, in memory only. Excluded from ``repr``.
content_sha256: 64-character lowercase hex SHA-256 of :attr:`data`; the
content address the bytes were read under. Enforced to match ``data``.
evidence_id: Originating purchase-invoice ``evidence_id`` when the bytes
came from a :class:`PurchaseInvoiceEvidence` record, else ``None``.
attachment_id: Originating ``attachment_id`` when the bytes came from an
:class:`~cadrumo.domain.attachments.models.Attachment`, else ``None``.
"""
model_config = STRICT_FROZEN_CONFIG
mime_type: str = Field(min_length=1)
document_shape: DocumentShape
data: bytes = Field(repr=False)
content_sha256: ContentDigest
evidence_id: str | None = None
attachment_id: str | None = None
@model_validator(mode="after")
@pydantic_validation_boundary
def _verify_content_address(self) -> Self:
"""Reject bytes whose digest does not match the declared content address."""
if not self.data:
raise ValueError("EvidenceInput.data must not be empty")
digest = sha256_hex(self.data)
if digest != self.content_sha256:
raise ValueError("EvidenceInput.content_sha256 must equal sha256(data)")
if self.evidence_id is None and self.attachment_id is None:
raise ValueError("EvidenceInput must carry an evidence_id or an attachment_id for provenance")
return self
@model_serializer
def _refuse_model_serialization(self) -> dict[str, object]:
"""Refuse pydantic serialization on every route, including nested dumps.
Registered as the model serializer so a parent model that embeds an
``EvidenceInput`` and calls ``model_dump`` / ``model_dump_json`` also raises
here, rather than serializing the raw ``data`` bytes through the field schema.
"""
raise NotImplementedError(_REFUSAL_MESSAGE)
@override
def model_dump(self, *args: object, **kwargs: object) -> Never: # reason: deliberate persistence tripwire
"""Refuse serialization -- decrypted FINANCIAL bytes must never be persisted."""
raise NotImplementedError(_REFUSAL_MESSAGE)
@override
def model_dump_json(self, *args: object, **kwargs: object) -> Never: # reason: deliberate persistence tripwire
"""Refuse JSON serialization -- decrypted FINANCIAL bytes must never be persisted."""
raise NotImplementedError(_REFUSAL_MESSAGE)
@override
def __iter__(self) -> Never: # reason: deliberate persistence tripwire
"""Refuse iteration / ``dict()`` -- it would expose the raw ``data`` bytes."""
raise NotImplementedError(_REFUSAL_MESSAGE)
@override
def __reduce_ex__(self, protocol: SupportsIndex) -> Never:
"""Refuse pickling -- it would embed the raw ``data`` bytes."""
raise NotImplementedError(_REFUSAL_MESSAGE)
def _reject_unreadable_bytes(data: bytes, *, mime_type: str, ports: EvidenceInputPorts) -> DocumentShape:
"""Refuse evidence whose BYTES match no readable document shape.
The read path's admission question used to be asked of the stored MIME type
through a two-member media kind, and a label cannot see inside a document.
It got both directions wrong: a PNG announced as ``application/pdf`` was
admitted and called a PDF, while a genuine PDF announced as
``application/octet-stream`` was refused outright. Neither answer was about
the document.
:attr:`DocumentShape.UNKNOWN` is the probe's own "never guessed at, always
refused" verdict, so refusing on it moves the authority to the bytes while
leaving every case where the label told the truth decided exactly as before.
``mime_type`` appears in the message only as the operator-visible breadcrumb
identifying the record they are looking at; it decides nothing.
"""
document_shape = ports.document_shape_probe(data)
if document_shape is DocumentShape.UNKNOWN:
raise PurchaseInvoiceEvidenceInputError(
f"evidence bytes (stored as {mime_type!r}) match no readable document shape; "
"only PDF, structured XML and image evidence can be read",
precondition_verdict=ledger_no_recovery_verdict(
LedgerPreconditionCondition.EVIDENCE_BYTES_READABLE,
facts={"document_shape_recognized": False},
),
)
return document_shape
[docs]
def resolve_attachment_evidence_input(
attachment_id: str,
*,
store: AttachmentStoreProtocol,
ports: EvidenceInputPorts,
evidence_id: str | None = None,
) -> EvidenceInput:
"""Read stored evidence bytes from secure storage into an ``EvidenceInput``.
The single read path for every evidence byte payload: bytes live in exactly
one place (the content-addressed
:class:`~cadrumo.domain.attachments.protocols.AttachmentStoreProtocol` blob for the
active bucket), so both evidence tiers reach them through this one function.
Loads the attachment manifest and its encrypted blob into memory. No file is
written.
Args:
attachment_id: Content-addressed id of the stored bytes.
store: Attachment store bound to the operation's secure-storage bucket.
ports: Required application capability for deriving the document shape
from the resolved bytes.
evidence_id: Originating :class:`PurchaseInvoiceEvidence` record id when
the caller reached these bytes through an evidence record, else
``None`` for a bare linked attachment. Recorded on the result purely
as provenance; it never selects the bytes.
Returns:
:class:`EvidenceInput`: In-memory bytes plus provenance, for an on-host read.
Raises:
PurchaseInvoiceEvidenceInputError: If the bytes match no readable
document shape. Judged on the bytes, so the stored MIME type can be
wrong in either direction without changing the verdict.
"""
manifest = store.load_manifest(attachment_id)
data = store.read_bytes(manifest.sha256)
document_shape = _reject_unreadable_bytes(data, mime_type=manifest.mime_type, ports=ports)
return EvidenceInput(
mime_type=manifest.mime_type,
document_shape=document_shape,
data=data,
content_sha256=manifest.sha256,
evidence_id=evidence_id,
attachment_id=manifest.attachment_id,
)
[docs]
def resolve_purchase_invoice_evidence_input(
evidence: PurchaseInvoiceEvidence,
*,
store: AttachmentStoreProtocol,
ports: EvidenceInputPorts,
) -> EvidenceInput:
"""Read a purchase-invoice evidence record's bytes from secure storage.
The provenance-binding adapter over :func:`resolve_attachment_evidence_input`:
a :class:`PurchaseInvoiceEvidence` record holds no bytes of its own, only the
``attachment_id`` naming their in-store home written at ``add`` time, so the
read delegates to the one attachment read path. Reading through this function
rather than calling the attachment resolver directly is what guarantees the
originating ``evidence_id`` is stamped on the result, so a record read can
never lose its provenance.
No byte-custody guard is needed or possible: ``attachment_id`` is a required
field, so a record that reached secure storage has an in-store byte home by
construction, and a byte-less record is refused at model validation rather
than here. The cleartext ``source_path`` is never a fallback byte source
(sensitive-financial-data-secure-storage-only).
Args:
evidence: The purchase-invoice evidence record.
store: Attachment store bound to the operation's secure-storage bucket.
ports: Required application capability for deriving the document shape
from the resolved bytes.
Returns:
:class:`EvidenceInput`: In-memory bytes plus provenance, for an on-host read.
"""
return resolve_attachment_evidence_input(
evidence.attachment_id,
store=store,
ports=ports,
evidence_id=evidence.evidence_id,
)