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

nordisk/holger

.nornir/properties-design.md

Custom artifact properties + the property search axis — design

Class: design · Function: properties (uses search for the axis + the PropertyLookup seam, the in-process Holger facade for the write path, and promote for the reused path-safe coordinate sanitizers). Whole-system view: design.md; the gap this closes is feature-complete-gaps.md §8 (custom properties — the last deferred search axis, previously ❌) and artifactory-parity-design.md §H.

What this is

Custom properties are user-defined key → value(s) metadata attached to an artifact coordinate (repository, namespace/name/version) — the Nexus/Artifactory "properties" capability. Tag (rust-prod, serde 1.0.0) with env=prod, team=core, reviewed=yes, then find every artifact carrying env=prod. This lights up the one search axis search-design.md deferred (it had no user-defined properties to match).

Two halves, one on-disk store:

  1. The store — server/lib/src/properties.rs::PropertyStore, a flat store at <data_root>/holger-props/<repo>/<coord>.props.json, each file a JSON object { "<key>": ["<value>", …] }. A key holds a set of values (so env=prod and env=staging coexist). The repo + coordinate are each reduced to ONE path-safe segment by promote's sanitize_repo/coord_key (reused, not twinned — Law #5), so a hostile coordinate can never write or read outside the root. Writes are last-wins per key, staged through a temp file + rename; an empty value-set removes the key (and an emptied document is deleted). A missing coordinate reads as no properties, never an error.

  2. The search axis — the pure engine gains search::search_repos_with_properties (the plain search_repos is now a thin wrapper that passes no lookup). A SearchQuery.properties: Vec<(key, value)> AND-composes with the coordinate axes; each candidate must satisfy every filter, resolved through a PropertyLookup trait the store implements. Value semantics: an empty value means "the key is present (any value)"; a non-empty value is an EXACT match against one of the key's values.

The one invariant that matters: fail-closed

The properties live in a side store, not on the backend, so the engine only consults them when it is handed a PropertyLookup. With property filters set but no lookup, no artifact matches — the axis fails closed, never open. An unconfigured property store therefore yields an empty result for a property query, never the whole corpus. (Two RED-when-broken tests pin this: a fail-open default would return every artifact.)

Because the primary coordinate axes run first and properties are checked before the expensive checksum fetch, a property filter also cheaply narrows the candidate set.

Coordinate model note (raw repos)

Properties are keyed by the coordinate a backend lists. For a znippy/rust/maven backend that is the real (namespace, name, version). For a raw repo, list() reports a blob as name=<relpath>, version="" — so an operator tags a raw blob with --name <path> --version "" (the same coordinate the search returns). This is consistent by construction: set and search speak the identical coordinate, so a CLI-set property is immediately found by a CLI/HTTP/gRPC search (verified end-to-end).

Surfaces

Operation gRPC HTTP CLI in-process
search by property SearchRequest.properties (PropertyMatch{key,value}) GET /-/search?property=key:value (repeatable) search --property key=value Holger::search_all
get properties PropertyService.GetProperties (open read) GET /-/properties?repo=&namespace=&name=&version= → JSON properties get Holger::get_artifact_properties
set / remove PropertyService.SetProperty (write-gated) PUT/POST/DELETE /-/properties?…&key=&value=… (write-gated) properties set --key --value… (no --value ⇒ remove) Holger::set_artifact_property

The property capability now ships on all four surfaces symmetrically. All share one on-disk store (Holger::property_store(), derived from the config's audit dir), wired into the gRPC service via HolgerGrpc::with_properties and the HTTP gateway via GatewaySettings.properties.

Write auth (mirrors SBOM attach + the artifact write path)

SetProperty (gRPC) and PUT/POST/DELETE /-/properties (HTTP) are writes — writer/admin, fail-closed under a configured role policy, scoped to the target repository (the gRPC gate uses authorize_write(&request, repo); the HTTP door resolves identity via auth::validate_request then authorize_http_write scoped to the coordinate's repo= from the query, not the - path segment). With auth unconfigured the write is open (dev/test), exactly as artifact put is. An empty value-set (or HTTP DELETE) removes the key. GetProperties / GET /-/properties are open reads, like the search axis. Property values ride in the gRPC message / repeatable value= query params, so the HTTP write door carries no artifact body and skips the checksum-deploy gate.

Observability (Law #4)

Gated #[cfg(feature="testmatrix")] functional_status cells on every surface (holger-http/properties, holger-http/properties_write, holger-cli/properties, holger-grpc/{set_property,get_properties}, and the gRPC Search axis via holger-grpc/search). 19 tests: 7 store, 4 engine (include/exclude, fail-closed with no lookup, AND-compose, key-presence), 3 HTTP (query parse + read door + write door set/remove/validate), 1 gRPC search axis, and 4 gRPC PropertyService (set→get round-trip incl. cross-coordinate exclusion, remove, the RED-when-broken write-gate — a role-policy'd unauthenticated set is refused and persists nothing, and no-store fails-precondition). The proto is regenerated with the pure-Rust cargo xtask gen-proto (no protoc, no shell — Law #9).

What is deliberately out of scope

Property namespacing/schema validation (keys are free-form). (The remote write door — gRPC PropertyService.SetProperty + PUT/DELETE /-/properties — and property-based retention/promotion policy have since shipped; see property-policy-design.md.)