Property-based retention & promotion policy — design
Class:
design· Function:property-policy(usespropertiesfor the sharedmap_matchespredicate + thePropertyStore,retentionfor the GC planner hook, andpromotefor the seal gate). Whole-system view:design.md; builds directly onproperties-design.md. Closesfeature-complete-gaps.md§4 (retention) + §3 (promotion) property-aware rules.
What this is
Custom properties become policy inputs: a repo's retention/GC and its promotion gate can key off a property.
- Retention pin — "keep any blob tagged
keep=true." A pinned artifact is never reclaimed by any retention rule (keep-last-N, max-age, quota). - Promotion requirement — "only promote when the source carries
signed=true." An artifact missing the required property is refused promotion.
Both reuse one matching rule — properties::map_matches(map, key, value) (empty value = "key present, any value") — the same predicate the property search axis and PropertyStore::matches use, so keep=true / signed=true mean exactly the same thing everywhere (Law #5, no twin).
Retention pin — a pure flag the adapter computes
The retention planner (retention::plan_retention) stays I/O-free: it never reads properties. Instead:
RetentionPolicygainsprotect_property: Option<(key, value)>(RON/CLI config).ArtifactRecordgains aprotected_by_property: boolflag.- The adapter
Holger::plan_repo_retention— which already turns a repo'slist()into records — resolves each artifact against the sharedPropertyStore(store.matches(repo, id, key, value)) and sets the flag. - The planner treats the flag as a hard protection: a
truerecord is added to the sameprotectedset asprotect_latest/keep_min, so no rule — superseded, aged, or quota-evicted — can ever select it, and it is annotatedRetentionReason::ProtectedByProperty. - Defense in depth: the executor (
Holger::execute_repo_retention) also refuses to unlink any delete row still carrying theProtectedByPropertyreason (as it already does forProtectedLatest/MinPerNameFloor), so a hand-built/corrupted plan can't drop a pin.
This keeps the planner a pure function (unit-testable with the flag set directly) while the one property lookup lives at the I/O boundary.
Config: RON protect_property: Some(("keep", "true")) on the policy, or CLI holger-server retention --protect-property keep=true (bare --protect-property keep = any value).
Promotion requirement — a gate at the seal boundary
PromotionEngine gains an optional require_property: Option<(key, value)> (defaulting to none = unconditional, unchanged). It only holds the requirement — it does no I/O. The promote boundary (the gRPC promote_artifact handler, which already has the PropertyStore) resolves the source coordinate's properties and calls the pure PromotionEngine::property_requirement_met(&map):
- requirement met ⇒ proceed to
seal(the existing content-address + exclusive-ref transition, unchanged); - not met ⇒ refuse with
failed_precondition(HTTP-equivalent 412), audited as aPromoteat status 412 with afunctional_statuscell — nothing is sealed; - fail-closed: a requirement configured but no property store means the property can't be verified, so the promotion is refused rather than waved through.
The requirement runs after the existing CVE/license policy gate — both must pass.
Config: env HOLGER_PROMOTE_REQUIRE_PROPERTY=signed=true (or bare signed), mirroring the existing HOLGER_PROMOTE_ENFORCE surface; PromotionEngine::parse_property_requirement parses it, with_required_property sets it. (A per-repo RON promotion-requirement surface is a deferred follow-up — this ships the smallest genuine slice: the pure predicate + its GC/promote hook + an env config, per the "defer the config UI" guidance.)
Quarantine — a gate at the serve boundary
HOLGER_QUARANTINE_PROPERTY=quarantine=true installs a serve-time predicate. A blob whose properties match is refused at the read boundary — reported as not found so its existence isn't leaked — even though it is still stored. Off unless configured, so the default read path is unchanged.
Invariant: the predicate means the same thing on every read door (Law #5, no twin). There are three of them — the unary gRPC FetchArtifact, the chunked gRPC StreamArtifact, and the HTTP GET/HEAD gateway — and a door that inlines its own copy of the check is a door that can forget it. The gRPC pair therefore share ONE helper, HolgerGrpc::refuse_if_quarantined (server/lib/src/grpc/services.rs), which both applies the predicate and audits the refusal as a 404 with "quarantined — refused at serve boundary"; the HTTP door applies the same properties::map_matches rule via serve_quarantined.
This was not always true. StreamArtifact takes the same FetchArtifactRequest message as FetchArtifact but went straight to repo.fetch, so a client that simply called the streaming RPC instead of the unary one downloaded the quarantined bytes in 64 KiB frames — and the audit line recorded a normal 200 Download, so the trail actively concealed the bypass. Hoisting the check into a shared helper is the fix and the guard against the next door regrowing the hole.
Observability (Law #4) & tests
functional_status cells fire on the promote gate (holger-grpc/promote_artifact, false + "blocked by property requirement"); the retention reason is carried in the plan/CLI output (protected-by-property). 9 tests, each with a RED-when-broken assertion:
- retention planner (2): a
protected_by_propertyrecord survives akeep_last_n=1sweep that reclaims its untagged sibling (+ a control that the untagged one IS reclaimed); a pinned record is never quota-evicted. - retention adapter (1, end-to-end): tag a live blob
keep=truevia the CLI-sharedset_artifact_property, thenplan_repo_retentionkeeps it while reclaiming the untagged sibling — proving the adapter→store→planner wiring. - promotion (2 pure):
parse_property_requirementforms;property_requirement_metgates on the map (missing/wrong value refused, correct allowed, no-requirement unconditional). - promotion (1 gRPC, RED-when-broken): an unsigned source artifact is refused (
failed_precondition, audited 412) and the same artifact promotes once taggedsigned=true.
Verified end-to-end via the CLI too: retention --keep-last-n 1 --protect-property keep=true keeps the tagged old version [protected-by-property] while the untagged sibling is RECLAIMed.
Tier discipline
A property predicate is pure std (a BTreeMap lookup) — no HTTP client, no new deps. holger-mikro (mini tier) + --no-default-features build unchanged; the airgap egress guarantee is untouched.
Deferred
Per-repo RON promotion-requirement config (env-only today); property-based rules on property-driven max-age overrides. (Property-based blocking on the serve axis — a quarantine=true property refusing a download, not just a promote — has since shipped; see "Quarantine — a gate at the serve boundary" above.)