# Data Model: Governance Artifact Lifecycle & Retention v1 ## Scope This slice adds one shared derived contract over existing governance artifacts. It does not introduce a generic artifact table, a new browsing console, or a new persistence layer. Any persisted lifecycle or retention markers that v1 truly requires must stay on the existing owning tables or aggregate roots for the concrete artifact families involved. ## Artifact Family Matrix | Artifact family | Owning record | Current immutable or historical anchors | Current lifecycle anchors | Current retention or access anchors | Current operator surfaces | First-slice adoption | |---|---|---|---|---|---|---| | Evidence snapshot | `EvidenceSnapshot` | `id`, `fingerprint`, `generated_at` | `status`, `isCurrent()`, latest vs older snapshot truth in the current artifact presenter | `expires_at`; explicit `expire` mutation via `EvidenceSnapshotService` | `EvidenceSnapshotResource`, linked review and pack detail | Visible contract adoption on existing resource and linked surfaces | | Review pack | `ReviewPack` | `id`, `fingerprint`, `previous_fingerprint`, `sha256`, `generated_at` | `status`, relation to `tenant_review_id`, `current_export_review_pack_id` through the review | `expires_at`; signed download allowed only when ready, unexpired, and entitled | `ReviewPackResource`, `ReviewPackDownloadController`, `CustomerReviewWorkspace`, tenant review detail | Visible contract adoption on existing resource, download, and workspace-linked surfaces | | Stored report | `StoredReport` | `id`, `report_type`, `fingerprint`, `previous_fingerprint`, `created_at` | Latest-per-tenant-and-type vs older fingerprint rows is the current or historical distinction | `created_at` plus `tenantpilot.stored_reports.retention_days`; pruning via `PruneStoredReportsCommand` | No dedicated operator resource; evidence sources, widgets, support diagnostics, telemetry | Headless contract adoption only | | Accepted-risk decision record | `FindingExceptionDecision` inside the `FindingException` aggregate | `id`, `decision_type`, `decided_at`, append-only history, parent `current_decision_id` | Parent exception `status`, `current_validity_state`, and current-vs-historical decision relationship | `expires_at`, `review_due_at`, and current validity on the parent exception; no hold or delete contract today | `DecisionRegister`, `ViewFindingException`, finding-governance projections | Headless contract adoption on aggregate models and current findings-service seams only; current register and detail surfaces stay unchanged in this slice | `TenantReview` remains a consumer and context surface for linked artifact truth in this slice. Its publication, archive, and successor states must not become retention proxies for the shared contract. ## Shared Derived Contract ### GovernanceArtifactReference Derived object emitted by the shared lifecycle or truth path. | Field | Description | Notes | |---|---|---| | `family` | One of `evidence_snapshot`, `review_pack`, `stored_report`, `finding_exception_decision` | No synthetic family for a generic registry | | `workspace_id` | Owning workspace scope | Required for all current families | | `tenant_id` | Owning tenant scope | Required for all current in-scope artifact families | | `record_type` | Current model or aggregate owner | Derived from the owning record, not a new registry enum | | `record_id` | Existing primary key | Stable inside the current family | | `display_reference` | Human-readable immutable reference shown on current surfaces | Built from family plus existing identifiers already owned by the record | | `integrity_anchor` | Existing fingerprint, SHA, or append-only decision anchor when available | Nullable; never invent a synthetic hash when the family has no current one | | `source_context` | Existing related artifact or aggregate context | Examples: evidence snapshot, current export pack, parent finding exception | Validation rules: - `workspace_id` and `tenant_id` remain required scope anchors for every current in-scope family. - The shared contract may normalize display labels, but it must not replace the owning record's real ID or fingerprint. - A direct-access expiry or hold state must not erase the artifact reference from historical views. ### GovernanceArtifactLifecycleState Derived state that explains whether the artifact is the current artifact in active circulation or a retained historical record. | Value | Meaning | Repo-grounded examples | |---|---|---| | `current` | Current artifact intended for active operator or customer use | Active evidence snapshot, current export review pack, current decision on the exception aggregate | | `historical` | Retained historical artifact that remains readable without implying a newer replacement path | Older stored reports by fingerprint, prior evidence snapshots still referenced by later reviews | | `superseded` | Historical artifact explicitly replaced by a newer current artifact | Prior export review pack after a newer export becomes current, prior decision row after a newer decision becomes current | Rules: - `expired_direct_access` is not a lifecycle state in this slice. It belongs to retention. - `suspended_read_only` is not a lifecycle state in this slice. It remains commercial lifecycle from Spec 251. - A family may map directly from existing repo truth to `historical` without needing a second new state value. ### GovernanceArtifactRetentionState Bounded retention state family used by the shared contract. | Value | Meaning | Repo-grounded anchor or note | |---|---|---| | `retained` | Artifact remains retained and readable under normal entitlement rules | Default state for current evidence, packs, stored reports before prune, and historical decisions | | `hold` | Artifact is retained under an explicit hold and cannot progress into deletion | New v1 mutation state only if current-table persistence can stay bounded | | `deletion_requested` | Explicit reversible request to remove the artifact from normal circulation later | New v1 mutation state only if current-table persistence can stay bounded | | `expired_direct_access` | Direct access has expired even though historical reference or audit truth may still remain | Current repo-real pattern for review packs and evidence snapshots with `expires_at` | Rules: - `hold` wins over `deletion_requested` for visible truth and progression rules. - `expired_direct_access` does not imply purge, provider deletion, or removal of the immutable reference. - `suspended_read_only` must never be used as a retention-state proxy. - Stored reports may stay `retained` or become effectively unavailable through prune, but prune itself remains a separate retained-history follow-up boundary rather than a generic purge workflow in this spec. ### GovernanceArtifactActionDecision Derived action contract emitted alongside lifecycle and retention truth. | Field | Description | |---|---| | `may_view` | Whether current entitlement and retention state still allow detail access | | `may_download` | Whether current entitlement, retention state, and artifact state still allow direct download of an already-generated artifact | | `may_generate_successor` | Whether a new successor artifact may be generated now | | `may_mutate_lifecycle` | Whether hold, release-hold, or deletion-request actions may execute now | | `blocked_reason` | Customer- or operator-readable reason for the blocked action | | `audit_action` | Stable audit event family for the action or mutation | Rules: - `may_generate_successor` remains separate from `may_download`. The repo already proves that a workspace can block new review-pack starts while still allowing ready-pack downloads. - The shared contract should reuse existing policy and capability checks before adding any family-local lifecycle gating. - If a family does not pass the bounded current-owner persistence gate in v1, `may_mutate_lifecycle` remains false or blocked while the contract still exposes truthful retention semantics. ## Likely Persistence Touch Points | Current table or aggregate | Current fields already in repo | Likely v1 additions only if required | Guardrail | |---|---|---|---| | `evidence_snapshots` | `status`, `expires_at`, `fingerprint`, `generated_at` | Family-local hold or deletion-request markers and actor or reason metadata | Do not add a generic artifact foreign key | | `review_packs` | `status`, `expires_at`, `fingerprint`, `previous_fingerprint`, `sha256`, `generated_at` | Family-local hold or deletion-request markers and actor or reason metadata | Keep download truth on the current record and controller path | | `tenant_reviews` | `status`, `published_at`, `archived_at`, `superseded_by_review_id`, `current_export_review_pack_id` | No new retention state; use only to point review surfaces at linked artifact truth | Review lifecycle remains review-owned context, not a shared artifact engine | | `stored_reports` | `report_type`, `fingerprint`, `previous_fingerprint`, `created_at`, `payload` | Prefer derived-only in v1; keep pruning command-owned | No new operator UI surface in this slice | | `finding_exceptions` plus `finding_exception_decisions` | `current_decision_id`, parent `status`, `current_validity_state`, `review_due_at`, `expires_at`, append-only decision rows | If hold or deletion-request semantics are needed, prefer aggregate-level state on `finding_exceptions` rather than mutation of append-only decision history | Do not break append-only history | ## Query and Precedence Rules - `hold` wins over `deletion_requested` for user-visible truth and action blocking. - `expired_direct_access` blocks direct download or open actions only where the current family already uses `expires_at` or equivalent access expiry. - `suspended_read_only` blocks new generation or lifecycle mutation actions, but it does not change lifecycle or retention state by itself. - `ReviewPackDownloadController` remains the canonical enforcement point for ready-pack direct download: entitlement, ready status, unexpired access, and file existence must all remain true. - `StoredReport` current-versus-historical selection should prefer latest report per tenant and report type, with older fingerprint rows remaining historical until pruned. - `FindingExceptionDecision` rows remain historically addressable even when the parent exception points to a newer current decision. ## State Transitions In Scope ### Existing repo-real transitions to preserve - Evidence snapshot: current active snapshot to expired direct access through `EvidenceSnapshotService::expire(...)` - Review pack: queued to generating to ready or failed, and ready to expired direct access - Finding exception decision history: requested to approved or renewed, rejected, or revoked through append-only decision creation on the parent aggregate - Stored report: retained-by-age to pruned-by-command through `PruneStoredReportsCommand` ### New bounded transitions this slice may add - `retained` to `hold` - `hold` to `retained` - `retained` to `deletion_requested` - `deletion_requested` to `retained` Guardrails: - No transition in this slice may claim purge or irreversible deletion. - No transition may depend on provider presence or workspace commercial state as its primary meaning. - If a requested transition needs a cross-family executor, scheduler, or export-before-delete workflow, it belongs to a follow-up spec instead of this slice. - If no current family can support these transitions without widening scope, v1 stops at read-only lifecycle truth plus existing download audit and does not add new mutation persistence.