"""The typed authority for every application-chosen on-disk location.
Where a byte lands used to be decided by four mutually unaware authorities: an
untyped ``dict[str, str]`` in the settings module, bare string constants in the
persistence namespace registry, module-local constants in application and
entrypoint modules, and unpinned inline copies of the registry's own names. A
name declared in the adapter layer could not be imported by ``core`` without
inverting the hexagonal direction, so the duplicates were unfixable where they
sat. This module is the single declaration all of them collapse onto: every
application-chosen name lives here, in ``core``, and the adapter layer consumes
it downward.
Six axes ride on each member because a bare name cannot carry them:
- the literal POSIX relative subpath, verbatim as it exists on disk;
- :class:`StorageNodeKind`, so file-versus-directory is a type-level fact rather
than a guess made from a field-name suffix -- a guess cannot reach the
per-bucket file names no suffix convention governs, and would put a directory
exactly where a document must be written;
- :class:`StorageScope`, because ``blobs`` and ``audit`` each legitimately name
both a top-level category and a per-bucket subdirectory at a different depth,
so members are identified by scope and name together;
- :class:`StorageOverridePolicy`, so the guarantee that an operator cannot
relocate a keystore out from under the bucket it unlocks is declared data
rather than an accident of which module a constant sat in;
- :class:`StorageLifecycle` and :class:`StorageGrouping`, the conceptual
classifications that answer what may be reclaimed and what belongs together;
- :class:`FingerprintParticipation`, an independent third axis that no union of
the other two reproduces, governing what the drift fingerprint covers.
The conceptual grouping has no effect on the resolved path. ``cache/`` is the
sole on-disk category prefix and remains so: the encrypted-state substrate, the
diagnostic logs, and the durable outputs keep bare, self-describing leaf names.
See Also:
:class:`~core.config.Settings`
Central settings aggregate whose path fields these members name.
:func:`~core.storage_taxonomy_locations.storage_path`
Resolver for a root-scoped member.
:func:`~core.storage_taxonomy_locations.bucket_scoped_storage_path`
Resolver for a bucket- or keystore-scoped member.
"""
from __future__ import annotations
from enum import StrEnum
from pathlib import Path
from typing import Final
from pydantic import BaseModel, Field, model_validator
from ..core.errors.hierarchy import pydantic_validation_boundary
from .models import STRICT_FROZEN_CONFIG
[docs]
class StorageNodeKind(StrEnum):
"""Whether a member names a directory or a single file.
Declared rather than inferred. A name-suffix heuristic cannot reach the
per-bucket file names -- ``manifest.toml``, ``.lock``, the keystore
sidecars -- that follow no suffix convention, and inferring wrongly creates
a directory where a document belongs, which fails much later at the write.
A second enum names the same axis one layer out: the storage adapter's
``StoragePathKind`` answers a wider question, adding a member for a row
living in the encrypted database rather than on disk and one for
content-addressed blob content. The two are deliberately **not** merged --
``core`` must not import an adapter type, and a ``StrEnum`` carrying
members cannot later be extended to subclass another -- so the
relationship is a declared parity rather than a shared definition:
``DIRECTORY`` and ``FILE`` must spell their values identically on both
sides. Both are ``StrEnum``, so code compares them by value across the
boundary and a divergent spelling would return ``False`` rather than
raising. A gate pins that overlap, and pins only the overlap, so the
adapter stays free to grow members ``core`` has no use for.
"""
DIRECTORY = "directory"
FILE = "file"
[docs]
class StorageScope(StrEnum):
"""The anchor a member's subpath is relative to.
Scope is a required part of a member's identity, not a decoration: the
top-level ``blobs`` and ``audit`` categories share their names with
per-bucket subdirectories at a different depth, and both are correct.
Keying on name alone would conflate them.
The keystore anchor is a second, independent bucket-id-parameterized root,
not a nested subdirectory of the bucket it unlocks. Production states this
as the load-bearing invariant it is
(:func:`~adapters.persistence.storage.bucket.keystore_paths.validate_keystore_separation`):
the keystore lives at ``<root>/keystore/<bucket-id>/``, sibling to
``buckets/``, and a configuration that resolves it under ``buckets/`` is
refused so a later unlock cannot silently violate the separation.
``KEYSTORE_ROOT`` names that per-bucket keystore directory itself --
parameterized by a bucket id like ``BUCKET_RELATIVE``, but anchored at
``<root>/keystore/`` rather than ``<root>/buckets/`` -- and
``KEYSTORE_RELATIVE`` names what nests beneath it.
"""
ROOT = "root"
BUCKET_RELATIVE = "bucket_relative"
KEYSTORE_ROOT = "keystore_root"
KEYSTORE_RELATIVE = "keystore_relative"
[docs]
class StorageOverridePolicy(StrEnum):
"""Whether an operator may relocate a member.
The bucket and keystore layout is deliberately fixed. An operator must not
be able to move a keystore out from under the bucket it unlocks, and that
guarantee is expressed here as data so it can be read and enforced, rather
than resting on which module a constant happened to sit in.
"""
OPERATOR_OVERRIDABLE = "operator_overridable"
FIXED = "fixed"
[docs]
class StorageLifecycle(StrEnum):
"""How a member's contents are bounded over time.
Governs what a reclaim operation may delete. ``UNBOUNDED_BY_DESIGN`` covers
the substrate a filing is defended with: growth is the point, and pruning it
would destroy evidence.
"""
UNBOUNDED_BY_DESIGN = "unbounded_by_design"
RETENTION = "retention"
ROTATION = "rotation"
TTL = "ttl"
[docs]
class StorageGrouping(StrEnum):
"""The conceptual family a member belongs to.
Purely a classification for operator-facing presentation and reclaim
grouping. It has no effect on the resolved path, so a member grouped as a
cache need not sit under the ``cache/`` prefix and several do not.
"""
STATE = "state"
LOGS = "logs"
CACHE = "cache"
EXPORTS = "exports"
[docs]
class StorageArea(StrEnum):
"""Stable operator vocabulary for the four aggregate storage families."""
STATE = "state"
LOGS = "logs"
CACHE = "cache"
EXPORTS = "exports"
[docs]
class StorageCustodyProfile(StrEnum):
"""Portable-profile custody depth selected by application policy."""
FULL = "full"
STRUCTURED = "structured"
[docs]
class FingerprintParticipation(StrEnum):
"""Whether writes beneath a member move the data-root drift digest.
An independent axis, never derived. Measured against every candidate, no
expression over lifecycle or grouping reproduces the shipped set, and three
candidates differ in both directions at once -- omitting members that are
excluded while adding members that are not. Getting it wrong is invisible
in both directions: excluding too much walks the digest toward the
empty-tree constant that once defeated drift detection outright, and
excluding too little churns it on every cache write until an operator
learns to distrust the refusal.
"""
PARTICIPATING = "participating"
EXCLUDED = "excluded"
STORAGE_ROOT_SETTINGS_FIELD: Final[str] = "cadrumo_local_storage_root"
"""The settings field naming the anchor every root-scoped member resolves against.
Neither a member nor an escape, and given its own name so it cannot be mistaken
for either. It is not a member because the taxonomy declares locations
*relative to* it -- a member for the root would be a member whose subpath is
itself. It is not an escape because the application both chooses it and writes
beneath it. It is therefore the anchor rather than a member.
"""
_PROFILE_CAPSULE_ENVELOPE_LABEL = "profile-capsule.password-envelope"
[docs]
class StorageCategory(StrEnum):
"""Every application-chosen on-disk location, identified by scope and name.
Root-scoped members carry their bare category name. Bucket- and
keystore-scoped members carry a scope-qualifying prefix, which is what keeps
root-scoped names distinct from per-bucket subdirectories.
"""
# ── State substrate and identity ────────────────────────────────────────
TOKENS = "tokens"
SECRETS = "secrets"
BLOBS = "blobs"
LIVE_STATE = "live-state"
# ── Fixed layout: live IVA remote-state capture ─────────────────────────
LIVE_STATE_IVA_WALLET = "live-state.iva-wallet"
LIVE_STATE_IVA_REMOTE_STATE = "live-state.iva-remote-state"
LIVE_STATE_IVA_REMOTE_STATE_FILED_HISTORY = "live-state.iva-remote-state.filed-history"
LIVE_STATE_IVA_REMOTE_STATE_WALLET = "live-state.iva-remote-state.wallet"
# ── Diagnostic and append-only telemetry logs ───────────────────────────
LOGS = "logs"
LOG_FILE = "logs.file"
LLM_USAGE = "llm-usage"
LLM_RUN_TELEMETRY = "llm-run-telemetry"
RUNS = "runs"
# ── Regenerable, evictable caches ───────────────────────────────────────
LLM_CACHE = "llm-cache"
CORPUS_SEARCH_CACHE = "corpus-search-cache"
CORPUS_SEARCH_INDEX = "corpus-search-cache.index"
# ── Durable generated outputs ───────────────────────────────────────────
SUBMISSIONS = "submissions"
SUBMISSIONS_AMENDMENT_RESULTS = "submissions.amendment-results"
SUBMISSIONS_AMENDMENTS = "submissions.amendments"
WORKFLOW_RUNS = "workflow-runs"
DRAFTS = "drafts"
JUSTIFICANTES = "justificantes"
FILING_HISTORY = "filing-history"
FILED_DECLARATIONS = "filed-declarations"
IVA_COMPENSATION_HISTORY = "iva-compensation-history"
IVA_READ_EVIDENCE = "iva-read-evidence"
FINANCIAL_TRANSACTIONS = "financial-transactions"
INVOICES = "invoices"
ATTACHMENTS = "attachments"
ATTACHMENTS_MANIFESTS = "attachments.manifests"
USAGE_RATIOS = "usage-ratios"
# ── Fixed layout: the bucket container and the active-profile pointer ───
BUCKETS = "buckets"
ACTIVE_PROFILE_POINTER = "active-profile-pointer"
ROOT_FALLBACK_DATABASE = "root-fallback-database"
CONFIG_RESET_JOURNAL = "config-reset-journal"
OPERATION_JOURNAL = "operation-journal"
PROFILE_CUSTODY_TRANSACTION_JOURNAL = "profile-custody-transaction-journal"
PROFILE_CUSTODY_RECEIPT = "profile-custody-receipt"
PROFILE_CUSTODY_HOLD_EVIDENCE = "profile-custody-hold-evidence"
# The three owner directories below the hold-evidence root. Each names one
# persisted format's home, so each is declared here rather than joined as a
# literal at the authority that writes into it: the path registry spells the
# same segments, and a name typed in both places drifts on the first rename.
PROFILE_CUSTODY_HOLD_LEGAL_OWNER = "profile-custody-hold-evidence.legal-owner"
PROFILE_CUSTODY_HOLD_FILING_OWNER = "profile-custody-hold-evidence.filing-owner"
PROFILE_CUSTODY_HOLD_DERIVED_EVIDENCE = "profile-custody-hold-evidence.derived"
PROFILE_CUSTODY_LABEL_HEAD = "profile-custody-label-head"
LOCAL_READER_FITNESS_VERDICTS = "local-reader-fitness-verdicts"
# ── Fixed layout: per-bucket ────────────────────────────────────────────
BUCKET_DATABASE = "bucket.db"
BUCKET_DATABASE_FILE = "bucket.db-file"
BUCKET_BLOBS = "bucket.blobs"
BUCKET_MANIFEST = "bucket.manifest"
BUCKET_LOCK = "bucket.lock"
BUCKET_OUTPUT_LANGUAGE_HINT = "bucket.output-language-hint"
BUCKET_KEYSTORE = "bucket.keystore"
# ── Fixed layout: current-format profile capsule ───────────────────────
PROFILE_CAPSULE_CUSTODY = "profile-capsule.custody"
PROFILE_CAPSULE_PASSWORD_ENVELOPE = _PROFILE_CAPSULE_ENVELOPE_LABEL
PROFILE_CAPSULE_RECOVERY_ENVELOPE = "profile-capsule.recovery-envelope"
PROFILE_CAPSULE_DATA = "profile-capsule.data"
PROFILE_CAPSULE_COMMIT = "profile-capsule.commit"
# ── Fixed layout: per-keystore ──────────────────────────────────────────
# The value still says "session" while the artefact it locates is the
# profile acceleration receipt. The token is an on-disk path segment, so
# correcting it moves real directories: it is carried by an authorised
# destructive local reset, never by a read-tolerant rename.
KEYSTORE_PROFILE_SESSION = "keystore.profile-session"
KEYSTORE_PROFILE_SESSION_RETIREMENT = "keystore.profile-session-retirement"
KEYSTORE_LOGIN_THROTTLE = "keystore.login-throttle"
[docs]
class StorageLocation(BaseModel):
"""One declared location and every axis a bare name cannot carry.
Frozen and strict: the taxonomy is a declaration, and a mutable one would
let a consumer rewrite the authority it is reading.
"""
model_config = STRICT_FROZEN_CONFIG
category: StorageCategory
subpath: str = Field(min_length=1)
"""POSIX-style path relative to this member's scope anchor, verbatim."""
node_kind: StorageNodeKind
scope: StorageScope
override_policy: StorageOverridePolicy
lifecycle: StorageLifecycle
grouping: StorageGrouping
fingerprint_participation: FingerprintParticipation
settings_field: str | None = None
"""Flat :class:`~core.config.Settings` attribute holding the resolved path.
Members name their settings field rather than absorbing it. Path fields stay
flat, introspectable attributes on the settings model because the structural
gates discover their subject by walking ``Settings.model_fields``; a model
that hid them behind a nested object or a property would leave those gates
discovering nothing, which passes silently and covers nothing.
"""
derives_settings_default: bool = False
"""Whether settings validation computes this field's default from the root.
False for a member whose field is a deliberate opt-in override. The registry
disk cache is the worked case: its name is governed here, but its field must
stay absent by default because its resolver selects a shared temporary
location under pytest precisely by observing that the field is unset.
Governing the name and deriving the field are separate decisions.
"""
consumer_module: str | None = None
"""Production module that consumes this location, relative to ``src/cadrumo``.
Names the module, and a structural gate verifies the claim: that module must
contain a real reference to this member -- an attribute load of the bound
settings field, an attribute load of the category member, or the field name
as a non-docstring string constant, which is how the live-evidence roots are
reached. A name appearing in prose satisfies nothing.
The claim is deliberately consumption rather than write-reachability.
Tracing statically from an attribute to a filesystem write produces a false
negative on every indirection, and a gate that blocks legitimate changes is
a gate somebody switches off. Consumption is weaker in theory and far
stronger in practice, because a location no module touches cannot be written
to -- which is the condition worth catching.
Exactly one of this and :attr:`dormant_reason` is set.
"""
dormant_reason: str | None = None
"""Why this member has no consuming module, when it has none.
A declared location nothing reads or writes is a decision waiting to be
taken -- wire it or delete it -- and the point of requiring the reason is
that the decision becomes visible in the declaration instead of waiting to
be rediscovered by an audit. Exactly one of this and
:attr:`consumer_module` is set.
"""
@model_validator(mode="after")
@pydantic_validation_boundary
def _fixed_override_policy_forbids_a_settings_field(self) -> StorageLocation:
"""Refuse a FIXED member that also exposes an operator-facing settings field.
The two claims contradict each other. ``FIXED`` asserts an operator
cannot relocate this member -- the guarantee that a keystore cannot be
moved out from under the bucket it unlocks. A ``settings_field`` is
precisely the field an operator overrides a location through. Today's
declarations happen to keep every ``FIXED`` member's field ``None``,
but "happens to" is not a guard: nothing before this validator stopped
a future ``FIXED`` member from also naming a ``settings_field``, which
would let a settings override silently defeat the immovability
``FIXED`` exists to promise. Refusing the combination at declaration
time makes it unconstructable instead of merely absent so far.
"""
if self.override_policy is StorageOverridePolicy.FIXED and self.settings_field is not None:
raise ValueError(
f"storage category {self.category.value!r} declares override_policy=FIXED and "
f"settings_field={self.settings_field!r}; a FIXED member must not expose an "
"operator-overridable settings field, or its immovability guarantee is not real",
)
return self
@model_validator(mode="after")
@pydantic_validation_boundary
def _require_exactly_one_liveness_claim(self) -> StorageLocation:
"""Refuse a member that claims both a consumer and dormancy, or neither."""
claims = (self.consumer_module, self.dormant_reason)
if sum(claim is not None for claim in claims) != 1:
raise ValueError(
f"storage category {self.category.value!r} must declare exactly one of "
"consumer_module (the production module that consumes this location) or "
"dormant_reason (why nothing does)",
)
for label, value in (("consumer_module", self.consumer_module), ("dormant_reason", self.dormant_reason)):
if value is not None and not value.strip():
raise ValueError(f"storage category {self.category.value!r} declares an empty {label}")
return self
[docs]
def relative_path(self) -> Path:
"""Return :attr:`subpath` as a path relative to this member's anchor."""
return Path(*self.subpath.split("/"))
__all__ = [
"STORAGE_ROOT_SETTINGS_FIELD",
"FingerprintParticipation",
"StorageArea",
"StorageCategory",
"StorageCustodyProfile",
"StorageGrouping",
"StorageLifecycle",
"StorageLocation",
"StorageNodeKind",
"StorageOverridePolicy",
"StorageScope",
]