"""Pydantic command + result records for :class:`BucketMaintenanceService`.
Used by: :mod:`~._service` to implement bucket operations.
The contract records sit at the package boundary so a programmatic
caller (the CLI handler, a future MCP surface) gets the same typed
input + output shape that the service consumes. Closed-value axes are
typed as their core enums per the architecture-boundaries discipline.
Every bucket selector is a :class:`BucketId`.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from pydantic import BaseModel, Field
from ...core import STRICT_FROZEN_CONFIG
from ...core.identity import BucketId
[docs]
class RenameBucketCommand(BaseModel):
"""Operator request to relabel a bucket.
``bucket_id`` is the stable :class:`BucketId`; only the
operator-visible label moves. The service forwards the relabel to
the profile-rename single-writer primitive, which holds the
cross-store atomicity (encrypted record ``display_name`` and
plaintext manifest ``label`` move together).
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
new_label: str = Field(min_length=1, max_length=160)
[docs]
class RenameBucketResult(BaseModel):
"""Outcome of a successful rename.
Carries the prior label so the operator-facing emitter can render
the before / after pair without re-reading the manifest. The
``occurred_at`` instant is the same instant carried by the
``BUCKET_RENAMED`` bucket event.
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
previous_label: str = Field(min_length=1, max_length=160)
new_label: str = Field(min_length=1, max_length=160)
occurred_at: datetime
[docs]
class DeleteBucketCommand(BaseModel):
"""Operator request to destructively erase a bucket.
``confirmed=True`` is required at the service boundary so a
programmatic caller observes the same guarantee the CLI ``--yes``
flag provides. The active bucket cannot be deleted; the operator
must switch profiles first.
``acknowledge_retention_override`` is the explicit legal-retention
override: when a filed tax record is still inside its four-year LGT
retention window (Ley 58/2003 art. 66/70) the erase is refused
unless this flag is ``True`` AND ``retention_override_reason`` is a
non-empty justification the audit trail records.
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
confirmed: bool = False
acknowledge_retention_override: bool = False
retention_override_reason: str | None = Field(default=None, min_length=1, max_length=512)
[docs]
class DeleteBucketResult(BaseModel):
"""Outcome of a successful bucket erasure.
Carries the deleted bucket's prior label so the operator-facing
emitter can render a confirming line without re-reading anything.
``retention_override_used`` records whether a still-retained record
was erased under the explicit legal-retention override, and
``latest_safe_erase_date`` names the instant the erased set would
otherwise have become safe to erase (``None`` when nothing was
inside its window).
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
previous_label: str = Field(min_length=1, max_length=160)
occurred_at: datetime
retention_override_used: bool = False
latest_safe_erase_date: datetime | None = None
[docs]
class ArchiveBucketCommand(BaseModel):
"""Operator request to move a bucket into reversible dormancy.
Unlike :class:`DeleteBucketCommand`, ``archive`` is a soft-only
tombstone: it never removes the bucket directory, so
:class:`RestoreBucketCommand` can bring the same bucket back.
``confirmed=True`` mirrors ``delete``'s boundary contract so a
programmatic caller observes the same guarantee the CLI ``--yes``
flag provides. The active bucket cannot be archived; the operator
must switch profiles first.
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
confirmed: bool = False
[docs]
class ArchiveBucketResult(BaseModel):
"""Outcome of a successful bucket archive.
Carries the archived bucket's label so the operator-facing emitter
can render a confirming line without re-reading the manifest.
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
label: str = Field(min_length=1, max_length=160)
occurred_at: datetime
[docs]
class RestoreBucketCommand(BaseModel):
"""Operator request to bring an archived bucket back to active status.
Symmetric inverse of :class:`ArchiveBucketCommand`. Refuses when the
target bucket is not currently archived (tombstoned), so a restore
never silently no-ops against an already-live bucket.
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
[docs]
class RestoreBucketResult(BaseModel):
"""Outcome of a successful bucket restore."""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
label: str = Field(min_length=1, max_length=160)
occurred_at: datetime
[docs]
class BrowseBucketCommand(BaseModel):
"""Operator request to enumerate a bucket's namespace inventory.
The current shape is namespace-level only: it returns each
namespace and its row count without decrypting payloads. Key-level
browse requires decryption and a ``SensitivityClass`` redaction
policy, both deferred to a follow-up Step under the
composition-pattern ADR.
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
namespace_filter: str | None = Field(default=None, min_length=1, max_length=128)
[docs]
class BucketNamespaceInventoryRow(BaseModel):
"""One row of the namespace-inventory browse result."""
model_config = STRICT_FROZEN_CONFIG
namespace: str = Field(min_length=1)
row_count: int = Field(ge=0)
[docs]
class BrowseBucketResult(BaseModel):
"""Namespace-level browse outcome.
Returns one row per namespace present in the bucket (optionally
substring-filtered by ``namespace_filter``), each carrying the
stored-row count. Read-only; emits no bucket event.
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
rows: tuple[BucketNamespaceInventoryRow, ...]
[docs]
class DiskUsageBucketCommand(BaseModel):
"""Operator request to measure a bucket's on-disk footprint.
Reads only filesystem metadata (``os.stat`` sizes) under
``<aeat_local_storage_root>/buckets/<bucket_id>/``; it never opens the
encrypted SQLite database or decrypts a secure-object payload, so no
master key or active-bucket session is required. This makes the
measurement safe to run against a non-active, even archived, bucket —
the same non-active-safe posture :func:`~._sandbox.preview_discard_sandbox`
already relies on for its namespace preview.
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
[docs]
class BucketDiskUsageSubdirRow(BaseModel):
"""One on-disk subdirectory's byte total in a disk-usage report.
``subdir`` names one of the bucket layout's fixed subdirectories
(``db``, ``blobs``, ``audit``); the row is emitted even when the
subdirectory is empty (``total_bytes=0``, ``file_count=0``) so a
caller can rely on exactly the layout's three rows always being
present.
"""
model_config = STRICT_FROZEN_CONFIG
subdir: str = Field(min_length=1)
total_bytes: int = Field(ge=0)
file_count: int = Field(ge=0)
[docs]
class DiskUsageBucketResult(BaseModel):
"""Bucket on-disk footprint outcome.
``total_bytes`` is the sum of every regular file under the bucket's
directory tree (``db`` + ``blobs`` + ``audit``, plus the bucket's own
manifest file); ``subdirs`` breaks that total down per fixed
subdirectory. Read-only; emits no bucket event.
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
total_bytes: int = Field(ge=0)
subdirs: tuple[BucketDiskUsageSubdirRow, ...]
[docs]
class ExportBucketCommand(BaseModel):
"""Operator request to export a bucket as a sealed archive.
The ``output_path`` is operator-specified; the service refuses to
overwrite an existing target. The ``recovery_wrap_passphrase``
field is optional: when present the service derives a
recovery-passphrase KEK and emits a 3-member archive (including
``recovery.wrap``); when absent the service uses the bucket's
active KEK and emits a 2-member archive without recovery-wrap.
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
output_path: Path
recovery_wrap_passphrase: str | None = Field(default=None, min_length=8, max_length=512)
[docs]
class ExportBucketResult(BaseModel):
"""Outcome of a successful bucket export.
Carries the written archive path plus the manifest digest recorded
in the sealed archive header. The digest is bound into the payload's
AEAD associated data, so import refuses a tampered header at
decryption; operator emitters render the path so the operator can
locate the file for backup or transfer.
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
output_path: Path
manifest_digest: str = Field(min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$")
recovery_wrap_present: bool
occurred_at: datetime
[docs]
class ImportBucketCommand(BaseModel):
"""Operator request to import a sealed bucket archive.
The ``source_path`` is operator-specified. The service refuses
when the archive's ``bucket_id`` collides with an existing live
profile unless ``force_replace`` is ``True``; when the source
archive carries a recovery-wrap member, the operator MUST supply
the matching ``recovery_wrap_passphrase``.
"""
model_config = STRICT_FROZEN_CONFIG
source_path: Path
force_replace: bool = False
recovery_wrap_passphrase: str | None = Field(default=None, min_length=8, max_length=512)
[docs]
class ImportBucketResult(BaseModel):
"""Outcome of a successful bucket import.
Carries the imported :class:`BucketId` and the manifest digest the
archive header declared. The digest is evidence of the sealed
archive header that authenticated the payload; it is not recomputed
against the freshly provisioned host manifest because import-host
lifecycle timestamps legitimately differ.
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
manifest_digest: str = Field(min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$")
archive_schema_version: int = Field(ge=1)
occurred_at: datetime
[docs]
class InspectBucketArchiveCommand(BaseModel):
"""Operator request to inspect a sealed bucket archive without restoring it.
Read-only: the source archive is neither decrypted nor written to. This
lets an operator confirm which bucket a backup file holds, when it was
written, and whether it carries a recovery-wrap member, without needing
the sealing key.
"""
model_config = STRICT_FROZEN_CONFIG
source_path: Path
[docs]
class InspectBucketArchiveResult(BaseModel):
"""Outcome of a successful sealed-archive inspection.
Every field is read from the archive's plaintext header plus the
on-disk file size; the AEAD-encrypted payload itself is never opened,
so this result cannot report per-store row counts. ``manifest_digest``
is the header's integrity anchor (unverified here — verification only
happens during a real ``import``, where it is authenticated as AEAD
associated data at decryption).
"""
model_config = STRICT_FROZEN_CONFIG
bucket_id: BucketId
manifest_digest: str = Field(min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$")
recovery_wrap_present: bool
archive_schema_version: int = Field(ge=1)
created_at: datetime
size_bytes: int = Field(ge=0)