74 — Lifecycle management and data administration
Status: proposal, 2026-09-25. Nothing here is shipped. This is the
companion to docs/design/73-data-architecture-and-lifecycle.md (the data
model) and to docs/plans/data-architecture-plan.md (milestones), and it
answers review findings R2, R8, R26, R28, R33 and R34 in
docs/plans/data-architecture-review.md. It defines how every kind of Vak
data is born, changes state, and ends, which policies govern that, how
people see and act on it, and which screens, APIs and commands that takes.
1. Rules
- People ask; the reconciler acts. No screen, API or command deletes bytes directly. They create or approve a request (trash, erase, release, expire). One level-triggered reconciler performs every destructive transition, so policy, holds, leases and recording are applied in exactly one place.
- Every transition is a record in
audit/lifecycle: subject, from, to, cause (policy, request, hold, quota), actor, time and TraceKey. "Why is this gone?" always has an answer that contains no content. - Preview before commit. Every destructive action shows its impact first, computed from catalog lineage: counts per class, derived copies, bytes, and processors outside our reach. The commit carries the preview's digest; if the impact changed in between, the commit is refused and re-previewed.
- Holds beat everything: user delete, retention expiry, quota pressure, Agent or space erasure, tenant offboarding.
- Records expire per conversation, never per entry. Partial deletion
would break
derive_messages()and the hash chain. Non-content shared ledgers (cost, routing, activity) expire by sealed segment. - Recoverable before irreversible. Trash is hidden everywhere and restorable. Erasure destroys keys and cannot be undone. The gap between them is policy (default 30 days).
- Receipts are honest. An erasure receipt lists what was destroyed and what Vak cannot reach: messages already sent to Telegram, Slack or Discord, prompts already processed by an LLM provider, and an external NATS deployment.
- Screens show measured evidence only (invariant 26) and filter by
audience before anything else (invariant 37). Operations URLs keep
workspaceandtimecontext, and every drill-down ends at raw evidence.
2. Lifecycles
Notation: a state list, the transitions (trigger → actor), the data effect, and whether the step is reversible. Every transition writes a lifecycle record (rule 2), so that is not repeated per row.
2.1 Tenant
provisioning → active → suspended → offboarding → shredded
| Transition | Trigger / actor | Data effect | Reversible |
|---|---|---|---|
| active → suspended | billing or admin | admission refused; data intact | yes |
| → offboarding | owner request | export window opens (default 30 d), schedules stop | yes, until shredded |
| offboarding → shredded | window end, no hold | tenant KEK destroyed; every record, object, Document and derived store removed; one retained tombstone | no |
2.2 Space
bound → active → detached → archived → erased
- Detached: every machine binding is removed. Data is kept, and the
space can be re-bound. This replaces today's
workspaces/forget, which forgets a path and leaves data scattered. - Archived: read-only. Its schedules are disabled and its retention clocks start.
- Erased is an erasure request with scope space (§3.4). Files in the person's own working tree are never touched: Vak erases what it stored, never the directory a person owns.
2.3 Agent
active → paused → archived → revoked → erased
| State | Admission | Schedules | Endpoints | Data |
|---|---|---|---|---|
| active | yes | run | live | normal |
| paused | refused (fails closed, invariant 37) | skipped (a Run record says so) | live, replies "paused" | intact |
| archived | refused | disabled | detached | read-only; retention clocks start |
| revoked | refused | disabled | credentials cut immediately, bot tokens removed | intact under label; for "this Agent was compromised" |
| erased | — | deleted | deleted | erasure scope agent: conversations, Documents, runs, artifacts it owns |
AgentLifecycle (crates/vak-server/src/agents.rs:15) gains Revoked.
erased is not a state on the definition; the definition itself becomes a
tombstone.
2.4 Conversation and session
open → idle → sealed → (archived) → trashed → erased, and separately
expired by policy.
| Transition | Trigger / actor | Data effect | Reversible |
|---|---|---|---|
| open → idle | no turn for N minutes | none (leases released) | yes |
| idle → sealed | reconciler | open segment sealed; checkpoints pruned to baseline plus final | yes (appending re-opens a new segment) |
| → archived | person | hidden from the default list; searchable | yes |
| → trashed | person (own conversation) or admin | hidden everywhere: lists, every search, recall, prompts, exports, digests. Schedules that deliver into it pause. | yes, within the trash window |
| trashed → erased | window end, or "Delete permanently" | erasure (§4) | no |
| → expired | last_activity + delete_after with no hold | erasure (§4) with cause policy | no |
This replaces today's archive.json/deleted.json sidecars
(crates/vak-server/src/lib.rs:9056-9210), whose "delete" only hides from
two lists (review R8).
2.5 Run and schedule
- Run:
claimed → running → settled{completed | failed | cancelled | abandoned | skipped}. The claim is a CAS on(schedule, slot). A lease that expires while running becomesabandoned. The Run record stays with its conversation's retention; a Run with no conversation (a skip, a script watchdog) follows the space'srunslabel. - Schedule:
draft → enabled ⇄ paused → disabled → deleted.- Deleting a schedule tombstones the definition; its Runs are kept.
- Pausing records every slot that passes as
skipped{paused}, so the history has no silent gap. on_crash = skip | retry_oncedecides what anabandonedslot does.
2.6 Execution and environment
- Execution:
prepared → running → settled → scrubbed. Scrubbing removesruntime/executions/<exe>once the execution settles (plus a grace period). Its stdout, stderr and produced files are objects that follow the owning conversation. - Environment: reuses
vak_sandbox::EnvironmentState:planned → preparing → ready → running → stopped → expired → removed.- Expiry comes after the candidate is promoted or rejected, or after the idle TTL, whichever comes first.
- A worktree environment also removes its branch.
- An environment with an unreviewed candidate is never removed without
the candidate first moving to
rejected{expired}, which is recorded and notified.
2.7 Artifact and version
draft → candidate → verified → promoted | shared | published | rejected
per version; the artifact itself is active → superseded → expired → erased.
- A version is immutable. Promotion writes into a space's working tree through the existing Review path (invariant 39) and records the target digest.
- Sharing is a grant (§6, screen A6) and never copies data.
- Unpublishing revokes grants and links; external copies are named in the receipt.
2.8 Documents (memory, entities, skills, prompt layers, presentation packs)
active → amended (new version) → forgotten (tombstone).
- Every save keeps the previous content as a version, under the Document's history retention (default 90 days for memory and entities).
- Forget tombstones the current value and schedules history erasure once the history window ends.
- A Document whose
derived_fromnames an erased conversation is erased with it (§4). A person may choose to keep a memory note by re-affirming it, which writes a new version with provenanceperson.
2.9 Checkpoints
captured → retained → pruned.
- Policy keeps the session baseline, the last N (default 20) while the
session is open, and baseline plus final after sealing, for
delete_after(default 30 days). - Checkpoints are space-owned: erasing a conversation removes its manifests' turn linkage and labels, never the space files a person kept.
2.10 Delivery jobs and inbox
- Delivery job:
queued → attempting → delivered | dead-lettered → sealed. When a job settles its record moves into the deliveries ledger and the job file is removed. Today delivered jobs are never removed. - Inbox entry:
unread → acked → expired(default 90 days). Entry bodies are content keyed to their conversation (§4).
2.11 Endpoints and allowlist
pending → allowed | denied → revoked. Pending and denied entries hold
personal data (sender ids, chat ids) and expire by policy (default 90 days).
A denied entry that expires is replaced by a hashed sticky-deny fingerprint,
so "a denied entry never re-prompts" (invariant 15) survives without keeping
the identifier.
2.12 Incidents, actions and audit
Incidents follow open → resolved → reopened (doc 28) and expire by policy.
Lifecycle, erasure, hold, grant and promotion records are audit class:
they are retained for the audit label's duration (default 7 years) and
contain no content, only ids, digests and reasons. That is what lets
them outlive the erasures they record.
2.13 Objects and keys
- Object:
live (≥1 key grant or ref) → orphaned → grace → collected. - Conversation key:
active → destroyed. Destruction is erasure. - Tenant KEK:
active → rotating → active. Rotation re-wraps the keys; it never re-encrypts content.
2.14 Telemetry, caches, runtime
These are ephemeral or telemetry classes with no per-item lifecycle, only
size and age limits and a boot sweep of runtime/. They never hold content
(plan M5), so erasure never needs them.
3. Policies
3.1 Retention labels
RetentionLabel {
id, name, description,
rules: [{ class, retain_for?: Duration, delete_after?: Duration,
on_expiry: erase | archive | review }],
}
- Attached to a tenant, space, Agent or conversation. It inherits downward, and a node may break inheritance and attach its own label, as with SharePoint labels.
- Resolution per class:
- the minimum keep is the maximum
retain_foralong the chain; - the maximum keep is the minimum
delete_after, but never below the minimum keep; - holds suspend expiry entirely. A person can make data live longer by labelling. Nobody can make a hold shorter.
- the minimum keep is the maximum
on_expiry = reviewqueues the item for a data steward instead of acting, which is useful for regulated spaces.- Defaults (tenant label
default):
| Class | retain_for | delete_after |
|---|---|---|
| conversation records + content objects | — | none (keep) |
| trash window | — | 30 d |
| runs (no conversation) | — | 180 d |
| checkpoints (sealed session) | — | 30 d |
| environments | — | 7 d after settle |
| Document history (memory, entities) | — | 90 d |
| inbox entries | — | 90 d |
| pending/denied allowlist entries | — | 90 d |
| cost / routing / activity segments | 90 d | 400 d |
| incidents | — | 400 d |
| audit class | 7 y | 7 y |
| telemetry | — | 14 d or 200 MB |
3.2 Legal hold
Hold { id, scope: Tenant | Space | Agent | Conversation | Audience(person) | Query(catalog filter),
reason, created_by, created_at, released_by?, released_at? }
A hold blocks every destructive transition on anything in scope, including items that enter the scope later (a Query hold is re-evaluated each tick). Creating and releasing a hold are audit records, and releasing needs a reason. A person whose trash is held sees "retained by your organisation's policy" when the tenant allows that disclosure, and otherwise the item simply stays in trash.
3.3 Quotas
- Soft quota: a notification and a Data-posture warning.
- Hard quota on Ephemeral or Derived data: evict caches and derived stores, which are rebuildable.
- Hard quota on Record, Object or Document data: new work is refused at admission with a clear error. It fails closed and never silently evicts records.
- Quotas attach at the tenant, space or Agent.
3.4 Erasure scopes and approval
| Scope | Who may request | Approval |
|---|---|---|
| conversation | its owner (a person, for their own), or a steward | none for the owner after the trash window; steward otherwise |
| audience / person (data-subject request) | steward | two-person when a second admin exists |
| Agent | owner or steward | two-person |
| space | owner or steward | two-person |
| tenant | owner | two-person, plus the offboarding window |
A request moves previewed → approved → executing → completed | blocked{hold} | failed{reason}. Execution is resumable: the reconciler checkpoints its
progress in the request record.
3.5 Export and portability
Export can be scoped by conversation, person, Agent, space or tenant. The
bundle contains plaintext records (JSONL), objects, Documents, the manifest
and signatures, and is itself a Run with a receipt. It is what
offboarding, portability requests and vak data export all produce.
3.6 Roles
Today the admin console has a single operator credential. A customer space needs the following, which M7 introduces:
| Role | Can |
|---|---|
| Owner | everything; tenant-level actions |
| Steward | retention labels, holds, erasure requests and approvals, audit export |
| Operator | runs, schedules, reconciler actions, integrity, storage views |
| Member | their own conversations: trash, restore, export, delete permanently where policy allows |
| Auditor | read-only audit, lifecycle and erasure receipts; no content |
Full RBAC for the rest of the product is a separate design. Until it lands, a single-operator install holds Owner, and the two-person rules degrade to a typed confirmation plus a delay (default 24 h, cancellable).
4. Erasure mechanics
Erasure is only complete if every copy of the content is keyed or linked. Two rules make that true:
- Content is keyed to its conversation wherever it is written. A
content field in a shared ledger (inbox
body, delivery text, outbox job payload, commitment statement, turn-card text) is field-encrypted under the conversation key. Non-content fields (ids, timestamps, states) stay readable for audit and scheduling. - Derived writes record
derived_from. Memory notes, entities, skill proposals, FTS rows and embeddings name their source conversation and turn (plan M1).
An erasure of scope S then runs these steps:
- Resolve S to its conversations through the catalog (for a person: every conversation whose audience includes them).
- Check holds. Any hold in scope blocks the whole request, recorded as
blocked{hold}with the hold ids. - Walk lineage outward from those conversations: Documents with
derived_from, artifacts produced and not promoted elsewhere, catalog rows, embeddings, key grants, run records, delivery jobs, inbox entries. - Destroy conversation keys. Records, field-encrypted content and key grants become unreadable.
- Remove derived plaintext:
- catalog rows, with
secure_deleteand a WAL checkpoint; - embeddings;
- Document versions derived only from the erased scope (a version with another live source is re-derived or kept, and the receipt says which);
- checkpoint labels and turn links.
- catalog rows, with
- GC objects whose last key grant was destroyed.
- Write the receipt:
- counts per class, and a digest of the erased identifiers;
- the processors outside reach, with timestamps: channel deliveries from
deliveries, provider dispatches fromWorkReceipt, the bus if configured; - backups that still hold ciphertext, and when they expire. The receipt is an audit-class record, signed, and downloadable.
- Restore guard. A backup restored later re-applies every erasure tombstone from the audit record before any restored data becomes readable (plan M7).
Headless honesty (doc 73 §7.3): where the KEK sits in the encrypted-file fallback, whose key lives beside it, at-rest encryption is nominal. Crypto-shred still works because it destroys the conversation key.
5. The reconciler
loop every tick (default 10 min; hints wake it early; one pass at boot):
observe catalog + refs + policy + holds + quotas + leases (read-only)
plan desired transitions, ordered: runtime scrub → env expiry →
checkpoint prune → trash/expiry erasure → document history →
segment seal/tier → object GC → quota eviction
guard drop any action whose subject is held, leased by a live turn,
or whose preview digest changed; record why
stage quarantine: move to lifecycle/quarantine/<action>/ (same fs)
commit make the change permanent after the stage grace (0 for ephemeral,
1 tick for everything else)
record one lifecycle record per transition; update metrics
- Idempotent and crash-safe. Every action is keyed; a crash between stage and commit resumes; nothing is committed that was not staged.
- Paced. An IO budget per tick (bytes and files) and per-tenant fairness. It never runs a heavy phase while a turn in the same space is mid-dispatch.
- Dry-run is the same plan, printed.
vak data gc --dry-run, the admin Lifecycle screen, anddoctorall show the identical plan object. - Undeclared paths found under a Vak-owned root are quarantined for one cycle and listed as incidents. That is a code defect to fix, so the quarantine exists to make it visible, not to hide it.
- Metrics: last tick, duration, actions by kind, bytes reclaimed, blocked by hold, errors. These are shown as measured values only.
6. Admin console
6.1 Navigation
The existing groups stay (crates/vak-admin-ui/src/App.tsx:6862-6945).
Changes:
| Group | Today | Proposed |
|---|---|---|
| Overview | Home, Inbox | Home (adds Data health), Inbox |
| Work | Sessions, Commitments | Conversations (renamed; lifecycle-aware), Runs (new), Library (new, M8), Commitments |
| Operate | Operations Center: Posture, Live work, Runtime & pools, Channels & delivery, Automations, Providers, incidents, sandbox | the same, plus Schedules (moved from Configure › Integrations › Scheduled tasks), and a Data section: Storage, Lifecycle, Integrity, Sync (M9) |
| Configure | Integrations, Gateway, Permissions & security, Prompts, Memory, Settings | the same, plus Governance (Retention & holds, Erasure requests), Spaces, Agents (lifecycle panel), Security › Keys |
| System | Setup, FinOps | Setup, FinOps (per Agent and per run), Backup & restore, Diagnostics › Traces & logs, Security › Audit export |
#/operations/work/runs/<session_id> is replaced by #/runs/<run_id>
(review R13). Scheduled-task definitions stay editable under Operate ›
Schedules; there is one place for schedules, not two.
6.2 Screens
Every screen follows rule 8: measured evidence only, audience-filtered, URL
carries workspace and time, drill-downs end at raw records, objects or
receipts. "Guard" names the confirmation a destructive action needs.
| # | Screen | Shows | Actions (guard) | Milestone |
|---|---|---|---|---|
| A1 | Home › Data health | storage by class, quota pressure, reconciler last tick and errors, open erasure requests, active holds, integrity status | links only | M7 |
| A2 | Work › Conversations | the list with facets: Agent, space, audience, state (open/idle/sealed/archived/trashed), label, hold, size, last activity | archive, trash, restore, export, request erasure (preview → typed confirmation), place hold (steward) | M0 (trash honesty), M7 |
| A3 | Conversation detail | tabs: Transcript, Forensics (existing SessionForensics.tsx), Lineage (graph from catalog edges), Lifecycle (effective label with the chain that produced it, holds, expiry date, transition history), Receipts | same as A2, per item | M6, M7 |
| A4 | Work › Runs | Actions-style table: run id, cause, Agent, space, schedule/slot, status and decision (with skip reason), duration, cost, started | open, cancel (if running), re-run (creates a new Run with cause manual) | M4 |
| A4b | Run detail | timeline: span waterfall (M5) and records; sessions; executions with stdout objects; artifacts produced; deliveries; receipts; cost | open any linked node | M4, M5 |
| A5 | Operate › Schedules | list: next slot (in the schedule's timezone), last decision, 30-day success rate, skipped reasons, on_crash; editor for definitions | pause/resume, run now, backfill a missed slot (explicit, recorded), edit | M4 |
| A6 | Work › Library | artifacts by space, kind and audience; version history; promotions; shares (inherited vs broken); comments; label | share / unshare (grant dialog showing inheritance), promote (Review path), request erasure | M8 |
| A7 | Operate › Data › Storage | bytes and counts by class × tenant/space/Agent; growth over time (daily catalog snapshots, measured); top consumers; dedupe ratio; cache and telemetry sizes vs quota | set a quota (operator), open the consumer | M7 |
| A8 | Operate › Data › Lifecycle | reconciler status; the pending plan (the dry-run) grouped by action with reason and policy; recent transitions; quarantine; errors | run a tick now, pause the reconciler (recorded; stewards are notified), retry a failed action | M7 |
| A9 | Operate › Data › Integrity | chain verification per record chain (last full and sampled), catalog staleness digest, object verification, key status, undeclared-path incidents | verify now, rebuild catalog (mechanical, invariant 19) | M6, M7 |
| A10 | Configure › Governance › Retention & holds | labels (rules per class); the inheritance tree tenant → space → Agent → conversation with effective policy and breaks; impact preview (what expires in 7/30/90 days); holds with scope, reason and items covered | create/edit label (preview impact; typed confirmation when it shortens anything), attach/break, create hold, release hold (reason required) | M7 |
| A11 | Configure › Governance › Erasure requests | queue with status; the preview (counts per class, derived items, processors outside reach, backups still holding ciphertext); progress; receipts | create (scope picker), approve (second person), cancel before execution, download receipt | M7 |
| A12 | Configure › Security › Keys | tenant KEK backend (keychain or encrypted-file, with the honest note), last rotation, escrow bundles issued; no key material ever shown | rotate KEK, issue escrow bundle (typed confirmation; recorded) | M7 |
| A13 | Configure › Spaces | spaces with per-machine bindings, trust state, git state, default label, quota, size | bind/unbind, trust, archive, request erasure | M3b, M7 |
| A14 | Configure › Agents › Lifecycle panel | state, and the data effects of the next transition (schedules affected, endpoints cut, data retained) | pause, resume, archive, revoke (immediate; typed confirmation), request erasure | M7 |
| A15 | Configure › Security › Audit export | range and scope; record kinds included; bundle signature | create export (a Run with a receipt) | M7 |
| A16 | System › Backup & restore | backups (ciphertext; key grants wrapped; whether escrow is included); restore dry-run showing erasures that will be re-applied | create, restore (typed confirmation), expire | M7 |
| A17 | System › Diagnostics › Traces & logs | search by run id or trace fields; span waterfall; log tail filtered by trace; telemetry quota | none (read-only) | M5 |
| A18 | Operate › Data › Sync | remotes, push/pull lag, pending objects and segments, leases held, conflicts, last error | push/pull now, release a stale lease (recorded) | M9 |
| A19 | System › FinOps | existing charts, plus per Agent and per run (cost rows now carry both) | existing | M1 (data), M4 (views) |
6.3 Client app (people, not operators)
| # | Place | Shows | Actions |
|---|---|---|---|
| C1 | Conversation menu | state and label badge | Archive, Move to trash, Export, Delete permanently (when policy allows the owner; otherwise "Ask an admin", which creates an erasure request) |
| C2 | Trash | trashed conversations with days left | Restore, Delete permanently |
| C3 | "Why is this gone?" | shown when a link or search hits a tombstone: when, by which policy or request, whether a receipt exists; never content | open the receipt (if the viewer may) |
| C4 | Library panel | artifacts this person can see; versions; comments | open, compare versions, share, promote (through Review) |
| C5 | Share dialog | current access (inherited vs explicit), roles | grant, revoke, break inheritance (named as such) |
| C6 | Runs panel per Agent | routine history with skip reasons and deliveries (replaces TasksModal last-run fields) | run now, pause (if permitted) |
| C7 | Settings › Your data | what Vak keeps about this person, per class; retention that applies; storage used | export all, trash all, request erasure |
| C8 | Workbench | execution states (running / settled / scrubbed), candidate expiry | keep candidate (extends to the label max), reject |
7. API
All routes sit under the existing authentication. Mutations follow
invariant 34 (cookie mutations need a recognised Origin; headerless
clients need a real token). Destructive routes also require a
confirm_digest, the preview digest the caller just displayed, so a stale
preview can never authorise a different impact.
GET /data/status classes, quotas, reconciler, integrity summary
GET /data/usage?scope=&class=&since= measured usage and growth
GET /data/lifecycle/plan the dry-run plan
POST /data/lifecycle/tick run now (operator)
GET /data/lifecycle/transitions?… lifecycle records
GET /data/integrity POST /data/integrity/verify POST /data/catalog/rebuild
GET|POST|PUT /data/labels[/{id}] labels; POST /data/labels/{id}/attach, /detach
GET /data/labels/effective?node= resolved policy + chain
GET|POST /data/holds POST /data/holds/{id}/release
POST /data/erasure/preview → { preview, confirm_digest }
POST /data/erasure { scope, confirm_digest } → request
POST /data/erasure/{id}/approve | /cancel GET /data/erasure/{id}[/receipt]
POST /data/export { scope } → Run
GET|POST /data/keys POST /data/keys/rotate POST /data/keys/escrow
GET|POST /data/backups POST /data/backups/{id}/restore/preview | /restore
POST /conversations/{id}/trash | /restore | /archive | /unarchive
GET /runs /runs/{id} /runs/{id}/spans POST /runs/{id}/cancel | /rerun
GET /schedules /schedules/{id}/slots POST /schedules/{id}/pause | /resume | /run | /backfill
GET /search /lineage/{node} /nodes/{id}
GET /library … (M8)
GET /data/sync … (M9)
These replace, in the same change (invariant 30):
POST /sessions/{id}/archive,DELETE /sessions/{id},DELETE /sessions/archived,POST /sessions/{id}/restore,GET /sessions?trash=true(M0's trash,vak_core::trash)POST /memory/cleanupPOST /workspaces/forgetPOST /backup/export|import/agents/{id}/schedule|runs(already deleted in M0)- the session-keyed Operations "runs"
8. CLI parity
vak data status | usage | plan | gc [--dry-run] | verify | rebuild-catalog | labels … | hold … | erase --scope … [--preview] | export --scope … | keys … | backup … | cat | grep, plus vak runs … and vak schedules ….
Every destructive command prints the preview and requires --confirm <digest> (or an interactive typed confirmation). vak doctor --repair may
invoke only reconciler actions classed as mechanical (invariant 19).
9. Acceptance
- Per screen: a browser acceptance run (AGENTS.md acceptance contract)
on a
/tmpworkspace shows real data, and each drill-down reaches raw evidence. A test asserts no screen renders a value that has no API source (invariant 26). - End-to-end scenarios:
- person trashes → restores → deletes permanently, and search, recall and prompts never show it again;
- a steward erases a person across three chats and two Agents, and the receipt lists the Telegram deliveries and provider dispatches it could not reach;
- a hold placed mid-erasure blocks it;
- label shortening previews its impact and needs confirmation;
- a restore re-applies erasures;
- an Agent revoke cuts its endpoints within one tick while its data stays;
- a paused schedule records every skipped slot.