Custom artifact properties + the property search axis — design
Class:
design· Function:properties(usessearchfor the axis + thePropertyLookupseam, the in-processHolgerfacade for the write path, andpromotefor the reused path-safe coordinate sanitizers). Whole-system view:design.md; the gap this closes isfeature-complete-gaps.md§8 (custom properties — the last deferred search axis, previously ❌) andartifactory-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:
-
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 (soenv=prodandenv=stagingcoexist). The repo + coordinate are each reduced to ONE path-safe segment by promote'ssanitize_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. -
The search axis — the pure engine gains
search::search_repos_with_properties(the plainsearch_reposis now a thin wrapper that passes no lookup). ASearchQuery.properties: Vec<(key, value)>AND-composes with the coordinate axes; each candidate must satisfy every filter, resolved through aPropertyLookuptrait 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.)