Repository navigation
[02/03] service-backup: publish immutable BackupSets manifest-last and retain complete sets #17
Copy link
Copy link
Open
Labels
agent::claude-codedepends-on:3034doingThe work step of the issue loop is on itThe work step of the issue loop is on itepic:3029lib:service-backupphase:createdtype:change
Milestone
Description
Problem
Parent epic: faberline/axiom#3029. Execution position: 02 of 03.
Several immutable artifacts cannot be considered one successful backup merely
because each upload returned success. A crash can leave a partial set, a stale
latest pointer, or retention that deletes the last good recovery point.
service-backup needs one reader-atomic publication and retention protocol.
Capability Alignment
Capability: Versioned Stateful-Service Backup and Restore.
Capability Gap: no manifest-last publication, complete-set retention, or orphan
partial cleanup across local, GCS, and S3-compatible sinks.
Progress Evidence: failure injection at every stage preserves the previous
successful manifest and never makes a partial set discoverable as latest.
Requirements
- R1: Store every artifact under immutable, collision-safe set keys and verify
written size/digest before publication continues. - R2: Publish the immutable manifest only after all artifacts verify; update a
latest-success pointer only after the manifest is durably readable. - R3: Make retries idempotent for the same set id/content and fail closed on a
conflicting reuse of an existing key. - R4: Retain complete sets as a unit, always preserve the latest configured
successful count, and never prune the previous success during a failed run. - R5: Detect and safely garbage-collect orphan partial sets after a grace
boundary without deleting objects referenced by any valid manifest. - R6: Implement equivalent observable semantics for file, GCS, and
S3-compatible backends, including readback verification and bounded errors.
Scope
In Scope
- Immutable key layout, upload/readback, manifest-last commit, latest pointer,
set-aware retention, orphan cleanup, retry/idempotence, backend adapters, and
failure tests.
Out of Scope
- Restore staging/apply, service-domain barriers, Kubernetes Jobs, incremental
backups, and cross-region replication.
Acceptance Criteria
- AC1: Readers observe either the previous complete set or the new complete set,
never a partial set. - AC2: Failure after every artifact, manifest, pointer, or prune stage leaves
the previous successful set restorable. - AC3: Retrying an identical set is safe; conflicting immutable content fails
without overwriting either version. - AC4: Orphan cleanup removes only unreferenced aged partials on all supported
backends.
Reference Context
Related Specs
| Spec | Relevance |
|---|---|
libs/service-backup/src/lib.rs |
current sink and retention API |
projects/sift/src/storage/archive.rs |
manifest-last and digest-readback precedent |
| faberline/axiom#3029 | parent lifecycle contract |
Spec Plan
| Spec ID | Action | Main Spec Ref |
|---|---|---|
service-backup-set-publication |
create | immutable set and manifest commit protocol |
service-backup-set-retention |
create | complete-set retention and orphan cleanup |
Verification Inventory
| Requirement | Gate | Oracle | Depends On |
|---|---|---|---|
| R1 | cargo test -p service-backup --all-features |
immutable upload and digest-readback cases pass | - |
| R2 | cargo test -p service-backup --all-features |
manifest-last and latest-pointer failure cases preserve prior success | R1 |
| R3 | cargo test -p service-backup --all-features |
retry/id conflict cases are deterministic | R2 |
| R4 | cargo test -p service-backup --all-features |
retention prunes only complete old sets | R2 |
| R5 | cargo test -p service-backup --all-features |
grace-bound orphan cleanup never removes referenced artifacts | R4 |
| R6 | cargo test -p service-backup --all-features |
file, GCS, and S3-compatible conformance cases share the oracle | R5 |
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
agent::claude-codedepends-on:3034doingThe work step of the issue loop is on itThe work step of the issue loop is on itepic:3029lib:service-backupphase:createdtype:change
Activity
chrischeng-c4 commentedon Oct 23, 2026
Scope of this spec: the first PR of #17 only — R1 (immutable, collision-safe set keys), plus the pure decision logic of R4 and R5 (which sets retention keeps, which partial sets are orphans). The publish order against real sinks (R2, R3), the file/GCS/S3 adapters (R6), and the readback I/O need later specs on this issue, after #16's streaming traits exist. This PR is pure domain code: it adds types and functions, performs no I/O, and changes no existing public item. It does not depend on #16's
BackupSetV1: its inputs are plain ids, times and flags.Goal
service-backupgets one place that decides where a backup set lives and what retention may delete, so every backend applies the same rules. AddSetKeyLayout(maps a set id to the immutable keys of its artifacts, its manifest, and the latest pointer), and a pureplan_retentionfunction that, given the sets found in a sink, returns which complete sets to keep, which to delete, and which partial sets are orphans old enough to delete. The rules that matter: the manifest key is the last thing written, so a set with no manifest is partial; retention never deletes the newestkeep_latestcomplete sets, and never deletes a partial set younger than the grace period or one that a complete set references. Later PRs of #17 call these from the publish and prune paths.Acceptance
crates/service-backup/src/domain/(one main type per file):set_key_layout.rs(SetKeyLayout,SetKeyError),retention_plan.rs(SetInventory,SetState,RetentionPlan,plan_retention). Declare them insrc/domain.rsand re-export fromsrc/lib.rsnext to the current exports.SetKeyLayout::new(prefix: &str) -> Result<Self, SetKeyError>:prefixis empty or/-separated segments of[A-Za-z0-9._-], none empty,.or.., no leading or trailing/. Keys:artifact_key(set_id, name)=<prefix>/sets/<set_id>/artifacts/<name>,manifest_key(set_id)=<prefix>/sets/<set_id>/manifest.json,latest_key()=<prefix>/latest.json,set_prefix(set_id)=<prefix>/sets/<set_id>/. With an emptyprefixthe leading<prefix>/is omitted.set_iduses[A-Za-z0-9._-]without/, non-empty, at most 128 bytes.nameuses the artifact-name rule from [01/03] service-backup: define streaming BackupSetV1 manifest and single-object compatibility #16's spec (relative/segments of[A-Za-z0-9._-], no./.., at most 256 bytes) and may not bemanifest.json-shadowing, which theartifacts/directory already prevents. Invalid input returnsSetKeyError(thiserror, one variant per rule), never a panic.SetKeyLayout::parse_set_id(key: &str) -> Option<&str>returns the set id of any key that lies under<prefix>/sets/<id>/andNonefor anything else, includinglatest.jsonand keys of another prefix. A test showsparse_set_id(artifact_key(id, n)) == Some(id)for ids and names that contain.,-,_and a nested namea/b.bin.(set_id, name)pairs never give the same key, and no artifact key equals a manifest key or the latest key. A test checks this over a small cross product including namesmanifest.jsonandlatest.json.SetInventory { set_id: String, state: SetState, newest_object_at: SystemTime }andSetState::{Complete { created_at: SystemTime }, Partial}.Completemeans the set's manifest object exists;Partialmeans objects exist under the set prefix but no manifest.plan_retention(inventory: &[SetInventory], keep_latest: usize, now: SystemTime, orphan_grace: Duration) -> RetentionPlanis pure and deterministic (input order does not change the output; ties oncreated_atbreak byset_id).RetentionPlan { keep: Vec<String>, delete_complete: Vec<String>, delete_orphans: Vec<String> }, each sorted byset_id.plan_retention: thekeep_latestnewestCompletesets are kept, withkeep_latestraised to at least 1 so the plan never deletes the last complete set; olderCompletesets go todelete_complete; aPartialset goes todelete_orphansonly whennow - newest_object_at >= orphan_grace, otherwise it is neither kept nor deleted (left out of all three lists); an empty inventory gives an empty plan; with zeroCompletesets nothing indelete_completeis produced.crates/service-backup/tests/it/set_layout.rs, registered intests/it/main.rswithout a feature gate, with one case each for: the key shapes with and without a prefix; each invalid prefix, set id and name;parse_set_idround trip and rejection oflatest.jsonand a foreign prefix; the collision cross product; retention keeps the newest N;keep_latest = 0still keeps one; a young partial set is untouched; an aged partial set is an orphan; input order does not change the plan; an inventory with only partial sets deletes no complete set.Never
BackupSink,SnapshotSource,run_backup_once,LocalFsSink,GcsSink, the S3 sink,fetch_backup_object, orRetentionPolicy. The existing age-basedprunestays as it is until a later PR replaces it.std::fs,SystemTime::now,Utc::now);ddd.toml[policy.domain]forbids them. The caller passesnow.schemars::JsonSchemaon the new types, and do not changetests/it/schema_golden.rsortests/it/wire_golden.rs.crates/service-backup/Cargo.toml.README.md,llms.txt,src/api/llm.rs, ordocs/in this PR.ddd.toml, or CI.Gate
chrischeng-c4 commentedon Oct 24, 2026
Work submitted in #63; waiting for required CI on the current head.