Legal holds, step 4: freezing, release, and the audit log

Placing or widening a hold freezes what's already archived in its scope
and range, its old deadline noted; releasing one gives each item no other
hold covers that deadline back, or release plus 30 days if later. One
pass over the archive does both and changes nothing twice (LH-6, LH-10,
LH-11). A held archived item can't be destroyed; restoring still can,
and the hold is named only to callers who may see holds (LH-7). Audit
records about a held account survive the purge (AU-7).

Fixes the daily clean-up of expired archived items (UD-13), which never
found any: the registry's unfiltered query reads an all-ids index that
archived items aren't in. Items are now walked account by account, kept
deleted accounts included. Expired items were still removed whenever
their account's archive was read.
This commit is contained in:
2026-09-27 19:33:07 -07:00
parent 7b97efbb7f
commit 8d3e99bc00
8 changed files with 341 additions and 8 deletions
+35
View File
@@ -107,6 +107,7 @@ impl Keeping {
const FEATURE: u8 = b'H';
const KIND_HOLD: u8 = b'h';
const KIND_ORIGINAL: u8 = b'o';
/// How many times creating a hold retries when another node took its id.
const CREATE_ATTEMPTS: usize = 5;
@@ -367,6 +368,40 @@ fn key(id: u32) -> ValueKey<ValueClass> {
ValueKey::from(class(id))
}
fn original_class(item_id: u64) -> ValueClass {
let mut key = Vec::with_capacity(10);
key.push(FEATURE);
key.push(KIND_ORIGINAL);
key.extend_from_slice(&item_id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
/// LH-10: an archived item's deadline from before a hold froze it, so a
/// release can give it back (or a later one). None for an item held from
/// its deletion, which never had one.
pub async fn original_deadline(data: &Store, item_id: u64) -> trc::Result<Option<u64>> {
data.get_value::<u64>(ValueKey::from(original_class(item_id)))
.await
.caused_by(trc::location!())
}
/// Notes (`Some`) or forgets (`None`) an item's deadline from before it
/// was frozen.
pub async fn set_original_deadline(data: &Store, item_id: u64, until: Option<u64>) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
match until {
Some(until) => batch.set(original_class(item_id), until.to_be_bytes().to_vec()),
None => batch.clear(original_class(item_id)),
};
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// One hold, released or not.
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Hold>> {
Ok(data