Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

.nornir/property-policy-design.md

Property-based retention & promotion policy — design

Class: design · Function: property-policy (uses properties for the shared map_matches predicate + the PropertyStore, retention for the GC planner hook, and promote for the seal gate). Whole-system view: design.md; builds directly on properties-design.md. Closes feature-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.

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:

  1. RetentionPolicy gains protect_property: Option<(key, value)> (RON/CLI config).
  2. ArtifactRecord gains a protected_by_property: bool flag.
  3. The adapter Holger::plan_repo_retention — which already turns a repo's list() into records — resolves each artifact against the shared PropertyStore (store.matches(repo, id, key, value)) and sets the flag.
  4. The planner treats the flag as a hard protection: a true record is added to the same protected set as protect_latest/keep_min, so no rule — superseded, aged, or quota-evicted — can ever select it, and it is annotated RetentionReason::ProtectedByProperty.
  5. Defense in depth: the executor (Holger::execute_repo_retention) also refuses to unlink any delete row still carrying the ProtectedByProperty reason (as it already does for ProtectedLatest/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):

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:

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.)