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

nordisk/holger

proto/holger.proto

syntax = "proto3";

package holger.v1;

// Repository service — query and manage artifacts
service RepositoryService {
  // Fetch a single artifact by ID
  rpc FetchArtifact(FetchArtifactRequest) returns (FetchArtifactResponse);
  
  // List artifacts in a repository (with optional filter)
  rpc ListArtifacts(ListArtifactsRequest) returns (ListArtifactsResponse);
  
  // Upload an artifact (write-enabled repos only)
  rpc PutArtifact(PutArtifactRequest) returns (PutArtifactResponse);
  
  // Stream download for large artifacts
  rpc StreamArtifact(FetchArtifactRequest) returns (stream ArtifactChunk);

  // Stream upload for large artifacts (stinky 14). The FIRST message names the
  // repository and the artifact and may carry the first bytes; every later
  // message carries bytes only. The server hands the pieces to the backend as
  // they arrive, so the body is never whole in this process unless a gate must
  // read it (the intake chain, the ingest gates) or `async_job` stages it.
  rpc PutArtifactStream(stream PutArtifactChunk) returns (PutArtifactResponse);
}

// Archive service — znippy archive operations
service ArchiveService {
  // List files in a znippy archive backing a repository
  rpc ListArchiveFiles(ListArchiveFilesRequest) returns (ListArchiveFilesResponse);
  
  // Get archive metadata (file count, total size, compression ratio)
  rpc ArchiveInfo(ArchiveInfoRequest) returns (ArchiveInfoResponse);
}

// Health / admin
service AdminService {
  // Server health check
  rpc Health(HealthRequest) returns (HealthResponse);

  // List configured repositories
  rpc ListRepositories(ListRepositoriesRequest) returns (ListRepositoriesResponse);

  // Report the server's operating profile — static (sealed, read-only, "rigged
  // for silent running") vs dynamic (writable / proxy). Server-reported truth so
  // the operator UI reflects the server, not a local toggle.
  rpc ServerProfile(ServerProfileRequest) returns (ServerProfileResponse);

  // Who does the SERVER think the caller is? Resolves the presented credential
  // (Bearer / mTLS CN) through the same validation every gated call uses and
  // reports the resulting identity + effective GLOBAL role — introspection
  // only, no secrets, and NOT an authorization decision: every operation is
  // still checked by its own gate. Anonymous callers get an answer too (the
  // default role), so a UI can explain what "not logged in" means here.
  rpc WhoAmI(WhoAmIRequest) returns (WhoAmIResponse);

  // How much storage this holger has, may still write, and has already used —
  // one statvfs(3) over its data root plus the archive bytes of every
  // repository that OWNS storage (groups are views and are not summed). Admin
  // read: it describes the host filesystem. A holger that was handed no data
  // root refuses by name; it never answers a capacity of zero for a failure.
  rpc Capacity(CapacityRequest) returns (CapacityReply);


  // ★★ ELECT THIS BOX A TWIN — the bootstrap door, and the ONLY way a role is
  // conferred over the wire.
  //
  // Owner rulings 2026-09-21: "the weakest machine must become the twin.
  // PERIOD" and "IT must not CHANGE role later ! so its a bootstrap thing from
  // xtask". So: both machines install from ONE identical medium with NO role
  // parameter, the control plane MEASURES them (Capacity here, plus the host
  // facts it reads itself), and it tells the weaker one what it is. Exactly
  // once: a box that already holds a role record refuses by name
  // (holger-twin-4), and a box that is not FRESH refuses by name
  // (holger-twin-5) because electing a serving box would put its history
  // behind a follower's refusals.
  //
  // ADMIN-gated like every other mutation here. The election is RECORDED and
  // APPLIED LIVE (stinky 14 §P3): every door refuses writes by name from the
  // reply on, and the follow starts. What waits for the next start is the
  // loopback bind of the public doors — a process does not rebind a door
  // underneath its clients.
  rpc BecomeTwin(BecomeTwinRequest) returns (BecomeTwinReply);

  // ★ THE TWIN OVER MUTUAL TLS — the front certifies, the primary's OWN
  // issuer signs, the twin pins it (nordisk-twin; gunnar's model). In order:
  // BecomeTwin on the twin; TwinCsr on the twin (the key is born there and
  // never crosses); IssueTwinCertificate on the primary; InstallTwinCertificate
  // on the twin. The same three with purpose "recharge" certify a fresh main to
  // read its twin's door (then Recharge on the main). ADMIN-gated; the three
  // pairing steps are asked of a twin too (they change no artifact).
  rpc TwinCsr(TwinCsrRequest) returns (TwinCsrReply);
  // Refused by name unless this box serves a replication door (holger-twin-7)
  // and the principal is one it certifies (holger-twin-8): `holger-twin` from a
  // primary only, `holger-recharge` from any box with a door.
  rpc IssueTwinCertificate(IssueTwinCertificateRequest) returns (IssueTwinCertificateReply);
  // Refused by name: another key (holger-twin-9), a CA that did not sign it
  // (holger-twin-10), a CA other than the pinned one (holger-twin-11). A
  // renewal under the SAME CA is taken.
  rpc InstallTwinCertificate(InstallTwinCertificateRequest) returns (InstallTwinCertificateReply);
  // A fresh main pulls the estate and every repository from the box its
  // "recharge" credential names, over that door, now. Admin; refused on a twin.
  rpc Recharge(RechargeRequest) returns (RechargeReply);
  // ★ THE RECHARGE LANE'S TWO QUESTIONS (gunnar's Bootstrap.Status and the
  // twin's log head, in holger's words). Asked BEFORE a byte moves, admin-read
  // gated, answered by a box that serves nothing yet too.
  // On the MAIN: fresh / restoring / recharged, and what makes it not fresh.
  rpc RechargeStatus(RechargeStatusRequest) returns (RechargeStatusReply);
  // On the TWIN: what it holds and whether it can give everything back.
  rpc TwinStatus(TwinStatusRequest) returns (TwinStatusReply);
  // ★ FENCE THIS PRIMARY (Gunnar's `fence_primary` pattern): the box that runs a
  // pulled `fence_primary` (or a recharge's fence-first) asks the OLD primary to
  // refuse every write from now on, and reads the fence back from the reply.
  // ADMIN-gated by identity (not by the write gates: a fenced box answers a second
  // fence the same). Refused on a twin, which takes no writes.
  rpc FencePrimary(FencePrimaryRequest) returns (FencePrimaryReply);

  // ★ THE ADMIN VIEW (2026-09-28; gunnar's admin mode, holger's own code): the
  // operational snapshot an ADMINISTRATOR's console panes read — every disk and
  // volume, the twin, the growth, the clock, plugins and monetize, the audit
  // log, the installer's log door. ADMIN-READ (the Admin role under RBAC;
  // refused by name to every other role and to a caller with no valid
  // credential; open only on a server with no auth at all, like every admin
  // read). Measured on every call, never cached. See AdminSnapshotReply.
  rpc AdminSnapshot(AdminSnapshotRequest) returns (AdminSnapshotReply);

  // ★ THE ESTATE BOARD (lane E3, 2026-10-19; Gunnar's `Admin.Topology` pattern,
  // Holger's own code): "which boxes is this estate, as THIS holger knows them?"
  // One row per box — this box first, then every box it holds EVIDENCE of: the
  // pairing feed's front, main and twin, the twins whose reports it keeps, the
  // primary a twin follows, and the downstream holgers that pull from it. Each row
  // carries its own live state, the sentence that says how holger knows, the
  // WireGuard overlay address, and the twin's lag. The board is DATA (Holger v2.0
  // H2): the server decides every word and draws nothing; holger-ui draws the rows
  // as they came and decides nothing. ADMIN-READ, like TwinStatus: the rows name
  // addresses, never a secret. Also served as JSON, `GET /-/estate.json`, on the
  // HTTP door.
  rpc Topology(TopologyRequest) returns (TopologyReply);

  // The ARTIFACT FLOW, drawn (restored by lane HOL-SVG-RON, owner 2026-10-30): the RON
  // configuration as boxes and arrows (sources, repositories, the ladder, the intake
  // steps and gates, the doors, storage), the estate it runs in (console, front, main,
  // twin, every downstream by name) and the live counters on each. The same picture
  // `GET /-/flow.svg` serves, from the same function; `graph_json` is the data model it
  // is rendered from (`GET /-/flow.json`, schema `holger.flow/v2`), for another
  // renderer. The server computes the picture; a console only shows it. Admin-read like
  // the estate board. `not_found` when the RON says `flow: (enabled: false)`.
  rpc FlowSvg(FlowSvgRequest) returns (FlowSvgReply);

  // ★ THE SECURITY PAGE (lane HOL-SEC, owner 2026-10-27: "Security must show security, not
  // the machinery again"). Holger is a security gateway; this is how its gate stands now, in
  // sections the server words and the console draws as they came: the intake chain and how
  // many sit at each step, each source and what it checks, each scanner and where scans run
  // (a container), what waits for the two-person approval, what is quarantined, blocked or
  // refused and why (by name), who may read (the `access` block and each repository's
  // visibility), the signatures on what syncs to downstreams, and the last security events
  // of the audit chain. Read by an Admin, a Repository Admin, a Security Approver or an
  // Auditor (`holger-auth-2` otherwise). Names no secret.
  rpc SecuritySummary(SecuritySummaryRequest) returns (SecuritySummaryReply);

  // ★ THE BOX'S IMPORT DOORS (lane W4, 2026-10-19). A box has no shell, so the
  // CLI's `intel import` and `bundle import` are reached here, over HTTP
  // (`POST /-/intel/import`, `POST /-/bundle/import`) and through the
  // `intel.inbox` / `bundle.inbox` drop directories, all running the SAME code:
  // verify the signed bundle, hold it to the anti-rollback floor, write the
  // audit record first, fail closed. The client streams the bundle's bytes;
  // `first_import` (on any chunk) lets an absent floor be established.
  // Repository Admin or Security Approver only (`holger-duty-2` otherwise).
  //
  // An intel bundle becomes the live local advisory database the next scan
  // reads. No `intel:` block: `holger-import-3`.
  rpc ImportIntelBundle(stream ImportBundleChunk) returns (ImportIntelBundleReply);
  // An update or airgap bundle lands every artifact AVAILABLE through the intake
  // chain (`IntakeChain::land_imported`), never into a raw repository's data
  // directory. No intake chain: `holger-import-2`; no trust policy
  // (`promotion.intake.trust_policy`): `holger-import-4`.
  rpc ImportBundle(stream ImportBundleChunk) returns (ImportBundleReply);
}

// Promotion service — the DEV → PROD transition (.nornir/promotion-design.md).
// Promotion moves an artifact from a mutable DEV repository into an immutable,
// content-addressed, sealed PROD store: the promoted artifact is immutable
// (re-promote refused) and blake3 content-addressed. Admin-gated, fail-closed.
service PromotionService {
  // Promote (seal) one artifact from a DEV repo into an immutable PROD store.
  rpc PromoteArtifact(PromoteArtifactRequest) returns (PromoteArtifactResponse);
  // Move one artifact of the intake chain to its next state, or quarantine,
  // release or block it. Separation of duties: the caller's duties decide the
  // verb (Importer → SCANNED/POLICY_CHECKED, SecurityApprover → APPROVED and
  // every hold, SecurityApprover or RepositoryAdmin → SEALED/PROMOTED/
  // AVAILABLE; Developer and Auditor move nothing). The importer of an artifact
  // never approves or releases it. APPROVED needs `approvals` distinct
  // approvers; a vote short of the quorum is recorded and answered with the
  // count. Every approval is attested by the approver's principal and kept in
  // the approval evidence slot. Refusals carry `holger-duty-<n>` or
  // `holger-intake-<n>`.
  rpc AdvanceArtifact(AdvanceArtifactRequest) returns (AdvanceArtifactResponse);
  // The intake state, history and evidence of one artifact. A Developer sees
  // only an AVAILABLE artifact; Importer, SecurityApprover, RepositoryAdmin
  // and Auditor see every state.
  rpc GetArtifactState(GetArtifactStateRequest) returns (GetArtifactStateResponse);
  // The security dashboard's queues: how many intake artifacts stand in each
  // queue (incoming, quarantined, awaiting-approval, blocked, available) and
  // the rows of the one queue asked for, newest first. The server owns which
  // states make up a queue. The same read duty as GetArtifactState decides each
  // row: an artifact the caller may not read is neither listed nor counted.
  rpc ListIntakeArtifacts(ListIntakeArtifactsRequest) returns (ListIntakeArtifactsResponse);
  // The scan evidence of one intake artifact: the latest record of the
  // evidence store (integrity hashes, every scanner's verdict and findings,
  // the SBOM document) and the policy's words. The read duty of
  // GetArtifactState decides it. W5: also every stored record with the DSSE
  // envelope that signs it, the signer's certificate, whether a scan now would
  // agree, and the SBOM link — the same records the SCANNED transition and the
  // `osv` gate read back instead of scanning again. Also `GET
  // /-/intake/evidence?sha256=` and `holger-server intake evidence`.
  rpc GetArtifactEvidence(GetArtifactEvidenceRequest) returns (GetArtifactEvidenceResponse);
  // THE DEPENDENCY SETS (lane HOL-DEPSET-APPROVE; these RPCs lane HOL-ENOSPC-SETS): a
  // pull's whole closure, read and judged as ONE set. The same code as the HTTP doors
  // `GET /-/intake/sets`, `GET /-/intake/set?id=`, `POST /-/intake/set/approve` and
  // `POST /-/intake/set/refuse` (`pull_intake::depset`), and the same duties: a set is
  // listed to a caller who may read every member; approve casts the caller's vote on every
  // member waiting at POLICY_CHECKED (two-person rule per member, the importer never
  // approves); refuse BLOCKS every waiting member with its reason. `holger-cli intake sets`,
  // `intake set <id>`, `intake set approve <id>`, `intake set refuse <id> --reason`. A
  // server without a pull-through answers FAILED_PRECONDITION, a build without the job
  // queue UNIMPLEMENTED (both `holger-server-9`).
  rpc ListDependencySets(ListDependencySetsRequest) returns (ListDependencySetsResponse);
  rpc GetDependencySet(GetDependencySetRequest) returns (DependencySet);
  rpc ApproveDependencySet(JudgeDependencySetRequest) returns (JudgeDependencySetResponse);
  rpc RefuseDependencySet(JudgeDependencySetRequest) returns (JudgeDependencySetResponse);
}

// Audit service — the per-artifact trail and the audit chain (lane S8).
// READ-ONLY. Only an Auditor or an Admin may call it (holger-audit-10); a
// server whose audit sink keeps nothing readable answers holger-audit-8 rather
// than an empty trail.
service AuditService {
  // The ordered trail of one artifact, keyed by the sha256 of its bytes:
  // imported, hash verified, scanned (one per scanner), policy passed or
  // denied, approved (one per approver), sealed, exported, imported on the
  // airgap side. Each entry names its record's place in the hash chain.
  rpc GetArtifactTrail(GetArtifactTrailRequest) returns (GetArtifactTrailResponse);
  // Walk the whole hash chain of the audit log and name the first break
  // (holger-audit-4 in break_reason).
  rpc VerifyAuditChain(VerifyAuditChainRequest) returns (VerifyAuditChainResponse);
  // "What did C download": every DOWNLOAD record, by who (ident) and/or by
  // repository, inside a time range — the newest `limit` of them, in log order,
  // each naming its place in the hash chain (stinky SB #19).
  rpc QueryDownloads(QueryDownloadsRequest) returns (QueryDownloadsResponse);
  // Filter and page every record (lane HOL-AUDIT): by actor, repository,
  // artifact prefix, action, source address, status and time range, oldest
  // first, read from the Arrow index the chain keeps current.
  rpc QueryAudit(AuditQueryRequest) returns (AuditQueryResponse);
  // Count the records: per repository, action and day by default, or by any of
  // repo, action, day, ident, client, status, under the same filter.
  rpc AggregateAudit(AuditAggregateRequest) returns (AuditAggregateResponse);
  // Hand the selected records out as ONE Arrow IPC file (the index schema), for a
  // reader's own engine. Capped per call; page with since_seq.
  rpc ExportAudit(AuditExportRequest) returns (AuditExportResponse);
}

// Search service — cross-repo artifact discovery (parity §H, .nornir/search-design.md).
// A thin, read-only aggregate over the backends' `list()` + `archive_files()`:
// find artifacts across every configured repository by name, GAVC-style
// coordinate (namespace/group · name · version), sha256 content checksum, or a
// raw archive path substring. Reads are open (anonymous), like the other read
// RPCs; also exposed as a read-only HTTP door (`GET /-/search`).
service SearchService {
  // Search artifacts across all (or a named subset of) repositories.
  rpc Search(SearchRequest) returns (SearchResponse);
}

// SBOM service — host + serve SBOM documents as first-class servable artifacts
// (parity §6/§11, .nornir/sbom-hosting-design.md). An SBOM (CycloneDX 1.5, built
// from modgunn's shared SBOM model) is ATTACHED to an artifact coordinate
// (repository + namespace/name/version) and served back byte-identical. Attach is
// a write (writer/admin, fail-closed under a configured role policy); fetch is an
// open read, also exposed as a read-only HTTP door (`GET /-/sbom`).
service SbomService {
  // Attach (host) an SBOM document for a coordinate. Write-gated; last-wins.
  rpc AttachSbom(AttachSbomRequest) returns (AttachSbomResponse);
  // Fetch the hosted SBOM document for a coordinate (NOT_FOUND when none).
  rpc FetchSbom(FetchSbomRequest) returns (FetchSbomResponse);
}

// Property service — set/get custom artifact PROPERTIES (parity §8,
// .nornir/properties-design.md). A property (`key` → values) is attached to an
// artifact coordinate and drives the `property` search axis (SearchRequest).
// SetProperty is a WRITE (writer/admin, fail-closed under a configured role
// policy, scoped to the artifact's repository); GetProperties is an open read,
// mirrored by the read-only `GET /-/properties` HTTP door + the `holger-server
// properties` CLI verb. All share one on-disk store.
service PropertyService {
  // Set `key`'s values on a coordinate (last-wins per key). An EMPTY `values`
  // REMOVES the key. Returns the resulting full property map. Write-gated.
  rpc SetProperty(SetPropertyRequest) returns (SetPropertyResponse);
  // Read all properties on a coordinate (empty map when none). Open read.
  rpc GetProperties(GetPropertiesRequest) returns (GetPropertiesResponse);
}

// Replication service — copy a repository's artifacts from a SOURCE into a
// DESTINATION (parity §I, .nornir/replication-design.md). Within one running
// server this replicates every artifact from `from_repo` into a writable
// `to_repo`, CONTENT-ID IDEMPOTENT: an artifact the destination already holds by
// content id (sha256) is skipped, so a re-run over an unchanged source is a
// no-op. Reads only from the source, writes only into the destination. Admin-
// gated on the destination (like promotion), fail-closed. The `holger-server
// replicate --from --to` CLI verb drives the same engine across two SEPARATE
// file-backed stores.
service ReplicationService {
  // Replicate from `from_repo` into `to_repo` (dry-run unless `execute`).
  rpc Replicate(ReplicateRequest) returns (ReplicateResponse);
}

// ─────────────────────────────────────────────────────────────────────────────
// Migration service — the ONE-TIME move of an existing Nexus or Artifactory
// registry INTO this holger.
//
// This is the first question every operator with an incumbent registry asks,
// and until now the answer was a two-step through a `.znippy` file on a disk.
// It is deliberately NOT a mirror: a migration runs once and finishes. A
// standing, continuously-converging relationship is a different feature with a
// different shape and must not be grown out of this one.
//
// ── Why this is FOUR RPCs and not one ────────────────────────────────────────
//
// `ReplicationService.Replicate` next door is ONE blocking unary RPC with a
// dry-run flag, and that shape is right for what it does: it copies between two
// repositories inside this server, so it is local, bounded and quick. A
// migration pulls someone else's entire registry across a network. It runs for
// HOURS. A single blocking RPC over that would mean a gRPC deadline (or any
// proxy in between) deciding when the migration ends, a UI with nothing to show
// but a spinner, no way to stop it short of killing the server, and a client
// disconnect silently abandoning a half-moved registry with nobody able to say
// how far it got. So the long operation is split from the call that starts it:
//
//   Discover  — the PREVIEW. Connect, list repositories, count assets, sum the
//               advertised sizes, and say exactly which holger repositories
//               would be CREATED. It reads no artifact content, so previewing a
//               terabyte costs a handful of requests. This is what a UI shows
//               before anything is committed, and it is the dry run.
//   Start     — hands back a job id IMMEDIATELY and returns. It does not block.
//   Watch     — a server stream of progress rows (repo, done, failed). Any
//               number of watchers, joining at any time; a watcher that drops
//               does not touch the job.
//   Cancel    — stops admitting new transfers. In-flight ones are allowed to
//               finish, because tearing down a half-written upload is how a
//               store gets a truncated artifact.
//
// ADMIN-gated, fail-closed, like every other mutating service here.
//
// ── Re-running is the recovery path ──────────────────────────────────────────
//
// Every asset is checked against the destination BEFORE it is fetched, keyed on
// its path and the sha256 the SOURCE published. So an interrupted or cancelled
// migration is resumed by starting it again: what landed is skipped, what did
// not is fetched, and a completed migration re-run is an all-skips no-op.
// ─────────────────────────────────────────────────────────────────────────────
service MigrationService {
  // Preview: what a migration WOULD do. Reads no artifact content.
  rpc Discover(DiscoverMigrationRequest) returns (DiscoverMigrationResponse);
  // Begin a migration. Returns a job id immediately; does NOT block.
  rpc Start(StartMigrationRequest) returns (StartMigrationResponse);
  // Follow a running (or finished) migration.
  rpc Watch(WatchMigrationRequest) returns (stream MigrationProgressEvent);
  // Stop admitting new transfers in a running migration.
  rpc Cancel(CancelMigrationRequest) returns (CancelMigrationResponse);
}

// ─── Messages ────────────────────────────────────────────────────────

message ArtifactId {
  string namespace = 1;  // e.g. groupId for Maven, empty for Rust
  string name = 2;
  string version = 3;
}

message FetchArtifactRequest {
  string repository = 1;
  ArtifactId id = 2;
}

message FetchArtifactResponse {
  bytes data = 1;
  string content_type = 2;
  int64 size_bytes = 3;
}

message ArtifactChunk {
  bytes data = 1;
}

message ListArtifactsRequest {
  string repository = 1;
  string name_filter = 2;  // optional substring filter
  int32 limit = 3;
  string page_token = 4;
}

// One row of a repository listing.
//
// ── Fields 4, 5 and 6 are `optional`, and that word is load-bearing ────────────
//
// A proto3 scalar has no field presence: a peer that does not send `sha256`
// and a peer that sends `sha256 = ""` are indistinguishable on the wire, and a
// `published_unix_ms` of 0 arrives looking exactly like a real answer of
// 1970-01-01T00:00:00Z. Both of those would be a FABRICATED value dressed as a
// measurement — an empty digest compares equal to another empty digest, and a
// zero timestamp sorts as the oldest thing in the repository.
//
// The `optional` keyword restores explicit presence (proto3 field presence;
// prost renders these as `Option<i64>` / `Option<String>`), so "this publisher
// does not know" is a distinct state on the wire and not a value at all.
//
// Wire compatibility, both directions:
//   * an OLD server answering a NEW client sends neither field; the client
//     reads `None` = unknown, which is the truth about an old server.
//   * a NEW server answering an OLD client sends two fields the old client
//     does not know; proto3 drops unknown fields, and the old client sees
//     exactly the listing it saw before.
// Neither peer has to be upgraded first, and nothing here is an invented value.
//
// Belt and braces on top of presence: a receiver still treats a
// `published_unix_ms` that is not strictly positive, and a `sha256` that is not
// 64 hex characters, as unknown — so even a hand-rolled client that emits the
// proto3 zero values cannot claim 1970 or an empty hash. See
// `holger_traits::normalize_published_unix_ms` / `normalize_sha256`.
//
// This is the same reasoning as `StartMigrationRequest.skip_digest_verification`
// next door, which is INVERTED on the wire so that the proto3 zero value is the
// safe one. Here the safe state is "unknown", and presence says it outright
// rather than by inversion.
message ArtifactEntry {
  ArtifactId id = 1;
  int64 size_bytes = 2;
  string content_type = 3;
  // When these bytes were published, Unix milliseconds UTC.
  // ABSENT = the serving backend does not know. Never guessed, never 0.
  optional int64 published_unix_ms = 4;
  // Lowercase-hex SHA-256 of the artifact bytes, 64 chars, no `sha256:` prefix.
  // ABSENT = the serving backend does not know, and in particular does NOT mean
  // the bytes are trusted — the same rule `RemoteAsset.sha256` states. A
  // backend that would have to read the artifact to answer leaves it absent
  // rather than turning a listing into a full read of the repository.
  optional string sha256 = 5;
  // An OPAQUE CHANGE TOKEN for these bytes, scoped to this server+repository.
  //
  // Equal ⇒ unchanged HERE. Different ⇒ changed HERE. It is not a content
  // digest and it is not comparable with one; nor is it comparable with a
  // token from any other source, because two sources holding byte-identical
  // artifacts legitimately publish different tokens (holger's binds the
  // archive-relative path, exactly as znippy's own provenance digest does).
  // A consumer stamps its own source scope onto the value before it stores or
  // compares it — see `holger_traits::scope_change_token`.
  //
  // This is the ONE provenance question a znippy-backed repository can answer
  // cheaply: the archive index already carries a BLAKE3 per chunk, so a
  // per-entry fold of them reads no artifact byte. 15 of 17 backends are
  // znippy-backed, and without this every one of them answered `None` to both
  // fields above, landed in the mirror's `unproven` bucket on every tick, and
  // re-fetched its whole repository every 300 s under the default policy.
  //
  // ABSENT = the serving backend does not know. Never guessed, never "".
  optional string change_token = 6;
}

message ListArtifactsResponse {
  repeated ArtifactEntry artifacts = 1;
  string next_page_token = 2;
}

message PutArtifactRequest {
  string repository = 1;
  ArtifactId id = 2;
  bytes data = 3;
  // ASYNC SHAPE -- see `PromoteArtifactRequest.async_job`. Unset/false writes
  // the bytes into the backend on this RPC, as always. Set, the FINALIZE half
  // (the backend write, which for a znippy repo is an index re-seal and for a
  // proxy an outbound round-trip) becomes a job: the body is staged and the
  // answer is a job id. The INGEST GATES still run here, before admission --
  // making an upload asynchronous must not make it ungated.
  bool async_job = 4;
}

// One piece of a `PutArtifactStream` upload. `repository`, `id` and
// `async_job` are read from the FIRST message only; `data` from every one.
message PutArtifactChunk {
  string repository = 1;
  ArtifactId id = 2;
  bytes data = 3;
  bool async_job = 4;
}

message PutArtifactResponse {
  bool success = 1;
  string message = 2;
  // Set ONLY when the request asked for the async shape. Empty otherwise.
  string job_id = 3;
}

message ListArchiveFilesRequest {
  string repository = 1;
  string prefix = 2;  // optional path prefix filter
}

message ListArchiveFilesResponse {
  repeated string paths = 1;
  int64 total_files = 2;
}

message ArchiveInfoRequest {
  string repository = 1;
}

message ArchiveInfoResponse {
  int64 file_count = 1;
  int64 total_uncompressed_bytes = 2;
  string archive_path = 3;
}

message HealthRequest {}

message HealthResponse {
  string status = 1;  // "ok"
  string version = 2;
  int64 uptime_seconds = 3;
}

message ListRepositoriesRequest {}

message RepositoryInfo {
  string name = 1;
  string repo_type = 2;  // "rust", "maven3"
  bool writable = 3;
  bool has_archive = 4;
  // Whether `RepositoryService.ListArtifacts` on this repository is a REAL
  // enumeration (`RepositoryBackendTrait::supports_listing`). A counter that
  // ignores this reads a full repository as empty; a consumer that meters by
  // artifact count MUST refuse, by name, while this is false.
  bool supports_listing = 5;
  // A GROUP repository is a read-only VIEW over `members`: it owns no storage,
  // and its `ArchiveInfo` is the SUM of its members'. A fleet total that adds a
  // group to its members counts those bytes twice — skip every `is_group`.
  bool is_group = 6;
  // The member repository names of a group, in resolution order. Empty for a
  // repository that owns its own storage.
  repeated string members = 7;
  // A SYSTEM repository of this estate (floored at Admin, e.g. the sealed
  // `monetize-backup`): the estate's own, its name taken, never a tenant a
  // buyer pays for. Set by `ListRepositories` only; false elsewhere.
  bool system = 8;
}

message ListRepositoriesResponse {
  repeated RepositoryInfo repositories = 1;
}

// Confer the TWIN role on this box. See AdminService.BecomeTwin.
message BecomeTwinRequest {
  // The primary this box will follow, as an address a client would use
  // ("grpc.holger.rs:50051"). MANDATORY: a twin that names no primary could
  // neither refuse a write by name nor follow anything.
  string primary = 1;
  // Seconds between follow cycles. 0 = the server's default.
  uint64 poll_seconds = 2;
  // The MEASUREMENT that decided this election, in the caller's own words
  // ("2 cores / 1 GB / 20 GB hdd vs 2 / 4 / 20 maxiops"). Recorded verbatim:
  // this record is read by an operator on the worst day, and a number with no
  // units is not a reason.
  string reason = 3;
}

message BecomeTwinReply {
  // Unix seconds the election was recorded at.
  uint64 elected_at = 1;
  // The authenticated identity that elected it — the server's own reading of
  // the credential, not anything the caller said about itself.
  string elected_by = 2;
  // True: the LOOPBACK BIND of the public doors waits for the next start,
  // because doors are bound at wiring time and a running server does not
  // rebind them underneath its clients. The role itself is live — see
  // `applied_live`.
  bool restart_required = 3;
  // True when every door refuses writes by name from this reply on and the
  // follow has started, without a restart (stinky 14 §P3).
  bool applied_live = 4;
}

// Which credential slot of THIS box a pairing step is about: "twin" (the
// twin following its primary; the default when empty) or "recharge" (a fresh
// main reading its twin).
message TwinCsrRequest {
  string purpose = 1;
}

message TwinCsrReply {
  // PKCS#10, PEM. Its key was minted on this box and never leaves it; asked
  // again, the same request.
  string csr_pem = 1;
  // The CN it carries — the principal the source must issue for.
  string principal = 2;
}

message IssueTwinCertificateRequest {
  // `holger-twin` or `holger-recharge`; the request's own CN must be this.
  string principal = 1;
  string csr_pem = 2;
}

message IssueTwinCertificateReply {
  // The client leaf: CN = principal, clientAuth only, 365 days.
  string cert_pem = 1;
  // This box's replication issuer — what the reader pins.
  string issuer_pem = 2;
  // The replication door's port and the addresses it answers on, in order.
  uint32 port = 3;
  repeated string hosts = 4;
  // notAfter, Unix seconds.
  int64 not_after = 5;
}

message InstallTwinCertificateRequest {
  string purpose = 1;
  string cert_pem = 2;
  string issuer_pem = 3;
  uint32 port = 4;
  repeated string hosts = 5;
  // RE-POINT (purpose "twin" only): the primary this twin followed is DEAD and
  // a fresh main was recharged from it — a main with an issuer of its own (an
  // issuer is never replicated). Every install check but the pin runs; the new
  // issuer is pinned and the twin's role record names `primary` from now on.
  // Admin and explicit: never a courier's retry.
  bool repoint = 6;
  string primary = 7;
}

message FencePrimaryRequest {
  // The epoch the order names (0 = a local fence).
  uint64 epoch = 1;
}

message FencePrimaryReply {
  // The box is fenced now: every door refuses writes.
  bool fenced = 1;
  // The epoch it is fenced at.
  uint64 epoch = 2;
  // The write refusal its doors answer from now on.
  string refusal = 3;
}

message RechargeStatusRequest {}

message RechargeStatusReply {
  // "fresh" (holds nothing of its own — a recharge may start), "restoring" (a
  // recharge began and is not whole — it resumes), "recharged" (done, once),
  // "holding" (state of its own: a re-image makes it fresh).
  string state = 1;
  bool fresh = 2;
  bool restoring = 3;
  bool recharged = 4;
  // False while the box refuses to serve (holger-twin-15).
  bool serving = 5;
  // What makes the box not fresh, by path; empty when fresh.
  string held = 6;
  // The source a pending recharge waits for, when one is declared.
  string awaiting_from = 7;
  // This box's control plane as a client dials it (its gRPC door; an
  // unspecified bind is named by its replication door's first address) — what
  // a twin re-pointed at it names as its primary.
  string control = 8;
}

message TwinStatusRequest {}

message TwinStatusReply {
  // "twin" or "primary".
  string role = 1;
  // The primary it follows (a twin).
  string primary = 2;
  // What it holds: estate files, and artifacts over every repository.
  uint64 estate_files = 3;
  uint64 artifacts = 4;
  // True when its last follow cycle that REACHED its primary was whole — a dead
  // primary makes every later cycle fail to connect, and that is not a loss.
  bool complete = 5;
  // Why not complete, in words; empty when complete.
  string why = 6;
  // Unix seconds of the last whole cycle; 0 = never.
  int64 last_whole_unix = 7;
  // How it last followed ("mtls <door>" / "plain <endpoint>").
  string follow = 8;
  // ★ What it last HEARD of its primary — the fence gate's evidence (A7 row 4,
  // Gunnar R-13 fence before recharge): Unix ms of the last follow cycle that
  // reached it (0 = never), how long ago that was on the TWIN's clock, what the
  // primary listed then (estate files; 0 = an empty primary), and the twin's
  // word: "following", "unreachable" (its last cycle did not reach it) or "idle".
  int64 primary_answer_unix_ms = 9;
  uint64 primary_answer_ago_ms = 10;
  uint64 primary_head = 11;
  string follow_state = 12;
}

message InstallTwinCertificateReply {
  int64 days_left = 1;
  // True when this replaced a certificate from the SAME issuer.
  bool renewal = 2;
}

message RechargeRequest {}

message RechargeReply {
  // The estate line, one line per repository, and what failed (empty = whole;
  // the `holger-recharged` marker is written only then).
  string estate = 1;
  repeated string artifacts = 2;
  repeated string failed = 3;
}

message FlowSvgRequest {
  // The zoom, as `GET /-/flow.svg?open=` names it (HOL-FLOW-SMART): the machines to unfold,
  // comma-separated, by id (`estate:main`) or short name (`main`, `twin`, a downstream's
  // name). Empty is level 1 (the Overview: the machines and the upstream sources); some is
  // level 2 (the Flow: those machines unfolded in place, with their part of the RON); `all`
  // is level 3, the full RON.
  string open = 1;
}

// The whole drawing, `image/svg+xml`, UTF-8, self-contained (no script, no external
// reference), and the model it was drawn from, JSON.
message FlowSvgReply {
  string svg = 1;
  string graph_json = 2;
  // Where the drawing is clickable (HOL-FLOW-SMART): each machine that unfolds or folds,
  // and the full-RON link. A console showing the drawing as an image (which follows no
  // link inside it) lays a plain link over each; it computes nothing.
  repeated FlowLink links = 3;
}

// One clickable place of the flow drawing, as the share (0..1) of the drawing's width and
// height it covers, and the relative link it follows (`flow?open=main`).
message FlowLink {
  string href = 1;
  // What following it does: `unfold main`, `fold twin`, `show the full RON`.
  string label = 2;
  double left = 3;
  double top = 4;
  double width = 5;
  double height = 6;
}

message CapacityRequest {}

// One measurement of the filesystem under `path` (the data root).
message CapacityReply {
  // The filesystem size: f_blocks * f_frsize.
  uint64 total_bytes = 1;
  // SERVABLE: what an unprivileged writer (holger) may still put there —
  // f_bavail * f_frsize, i.e. without the root reserve. Not f_bfree.
  uint64 available_bytes = 2;
  // The sum of `ArchiveInfo.total_uncompressed_bytes` over every repository
  // that owns storage. Groups are excluded (their info is a sum of members).
  uint64 used_by_holger_bytes = 3;
  // The data root the measurement was taken over.
  string path = 4;
  // ── the GROWTH VERDICT (2026-09-27, additive) ──
  // Can this data set ever hold more than it does, and by whose hand — edda
  // appliance-grow's ladder over MEASURED facts (the journal on the box, this
  // build's engine, the kernel's PCI hot-plug, PID 1). Reported on every
  // answer: "yes, and this is the last capacity" is a different sale. An OLD
  // holger leaves it 0 (unspecified), which a reader must take as unknown.
  SetGrowth growth = 5;
  // The clause that says it, in words (an attached disk that is billed and
  // invisible is said in those words).
  string growth_reason = 6;
  // What `path` sits on: `4×75G raid0 xfs`; empty when it is not a mount of its own.
  string data_set = 7;
  // The running kernel has PCI hot-plug (a runtime growth needs it).
  bool hotplug = 8;
  // ── the rest of the ELECTION's measurement (stinky SB #37, additive) ──
  // The installer elects the weakest box the twin on cores, then RAM, then data
  // room (nordisk-pairing `elect::weakest`); a capacity that answered the disk
  // alone could not say which box that is. An OLD holger leaves both 0.
  // CPUs this process may run on (available_parallelism).
  uint32 cpu_cores = 9;
  // MemTotal of /proc/meminfo, bytes.
  uint64 ram_bytes = 10;
}

// gunnar's SetGrowth, the same numbers — monetize's `Growth` reads both.
enum SetGrowth {
  SET_GROWTH_UNSPECIFIED = 0;
  GROWTH_UNKNOWN = 1;
  GROWTH_SEALED = 2;
  GROWTH_OPERATOR_ONLY = 3;
  GROWTH_AT_RESTART = 4;
  GROWTH_AT_RUNTIME = 5;
}

message ServerProfileRequest {}

message PromoteArtifactRequest {
  string from_repo = 1;   // source, mutable DEV repository
  string to_repo = 2;     // target, immutable PROD repository
  ArtifactId artifact = 3;
  // ASYNC SHAPE (2026-09-21, stinky 01 §1.3), purely ADDITIVE: unset/false is
  // the original synchronous promotion, byte for byte -- the seal runs on this
  // RPC and the response carries `content_address`. Set true and the server
  // ADMITS the promotion to its durable job queue and answers at once with
  // `job_id`; the seal happens on the queue's gatling pool and is read back
  // through `JobService.GetJob`. A server built WITHOUT the `jobs` feature
  // refuses a true here BY NAME rather than silently promoting inline.
  bool async_job = 4;
}

message PromoteArtifactResponse {
  bool success = 1;
  string message = 2;
  string content_address = 3;  // blake3 hex of the promoted bytes
  string promoted_by = 4;      // identity that performed the promotion
  // Set ONLY when the request asked for the async shape: the admitted job's id.
  // Empty on every synchronous promotion, so an old client sees no change.
  string job_id = 5;
}

// ─── The intake chain's wire verb (separation of duties) ────────────

// What AdvanceArtifact does. ADVANCE moves to the next state on the chain (or
// to `to_state` when set, which must be the next state).
enum IntakeVerb {
  INTAKE_VERB_ADVANCE = 0;
  INTAKE_VERB_QUARANTINE = 1;
  INTAKE_VERB_RELEASE = 2;
  INTAKE_VERB_BLOCK = 3;
}

// One evidence slot: kind is scan | malware | license | provenance | approval |
// other; verdict is pass | warn | fail. A caller never supplies `approval`:
// the server mints it from the authenticated approver (holger-duty-7).
message IntakeEvidence {
  string kind = 1;
  string source = 2;
  string verdict = 3;
  string reference = 4;
}

message AdvanceArtifactRequest {
  ArtifactId artifact = 1;
  IntakeVerb verb = 2;
  // Optional: the state ADVANCE is meant to reach (refused unless it is the next one).
  string to_state = 3;
  string reason = 4;
  repeated IntakeEvidence evidence = 5;
}

message AdvanceArtifactResponse {
  string from_state = 1;
  // The state after the call. Equal to from_state when an approval vote was
  // recorded short of the quorum.
  string state = 2;
  // Distinct approvals now recorded for the APPROVED hop, and how many it needs.
  uint32 approvals = 3;
  uint32 approvals_required = 4;
  // The evidence the transition carried (the approval slots on APPROVED).
  repeated IntakeEvidence evidence = 5;
  string message = 6;
  string sha256 = 7;
  // The authenticated principal the server acted for.
  string actor = 8;
}

message GetArtifactStateRequest {
  ArtifactId artifact = 1;
}

message IntakeTransition {
  string from_state = 1;
  string to_state = 2;
  string actor = 3;
  int64 at = 4;
  repeated IntakeEvidence evidence = 5;
  string policy = 6;
  string reason = 7;
}

message GetArtifactStateResponse {
  string state = 1;
  string sha256 = 2;
  // The principal that ingested the artifact; it never approves it.
  string importer = 3;
  uint32 approvals = 4;
  uint32 approvals_required = 5;
  repeated IntakeTransition history = 6;
  // Approval votes recorded short of the quorum.
  repeated IntakeEvidence pending_approvals = 7;
  // The source the artifact came from (tier 1 `sources:`); empty on a chain
  // declared with the old `promotion:` spelling.
  string source = 8;
  // The artifact's effective policy (the floor plus its source's gates), as JSON:
  // each rule with who set it (`floor`, `steps.gates`, `source '<name>'`).
  string effective_policy = 9;
}

message ListIntakeArtifactsRequest {
  // The queue whose rows to return: incoming | quarantined |
  // awaiting-approval | blocked | available. Empty returns the counts only.
  string queue = 1;
  // At most this many rows (0 = the server's default, 200).
  uint32 limit = 2;
}

// One queue of the security dashboard, as the server groups the states.
message IntakeQueue {
  // The stable name: incoming | quarantined | awaiting-approval | blocked | available.
  string name = 1;
  // The words an operator reads: "Awaiting approval".
  string title = 2;
  // The intake states the queue holds, by name.
  repeated string states = 3;
  // The artifacts in the queue that the caller may read.
  uint64 count = 4;
}

// One intake artifact where it stands now.
message IntakeArtifactRow {
  ArtifactId artifact = 1;
  string state = 2;
  string sha256 = 3;
  // The intake repository it stands in.
  string repository = 4;
  // The principal of the last transition, and when (Unix seconds).
  string actor = 5;
  int64 at = 6;
  // The state a QUARANTINED artifact was held at.
  string held_from = 7;
  string importer = 8;
  // Approval votes recorded short of the quorum, and the quorum.
  uint32 approvals = 9;
  uint32 approvals_required = 10;
  // The policy's words and the reason of the last transition.
  string policy = 11;
  string reason = 12;
}

message ListIntakeArtifactsResponse {
  repeated IntakeQueue queues = 1;
  // The rows of the queue asked for.
  repeated IntakeArtifactRow artifacts = 2;
  // True when the queue held more rows than `limit`.
  bool truncated = 3;
}

message GetArtifactEvidenceRequest {
  ArtifactId artifact = 1;
}

message EvidenceHash {
  // sha256 | blake3.
  string alg = 1;
  string hex = 2;
}

message EvidenceFinding {
  string id = 1;
  // info | low | medium | high | critical.
  string severity = 2;
  string summary = 3;
}

// One scanner's evidence about the artifact.
message EvidenceRecord {
  string scanner_id = 1;
  string scanner_version = 2;
  // inspection | malware | vulnerability | license | secrets | provenance | sbom.
  string kind = 3;
  // pass | warn | block | error.
  string verdict = 4;
  repeated EvidenceFinding findings = 5;
  repeated EvidenceHash inputs = 6;
  string detail = 7;
  // The operator requires this kind; an error from it blocks.
  bool required = 8;
  // No scanner is installed in this slot.
  bool not_configured = 9;
  // A document the scanner produced (the CycloneDX SBOM), as JSON. Empty when none.
  string document_json = 10;
}

message GetArtifactEvidenceResponse {
  // The intake state and the sha256 recorded at ingest.
  string state = 1;
  string sha256 = 2;
  // False when no scan has recorded evidence for these bytes yet.
  bool scanned = 3;
  string file_name = 4;
  string ecosystem = 5;
  uint64 size = 6;
  // The hashes of the bytes the scanners saw.
  repeated EvidenceHash hashes = 7;
  // The fold over every record: pass | warn | block | error.
  string verdict = 8;
  // One sentence per reason the verdict is not pass.
  repeated string reasons = 9;
  repeated EvidenceRecord records = 10;
  // When the scan ran, Unix seconds.
  int64 scanned_at = 11;
  // The policy's words on the last transition.
  string policy = 12;
  // W5: every stored record about these bytes, oldest scan first, each with the
  // envelope that signs it.
  repeated ArtifactEvidenceRecord stored = 13;
  // The attestation signer's certificate (DER) the envelopes verify against.
  bytes signer_cert = 14;
  // `/-/intake/sbom?sha256=<hex>` when the newest record carries an SBOM.
  string sbom_link = 15;
}

// ── the dependency sets (PromotionService.*DependencySet*) ──

message ListDependencySetsRequest {
  // Only the sets in this status (resolving | held | refused | awaiting-approval |
  // approved | available); empty: every set the caller may read.
  string status = 1;
}

message ListDependencySetsResponse {
  // Newest first.
  repeated DependencySet sets = 1;
  // How many distinct approvers each member needs.
  uint32 approvals_required = 2;
}

message GetDependencySetRequest {
  string id = 1;
}

message JudgeDependencySetRequest {
  string id = 1;
  // Why: kept with every vote; a refusal names it on every blocked member (required).
  string reason = 2;
  // The `revision` of the set as the caller was shown it (SEC-REVIEW D4, required): a vote
  // naming none, or another than the set has now, is refused (FAILED_PRECONDITION,
  // holger-depset-4) and nothing is cast.
  string revision = 3;
}

message JudgeDependencySetResponse {
  string id = 1;
  // Where the set stands after the call.
  string status = 2;
  // The members this call voted on (approve) or blocked (refuse).
  uint32 moved = 3;
  string message = 4;
  DependencySet set = 5;
}

// A member or a coordinate, and why it refuses or holds a set.
message DependencySetReason {
  string name = 1;
  string why = 2;
}

// One member of a set, as an approver is shown it.
message DependencySetMember {
  // The chain coordinate (`pypi/dep-1.0-py3-none-any.whl`).
  string name = 1;
  // requested | parent | bom | descriptor | dependency.
  string role = 2;
  // What needed it.
  string via = 3;
  // The intake state (POLICY_CHECKED …), or not-in-chain.
  string state = 4;
  string sha256 = 5;
  // The votes cast on its current bytes, and by whom.
  uint32 approvals = 6;
  repeated string approvers = 7;
  // Why it is refused or held, when it is.
  string reason = 8;
  // Advisories recorded as a warning.
  repeated string warnings = 9;
}

// One dependency set: what a pull brought in, judged as one.
message DependencySet {
  string id = 1;
  // The pull-through source.
  string source = 2;
  // maven | npm | crates | python.
  string ecosystem = 3;
  // What was asked for, in the ecosystem's words (`app==1.0`).
  string root = 4;
  // When the pull ran, Unix seconds.
  int64 at = 5;
  // resolving | held | refused | awaiting-approval | approved | available.
  string status = 6;
  // The words for the status.
  string summary = 7;
  repeated DependencySetMember members = 8;
  // The members that refuse the set.
  repeated DependencySetReason blockers = 9;
  // What holds the set.
  repeated DependencySetReason holds = 10;
  uint32 approvals_required = 11;
  // The approvers who voted on every member still waiting.
  repeated string approvers = 12;
  // Members waiting at POLICY_CHECKED, and members AVAILABLE.
  uint32 awaiting = 13;
  uint32 available = 14;
  repeated string notes = 15;
  // What a vote names (SEC-REVIEW D4): a hash over the members and their sha256 as shown.
  string revision = 16;
}

// One stored scan-evidence record (W5).
message ArtifactEvidenceRecord {
  // The blake3 of the record's bytes: its name in the evidence store.
  string id = 1;
  // When the scan ran, Unix seconds.
  int64 scanned_at = 2;
  // The fold over every scanner: pass | warn | block | error.
  string verdict = 3;
  repeated string reasons = 4;
  // The record itself (holger.scan-evidence/v1 JSON), byte-identical to the store.
  bytes evidence_json = 5;
  // The DSSE envelope (in-toto, predicate https://holger.rs/attestation/scan-evidence/v1)
  // that verifies to this record; empty when the record is not signed.
  bytes envelope = 6;
  // Empty when a scan now would agree; otherwise why it is stale (a newer
  // scanner version or intel database sequence).
  string stale = 7;
}

// ─── AuditService messages ───────────────────────────────────────────

message GetArtifactTrailRequest {
  // Lowercase-hex SHA-256 of the artifact's bytes (a `sha256:` prefix is allowed).
  string sha256 = 1;
}

message ArtifactTrailEntry {
  // The record's sequence number and chain hash in the audit log.
  uint64 seq = 1;
  string hash = 2;
  int64 ts_nanos = 3;
  // imported | hash_verified | scanned | policy_passed | policy_denied |
  // approved | sealed | exported | airgap_imported
  string kind = 4;
  string ident = 5;
  string repo = 6;
  string artifact = 7;
  uint32 status = 8;
  string detail = 9;
}

message GetArtifactTrailResponse {
  string sha256 = 1;
  repeated ArtifactTrailEntry entries = 2;
  // Whether the whole audit chain verified when the trail was read.
  bool chain_intact = 3;
  string chain_summary = 4;
}

message QueryDownloadsRequest {
  // Who downloaded (the authenticated principal; `anonymous` for open reads).
  // Empty = anybody.
  string ident = 1;
  // The repository. Empty = every repository.
  string repo = 2;
  // From (inclusive) and to (exclusive), nanoseconds since the Unix epoch.
  // Zero = open.
  int64 since_nanos = 3;
  int64 until_nanos = 4;
  // At most this many rows, the NEWEST that match. Zero = 1000; capped at 10000.
  uint64 limit = 5;
}

message DownloadRow {
  uint64 seq = 1;
  string hash = 2;
  int64 ts_nanos = 3;
  string ident = 4;
  string repo = 5;
  string artifact = 6;
  string source_ip = 7;
  uint32 status = 8;
  uint64 bytes = 9;
  string sha256 = 10;
}

message QueryDownloadsResponse {
  repeated DownloadRow rows = 1;
  // How many rows matched in all, and whether the limit left some out.
  uint64 matched = 2;
  bool truncated = 3;
}

// Which records: every field optional, set fields AND together.
message AuditFilter {
  // The actor (authenticated principal; "anonymous" for open reads).
  string ident = 1;
  string repo = 2;
  // A PREFIX of the artifact: "acme/" a namespace, "acme/widget@" every version.
  string artifact_prefix = 3;
  // download | upload | delete | list | promote | entitlement | purge | scan |
  // token | other. Any other word is refused.
  string action = 4;
  // The client address; without a port it matches every port of the address.
  string source_ip = 5;
  // The exact status. 0 = any.
  uint32 status = 6;
  // From (inclusive) and to (exclusive), nanoseconds since the Unix epoch. 0 = open.
  int64 since_nanos = 7;
  int64 until_nanos = 8;
}

message AuditRecordRow {
  uint64 seq = 1;
  string hash = 2;
  int64 ts_nanos = 3;
  string ident = 4;
  string action = 5;
  string repo = 6;
  string artifact = 7;
  string source_ip = 8;
  uint32 status = 9;
  uint64 bytes = 10;
  string detail = 11;
  string sha256 = 12;
  string trail = 13;
}

message AuditQueryRequest {
  AuditFilter filter = 1;
  // Matching records to skip; pass back next_offset.
  uint64 offset = 2;
  // Page size. 0 = 100; capped at 1000 (limit_applied says what was used).
  uint32 limit = 3;
}

message AuditQueryResponse {
  // The page, oldest first.
  repeated AuditRecordRow records = 1;
  // Every match before paging: the number to show.
  uint64 matched = 2;
  uint64 next_offset = 3;
  // Matches exist beyond this page.
  bool more = 4;
  uint64 scanned = 5;
  uint32 limit_applied = 6;
}

message AuditAggregateRequest {
  AuditFilter filter = 1;
  // repo | action | day | ident | client | status. Empty = repo, action, day.
  repeated string group_by = 2;
  // Only 2xx statuses (what was served, not what was refused).
  bool ok_only = 3;
  // At most this many groups (the first in key order). 0 = 10000.
  uint32 max_groups = 4;
}

message AuditAggregateRow {
  // The keys not grouped on are empty (status 0).
  string repo = 1;
  string action = 2;
  // YYYY-MM-DD, UTC.
  string day = 3;
  string ident = 4;
  // The client address without its port. An address is not an identity.
  string client = 5;
  uint32 status = 6;
  uint64 count = 7;
  uint64 bytes = 8;
  int64 first_ts_nanos = 9;
  int64 last_ts_nanos = 10;
}

message AuditAggregateResponse {
  repeated string group_by = 1;
  repeated AuditAggregateRow rows = 2;
  bool groups_truncated = 3;
  uint64 rows_counted = 4;
  uint64 scanned = 5;
}

message AuditExportRequest {
  AuditFilter filter = 1;
  // Only rows with seq >= since_seq (the next page: next_seq of the last reply).
  uint64 since_seq = 2;
  // At most this many rows, the oldest that match. 0 = 50000; capped at 50000.
  uint32 limit = 3;
}

message AuditExportResponse {
  // One Arrow IPC file.
  bytes arrow_ipc = 1;
  uint64 rows = 2;
  bool truncated = 3;
  uint64 next_seq = 4;
  // "holger.audit.index/v1" and the schema, `name:type` per column.
  string layout = 5;
  string schema = 6;
}

message VerifyAuditChainRequest {}

message VerifyAuditChainResponse {
  bool intact = 1;
  uint64 records = 2;
  uint64 checkpoints = 3;
  uint64 signatures_verified = 4;
  uint64 signatures_unchecked = 5;
  uint64 unsigned_checkpoints = 6;
  // The hash of the last line that verified.
  string head = 7;
  // The first break: 1-based line, its seq, and why. Zero / empty when intact.
  uint64 break_line = 8;
  uint64 break_seq = 9;
  string break_reason = 10;
  string summary = 11;
}

// ─── SearchService messages ──────────────────────────────────────────

message SearchRequest {
  // Case-insensitive substring match on the artifact NAME (Maven artifactId,
  // crate name, …). Empty = any name.
  string name = 1;
  // Case-insensitive substring match on the NAMESPACE (Maven groupId, npm
  // scope). Empty = any / no namespace constraint.
  string namespace = 2;
  // Exact match on the artifact VERSION. Empty = any version.
  string version = 3;
  // Lowercase-hex sha256 of the artifact CONTENT (exact, case-insensitive).
  // Setting this forces the server to hash candidate artifacts' bytes, so it is
  // the one expensive axis — leave empty unless you are searching by digest.
  string checksum = 4;
  // Case-insensitive substring match on a RAW archive file path. Produces path
  // hits (see `paths`), independent of the artifact axes above.
  string path = 5;
  // Restrict the search to these repository names. Empty = every configured repo.
  repeated string repositories = 6;
  // Cap the number of hits returned (per hit kind). 0 ⇒ a server default.
  int32 limit = 7;
  // Custom-PROPERTY filters (AND-composed with each other and the axes above). An
  // artifact matches only if it carries every listed property. Empty = no
  // property constraint. Requires the server to have a property store configured;
  // with none, a property filter matches nothing (fail-closed).
  repeated PropertyMatch properties = 8;
}

// One custom-property filter: the artifact must carry `key`, and — when `value`
// is non-empty — that exact value among the key's values (empty `value` = "the
// key is present, any value").
message PropertyMatch {
  string key = 1;
  string value = 2;
}

// One artifact matched by name / coordinate / checksum, tagged with the repo it
// was found in.
message ArtifactHit {
  string repository = 1;
  ArtifactId id = 2;
  int64 size_bytes = 3;
  string content_type = 4;
  // sha256 hex of the content — set ONLY when a checksum query forced a hash
  // (empty otherwise, to keep the common name/coordinate search cheap).
  string checksum = 5;
}

// One raw archive path matched by the `path` axis, tagged with its repo.
message PathHit {
  string repository = 1;
  string path = 2;
}

message SearchResponse {
  repeated ArtifactHit artifacts = 1;
  repeated PathHit paths = 2;
  // True when the result set hit the `limit` cap and more hits exist.
  bool truncated = 3;
}

// ─── SbomService messages ────────────────────────────────────────────

message AttachSbomRequest {
  // The repository the artifact lives in.
  string repository = 1;
  // The artifact coordinate the SBOM describes (namespace/name/version).
  ArtifactId artifact = 2;
  // The SBOM document bytes to host (a CycloneDX 1.5 JSON document). Served back
  // byte-identical by FetchSbom / the HTTP door.
  bytes document = 3;
}

message AttachSbomResponse {
  bool success = 1;
  string message = 2;
  // blake3 hex of the attached document (the SBOM's content address).
  string content_address = 3;
}

message FetchSbomRequest {
  string repository = 1;
  ArtifactId artifact = 2;
}

message FetchSbomResponse {
  // The hosted SBOM document bytes (byte-identical to what was attached).
  bytes document = 1;
  int64 size_bytes = 2;
  // blake3 hex of the document (its content address).
  string content_address = 3;
}

// ─── PropertyService messages ────────────────────────────────────────

// One property: a key with its ordered set of values (the map's entry form on
// the wire, since proto has no first-class ordered multimap here).
message PropertyEntry {
  string key = 1;
  repeated string values = 2;
}

message SetPropertyRequest {
  string repository = 1;
  ArtifactId artifact = 2;
  // The property key to set (or, with empty `values`, remove).
  string key = 3;
  // The key's values (deduped, order preserved). EMPTY ⇒ remove the key.
  repeated string values = 4;
}

message SetPropertyResponse {
  bool success = 1;
  string message = 2;
  // The coordinate's full property map AFTER the set/remove.
  repeated PropertyEntry properties = 3;
}

message GetPropertiesRequest {
  string repository = 1;
  ArtifactId artifact = 2;
}

message GetPropertiesResponse {
  // The coordinate's full property map (empty when none attached).
  repeated PropertyEntry properties = 1;
}

// ─── ReplicationService messages ─────────────────────────────────────

message ReplicateRequest {
  // Source repository — read-only here (only listed + fetched, never mutated).
  string from_repo = 1;
  // Destination repository — where artifacts land. MUST be writable.
  string to_repo = 2;
  // false (default) = dry-run PREVIEW (report what would copy, write nothing);
  // true = actually copy the missing artifacts into the destination.
  bool execute = 3;
  // Cap the number of source artifacts examined; 0 = unlimited.
  int32 limit = 4;
  // ASYNC SHAPE -- see `PromoteArtifactRequest.async_job`. True admits it to the
  // job queue and answers with `job_id`. An EXECUTING replication on a server
  // with a job queue is queued even when this is unset; a dry run, or a server
  // without a queue, runs on this RPC and the caller waits.
  bool async_job = 5;
}

message ReplicateResponse {
  // Total source artifacts examined.
  int64 examined = 1;
  // Artifacts actually written into the destination (0 on a dry run).
  int64 copied = 2;
  // Artifacts already present in the destination by content id (skipped).
  int64 skipped = 3;
  // Artifacts a dry run WOULD copy (0 on an executing run).
  int64 would_copy = 4;
  // Artifacts that could not be replicated (source unreadable / dest read-only).
  int64 failed = 5;
  // Sum of bytes copied (0 on a dry run).
  int64 copied_bytes = 6;
  // True when the `limit` cap was hit and more source artifacts remained.
  bool truncated = 7;
  // True when nothing failed — the replication is fully accounted for.
  bool complete = 8;
  string message = 9;
  // Set ONLY when the request asked for the async shape: the admitted job's id.
  // Every count above is 0 in that case -- nothing has run yet, and reporting a
  // zero as a result would be a lie. Read the outcome with `JobService.GetJob`.
  string job_id = 10;
}

// ─── MigrationService messages ───────────────────────────────────────

// Where a migration reads FROM, credentials included.
//
// ⚠ The credential is a PARAMETER and lives in memory for the life of the job
// alone. It is never written to disk, never echoed back by any RPC here, and
// never audited. A one-shot move does not need a stored secret, and a stored
// secret is a standing capability against someone else's registry that outlives
// the reason it was given. Persisting one is a separate decision with its own
// holder (`nornir-vault`), and it is not made here.
message MigrationSource {
  enum Kind {
    MIGRATION_SOURCE_UNSPECIFIED = 0;
    NEXUS = 1;
    ARTIFACTORY = 2;
  }
  Kind kind = 1;
  // Nexus: the base URL. Artifactory: the URL up to (not including)
  // `/artifactory`.
  string base_url = 2;
  // Basic-auth credential. In memory only — see the warning above.
  string username = 3;
  string password = 4;
  // Migrate only this ONE source repository. Empty = the whole registry.
  string repository = 5;
}

// One source repository and what taking it would mean for holger.
message MigrationRepoPlan {
  string source_repo = 1;
  // The format string the SOURCE used, verbatim (`maven2`, `pypi`, `generic`).
  string source_format = 2;
  string target_repo = 3;
  // holger's `ron_repo_type` for the mapped format (`maven3`, `pip`, `raw`).
  // Empty when holger has no format for what the source said.
  string target_format = 4;
  // `existing` | `create` | `collision` | `unmapped_format`.
  string disposition = 5;
  // Why this repository refuses, when it does. Empty otherwise.
  string refusal_reason = 6;
  // Assets the source LISTED. No artifact content was read to count them.
  int64 assets = 7;
  // Sum of the sizes the source's listing advertised. 0 where it advertised
  // none — which is "not stated", not "empty".
  int64 bytes = 8;
  // The RON declaration an operator must add to the wired config, and restart,
  // BEFORE this repository can be filled. Non-empty only for `create`.
  //
  // ⚠ holger cannot create a repository while it is running: the route table is
  // built from the config at boot and is immutable for the process lifetime. So
  // a `create` row is not a thing the migration will do — it is a thing a person
  // must do first. Rendered by `traits::migrate::ron_declaration`, the same
  // function the run-time refusal quotes, so the console and the terminal hand
  // over one text and not two.
  string declaration = 9;
}

message DiscoverMigrationRequest {
  MigrationSource source = 1;
}

message DiscoverMigrationResponse {
  repeated MigrationRepoPlan repos = 1;
  int64 total_assets = 2;
  int64 total_bytes = 3;
  // True when nothing refuses — this plan would actually run.
  bool would_run = 4;
  // One line per refusing repository. Non-empty iff `would_run` is false.
  repeated string refusals = 5;
  // The same human preview the CLI's `--dry-run` prints, rendered ONCE in
  // `traits::migrate` so the UI and the terminal cannot describe the same plan
  // in two different ways.
  string report = 6;
}

message StartMigrationRequest {
  MigrationSource source = 1;
  // In-flight asset transfers. 0 = the engine default.
  int32 concurrency = 2;
  // ⚠ Inverted ON PURPOSE. proto3 has no field presence for a bool, so a
  // `verify_digests` a caller forgot to set would arrive as false and silently
  // turn the check off. The safe state has to be the zero value: leave this
  // alone and every asset the source published a sha256 for is verified before
  // it is stored.
  bool skip_digest_verification = 3;
}

message StartMigrationResponse {
  // Follow it with `Watch`, stop it with `Cancel`.
  string job_id = 1;
  string message = 2;
  // Echo of the plan the job started with, so a caller that skipped `Discover`
  // still learns which repositories it is creating.
  repeated MigrationRepoPlan repos = 3;
}

message WatchMigrationRequest {
  string job_id = 1;
}

// One progress row. A watcher that joins late receives the latest row for each
// repository first, then follows live — so a UI that reconnects is not blind.
message MigrationProgressEvent {
  string job_id = 1;
  // The holger repository this row is about.
  string repo = 2;
  // Assets written into this repository so far.
  int64 done = 3;
  // Assets that could not be moved.
  int64 failed = 4;
  // Assets the destination already held — the idempotence count.
  int64 skipped = 5;
  // Assets the source listed for this repository: the denominator.
  int64 total = 6;
  int64 bytes = 7;
  // Last row for this repository.
  bool repo_finished = 8;
  // Last row for the whole job. The stream ends after it.
  bool job_finished = 9;
  // Only meaningful when `job_finished`: nothing failed and it was not cut
  // short. A migration that "succeeded" with failures is one an operator would
  // wrongly believe is done.
  bool complete = 10;
  bool cancelled = 11;
  string message = 12;
}

message CancelMigrationRequest {
  string job_id = 1;
}

message CancelMigrationResponse {
  // True when the job was running and is now winding down. False when it had
  // already finished — which is not an error, just an answer.
  bool cancelling = 1;
  string message = 2;
}

message WhoAmIRequest {
  // Optional. When set, the answer also carries the caller's role ON THIS
  // repository (`repository_role`) — the per-repository ACL included, the same
  // resolution the write and admin gates apply. A console asks this instead of
  // deciding for itself what a person may do to a repository (holger-ui's
  // thin-layer move, 2026-10-04: buying room for a repository is a write on it).
  string repository = 1;
}

message WhoAmIResponse {
  // True when the presented credential validated against a configured method.
  bool authenticated = 1;
  // The validated principal (OIDC sub / StaticToken subject / mTLS CN).
  // Empty for anonymous callers.
  string subject = 2;
  // Which configured method validated the credential (e.g. "bearer", "oidc",
  // "mtls"). Empty for anonymous callers.
  string method = 3;
  // The caller's effective GLOBAL role name ("denied"/"reader"/"writer"/
  // "admin"), from the RBAC map or the default role. Per-repo ACLs can grant
  // or deny beyond this — this is discoverability, not authorization.
  string global_role = 4;
  // Whether the opt-in read gate (`require_auth_for_reads`) is active — with
  // it on, even reads need the reader floor.
  bool read_gate = 5;
  // The caller's effective role on `WhoAmIRequest.repository`
  // ("denied"/"reader"/"writer"/"admin"), per-repository ACL included. Empty
  // when no repository was asked about.
  string repository_role = 6;
  // What is open on this Holger, in plain words (HCON-5: the RON's `access`
  // block and its public repositories), read only.
  string access_words = 7;
}

message ServerProfileResponse {
  // "static" (sealed / read-only) or "dynamic" (writable / proxy).
  string profile = 1;
  // True when the server accepts no writes on any repository — server truth,
  // derived from the live route table (or an explicit sealed-mode override).
  bool read_only = 2;
  // Human label for the UI chrome/badge (e.g. the red "silent running" strip).
  string label = 3;
  // Number of configured repositories that accept writes (0 ⇒ read_only).
  int32 writable_repo_count = 4;

  // ── THE MODULE CENSUS ────────────────────────────────────────────────────
  //
  // An appliance has no shell. This is the only way an operator can ask "what
  // is loaded here, and what may it do" — and the only way a test can ask it of
  // a RUNNING box rather than of a build.
  //
  // A cargo feature's absence is provable in the symbol table. An uninstalled
  // module's absence is only "a file that is not there", which is weaker. These
  // three fields are where that strength is recovered.
  repeated LoadedModule modules = 5;
  // The union of every capability granted to any module on this boot. A set
  // that does not contain `principal.read`, `digest.read`, `clock.read` or
  // `audit.write` proves "and none can be" from CONFIGURATION, without looking
  // at a directory at all.
  repeated string granted_capabilities = 6;
  // The host functions actually defined on this boot's wasm linkers. A module
  // importing anything outside this set fails INSTANTIATION, because the import
  // is not defined — it is not merely refused at the call.
  repeated string linker_defines = 7;
}

// One guest this boot loaded.
message LoadedModule {
  // The name the module's own manifest declares.
  string name = 1;
  // "linked" for a guest compiled into this binary, or the module's file name
  // for one loaded from the plugin directory.
  string origin = 2;
  // "package-handler" | "meter".
  string kind = 3;
  // Hex blake3-256 of the module bytes, over exactly what the detached CMS
  // signature covers. EMPTY for a linked guest, which has no module bytes —
  // and empty is distinguishable from a digest, which is the point.
  string digest = 4;
  // The verified signer this module arrived under, when a trust policy was in
  // force. Empty means no provenance was REQUIRED — never "it failed and we
  // carried on", which is a boot error.
  string receipt = 5;
  // What the operator granted THIS module, by name.
  repeated string granted = 6;
}

// ─────────────────────────────────────────────────────────────────────────────
// Usage — what a repository is CONSUMING, in holger's own units.
//
// **In the open build, always.** Counting is not money: a self-hoster wants the
// numbers too, and a box that cannot say how much it holds is worse for
// everyone. What is NOT here is any notion of a plan, a price or a cap — that
// contract lives in `holger-metered.proto` and is compiled only by the metered
// build.
//
// **The tenant is a repository** (`RepositoryInfo.name`): holger has no
// namespaces, and the repository is the unit an operator sells.
//
// ⚠ **Three of the six meters are SERVER-WIDE and say so.** Only `artifacts` and
// `archive_bytes` are measured per repository today; `bytes_served`, `requests`
// and `proxy_upstream_hits` are process counters (`metrics.rs`) with no
// per-repository breakdown. Reporting a server-wide number under one
// repository's name would be a number that is not true, and a bill drawn from
// it would be wrong, so each reading carries its own `scope` and a reader that
// ignores the field cannot mistake one for the other.
// ─────────────────────────────────────────────────────────────────────────────
service UsageService {
  // One repository's meters. READER on that repository.
  rpc ReadUsage(ReadUsageRequest) returns (ReadUsageResponse);
}

// What a single reading is a measurement OF.
enum MeterScope {
  METER_SCOPE_UNSPECIFIED = 0;
  // Measured for THIS repository alone.
  METER_SCOPE_REPOSITORY = 1;
  // Measured for the whole server. The same value comes back for every
  // repository, and it is not divisible between them.
  METER_SCOPE_SERVER = 2;
}

message MeterReading {
  // The meter's name, as monetize's holger plugin declares it: `artifacts`,
  // `archive_bytes`, `bytes_served`, `requests`, `proxy_upstream_hits`, `repos`.
  string name = 1;
  // ⚠ MEANINGLESS unless `counted` is true. See below.
  uint64 value = 2;
  MeterScope scope = 3;
  // Where the number came from: the backend name, `metrics`, or whatever
  // produced it. A reader that has to ask "who said this" has a number it
  // cannot check.
  string source = 4;
  // **THE FALSE-ZERO GUARD.** `false` means this backend cannot answer this
  // meter at all, and `value` is not a measurement — not a zero, not a
  // default, not "empty".
  //
  // Four of holger's backends (`znippy-go`, `znippy-composer`, `znippy-docker`,
  // `znippy-nuget`) never override `RepositoryBackendTrait::list` and answer the
  // trait default, an empty listing. `list(...).unwrap_or(0)` stamped with a
  // REPOSITORY scope reported a full repository as empty, and collapsed a
  // genuine backend ERROR into the same 0 — so "the disk broke" and "nothing
  // here" were one answer on the wire. A bill drawn from that is wrong in the
  // customer's favour on a healthy box and wrong in ours on a broken one.
  //
  // A fold that treats `counted == false` as a zero is the bug this field
  // exists to make impossible. monetize's own fold already counts an absent
  // reading as a HOLE (`FleetTotal::tenants_without_reading`), which is the
  // behaviour this field lets it keep.
  bool counted = 5;
  // Why not, by name, when `counted` is false. Empty when it is true.
  string refusal_reason = 6;
}

// A ceiling an order attached to one meter, echoed back so a tenant can see its
// own. `limit` of 0 means NO CEILING -- it has never meant "may hold nothing".
message MeterCap {
  string meter = 1;
  uint64 limit = 2;
}

message ReadUsageRequest {
  string repository = 1;
}

message ReadUsageResponse {
  repeated MeterReading meters = 1;
  // When the reading was taken, Unix milliseconds — `monetize_product::Usage`
  // carries the same field and will not invent one.
  int64 measured_at_unix_ms = 2;
  // The ceilings in force for this repository, so a tenant can see its own.
  // Empty in a build with no paid state, and empty is not "zero everywhere".
  repeated MeterCap caps = 3;
}


// ─── JobService — the operator's view of the durable job queue ──────────
//
// stinky 01 §6.4 measured the hole this fills: `jera::jobs::JobStore::list`
// existed, holger wrote rows to it, and NOTHING exposed them -- "the ledger is
// written and never shown".
//
// READ-ONLY, exactly like `AuditService` and for the same reason: a write door
// onto the job ledger would be a way to launder what the server says it did.
// There is no Cancel here; cancelling a seal in flight is a separate decision
// with its own consequences and is not made by this service.
//
// ADMIN only, gated like `AuditService`. The rows name authenticated principals
// and the coordinates they moved.
//
// Mounted only by a build carrying the `jobs` feature. A build without it does
// not merely refuse -- the service is not registered, and the async flags on
// PromoteArtifact/Replicate refuse BY NAME.
service JobService {
  // One job, by id.
  rpc GetJob(GetJobRequest) returns (GetJobResponse);
  // The recent jobs, newest first.
  rpc ListJobs(ListJobsRequest) returns (ListJobsResponse);
}

// One row of the queue, as a reader sees it.
message JobRow {
  string job_id = 1;
  // `holger_promote` | `holger_replicate` | `holger_upload` | `holger_gate`.
  string kind = 2;
  // What it acts on, human-readable (`dev->prod:name:version`).
  string target = 3;
  // `queued` | `running` | `done` | `failed` | `interrupted`.
  //
  // `interrupted` is holger's own and is the point of the whole lane: a job that
  // was running when the process died is named, with a reason, instead of being
  // left `running` for ever (stinky 01 §6.2) or laundered into `failed`, which
  // would claim it was tried and refused.
  string status = 4;
  // The idempotency key -- two jobs with the same content id are the same work.
  string content_id = 5;
  // Terminal result pointer: a seal's content address, a replication's counts.
  string result_ref = 6;
  // Why it failed or was interrupted. Empty otherwise -- never a placeholder.
  string reason = 7;
  // The authenticated principal that asked.
  string requested_by = 8;
  int64 ts_start_micros = 9;
  int64 ts_end_micros = 10;
  int64 elapsed_ms = 11;
  // What it is doing in the admin view's words: a pull-through (`holger_fetch`)
  // or a mirror asset (`holger_mirror_asset`) reads `requested` -> `fetching` ->
  // `done` | `failed`; every other kind reads its status word. A `failed` stage
  // with `next_attempt_micros` set is a retry waiting (status `retry_wait`).
  string stage = 12;
  // Attempts made so far, and the ceiling (1 for the four original kinds).
  uint32 attempts = 13;
  uint32 max_attempts = 14;
  // When a `retry_wait` job runs again (µs since the epoch); 0 = not waiting.
  int64 next_attempt_micros = 15;
  // Pending-list priority from the consumer's admission class: 0 Full,
  // 1 Reduced, 2 Floor, 3 Queued. Lower runs first.
  uint32 priority = 16;
}

message GetJobRequest {
  string job_id = 1;
}

message GetJobResponse {
  // False when the ledger holds no such job -- the row below is then empty, and
  // "not found" is not spelled as an all-zero job.
  bool found = 1;
  JobRow job = 2;
}

message ListJobsRequest {
  // Filter by kind; empty = every kind.
  string kind = 1;
  // Cap the rows returned; 0 = every row the ledger holds.
  int32 limit = 2;
  // Filter by state: empty = all; `waiting` (queued, or failed with a retry
  // scheduled), `running`, `failed` (terminal failed or interrupted, AND
  // retry_wait — every row with a reason), `done`.
  string state = 3;
}

message ListJobsResponse {
  repeated JobRow jobs = 1;
}

// ─── The admin view (AdminService.AdminSnapshot) ───────────────────────────
//
// What an administrator is shown and nobody else: operational detail about
// the machine. Every field is MEASURED at the call; a figure that could not be
// measured carries its reason in an `error`/`problems` field, never a zero that
// reads as a measurement.

message AdminSnapshotRequest {
  // How many of the newest log lines to return; 0 = 200, capped at 2000.
  uint32 log_lines = 1;
}

message AdminSnapshotReply {
  // When this was taken, by the estate clock (Unix ms), and by whom.
  int64 taken_unix_ms = 1;
  string host = 2;
  string version = 3;
  uint64 uptime_seconds = 4;
  // "primary" | "twin".
  string role = 5;
  AdminDisks disks = 6;
  AdminTwin twin = 7;
  AdminGrowth growth = 8;
  AdminClock clock = 9;
  AdminPlugins plugins = 10;
  AdminAudit audit = 11;
  AdminLogs logs = 12;
  AdminPayments payments = 13;
  // The disk speed test over the data root (edda disk-snapshot `speed`), run
  // ONCE at start on its own thread. `measured` false = not run yet / skipped /
  // failed, with the reason: the console says N/A, never a zero.
  AdminDiskSpeed disk_speed = 14;
  // Every repository (monetize's TENANT for holger): what it holds on disk,
  // whether those bytes are a CACHE (a repository with an upstream: pull-through
  // or proxy), and its paid `disk_bytes` cap (the metered build's entitlement).
  repeated AdminTenant tenants = 15;
  // The redb job queue's depth, by status.
  AdminQueue queue = 16;
  // Every paired downstream as this upstream last heard from it: the rows of
  // holger.grow.v1 GrowService.Downstreams, field for field (the twins are
  // `twin.twins`, GrowService.Twins). The console's Sync view reads both.
  repeated AdminDownstreamRow downstreams = 17;
}

message AdminDiskSpeed {
  bool measured = 1;
  // The directory measured (the data root).
  string path = 2;
  // Sequential write + fsync, and the read back off the device, bytes/s.
  double seq_write_bps = 3;
  double seq_read_bps = 4;
  // direct (O_DIRECT) | dropped (the page cache dropped first)
  string read_how = 5;
  // The read was faster than any disk (> 5 GB/s): the cache answered, so the
  // read figure is not a disk speed.
  bool read_cached = 6;
  // 4 KiB overwrites, each fdatasync'd, per second.
  double sync_4k_ops_per_s = 7;
  double seconds = 8;
  int64 at_unix_ms = 9;
  // Why there is no figure (not run yet, skipped for free space, failed).
  string why = 10;
}

message AdminTenant {
  string repository = 1;
  // The repository has an upstream: what it holds is a cache.
  bool cache = 2;
  repeated string upstreams = 3;
  // `disk_bytes` as the meter counts it; `used_measured` false with `used_why`
  // when it could not be counted (a group owns none, no storage yet …).
  bool used_measured = 4;
  uint64 used_bytes = 5;
  string used_why = 6;
  // The paid cap (quota): `has_cap` false with `cap_why` when none is known.
  bool has_cap = 7;
  uint64 cap_bytes = 8;
  string cap_why = 9;
  // The entitlement's state (active | grace | suspended …) when one exists.
  string state = 10;
}

message AdminQueue {
  // False when this server carries no job queue (built or configured without it).
  bool present = 1;
  string why = 2;
  // Jobs not in a terminal state (queued, running, waiting to retry).
  uint64 open = 3;
  // status word → count, over the newest `scanned` jobs.
  map<string, uint64> by_status = 4;
  uint64 scanned = 5;
  uint32 workers = 6;
}

message AdminDisks {
  // Every storage mount (pseudo filesystems left out), with holger's labels
  // on the mount each labelled path sits on: `system` (/), `data` (the data
  // root), `storage:<repo>` (a repository's archive or files).
  repeated AdminDisk mounts = 1;
  repeated AdminBlockDevice block_devices = 2;
  // A labelled path that sits on no mount, with the reason.
  repeated AdminUnresolved unresolved = 3;
  // Why the mount table or /sys/block could not be read.
  repeated string problems = 4;
  // The rule the floor figures were computed with, in words — and whether
  // holger enforces it (it does not, today: the floor is advisory).
  string floor_rule = 5;
}

message AdminDisk {
  string source = 1;
  string mount_point = 2;
  string fstype = 3;
  bool read_only = 4;
  repeated string labels = 5;
  repeated string paths = 6;
  // statvfs(3), in bytes. All 0 with `error` set when it could not be taken.
  uint64 total_bytes = 7;
  uint64 free_bytes = 8;
  uint64 available_bytes = 9;
  uint64 used_bytes = 10;
  uint64 reserved_bytes = 11;
  uint64 inodes_total = 12;
  uint64 inodes_free = 13;
  uint64 block_size = 14;
  uint64 floor_bytes = 15;
  // available − floor.
  uint64 servable_bytes = 16;
  uint32 used_permille = 17;
  string error = 18;
}

message AdminPartition {
  string name = 1;
  uint64 size_bytes = 2;
}

message AdminBlockDevice {
  string name = 1;
  uint64 size_bytes = 2;
  bool rotational = 3;
  bool removable = 4;
  bool read_only = 5;
  repeated AdminPartition partitions = 6;
  // What holds it or one of its partitions (md127, dm-0).
  repeated string holders = 7;
  // What it is built from (an array's members).
  repeated string members = 8;
  string raid_level = 9;
}

message AdminUnresolved {
  string label = 1;
  string path = 2;
  string why = 3;
  // True for a repository's storage folder (`storage:<repo>`) that does not exist yet:
  // holger creates it at the first upload, so it is no fault, and `why` says that
  // (lane HOL-UI-FIX). False for every other path that sits on no mount.
  bool made_at_first_upload = 4;
}

message AdminTwin {
  // "primary" | "twin".
  string role = 1;
  // The primary a twin follows.
  string primary = 2;
  bool serving = 3;
  bool accepts_writes = 4;
  // A box awaiting its recharge serves nothing; the source it waits for.
  string awaiting_recharge_from = 5;
  // A twin: is it alive and whole — how it last followed, whether its last
  // cycle that reached the primary was whole, when the last whole one ended,
  // and the lag: now − that end, seconds (−1 = never whole).
  string follow = 6;
  bool complete = 7;
  string why = 8;
  int64 last_whole_unix = 9;
  int64 lag_seconds = 10;
  // The storage invariant's verdict warning (empty when none).
  string store_warning = 11;
  // The replication door on this box: refusals by name and admitted peers.
  uint64 door_refused = 12;
  string door_last_refusal = 13;
  map<string, uint64> door_admitted = 14;
  // The mTLS credentials this box holds (purpose "twin", "recharge").
  repeated AdminTwinCredential credentials = 15;
  // A primary: every enrolled twin, as its reports say.
  repeated AdminTwinRow twins = 16;
  string twins_error = 17;
}

message AdminTwinCredential {
  string purpose = 1;
  bool installed = 2;
  // The leaf's CN, the pinned issuer's CN.
  string principal = 3;
  string issuer = 4;
  int64 not_after_unix = 5;
  int64 days_left = 6;
  repeated string doors = 7;
  // Why it could not be read (a half-installed credential is an error).
  string error = 8;
}

message AdminTwinRow {
  string principal = 1;
  string address = 2;
  // ok | behind | stalled | silent — stalled: still reporting, the applied
  // position unchanged with the head ahead for longer than TWIN_STALLED_AFTER_MS
  string state = 3;
  int64 last_seen_unix_ms = 4;
  uint64 lag_entries = 5;
  uint64 disk_total_bytes = 6;
  uint64 disk_used_bytes = 7;
  uint32 fill_permille = 8;
  // The last synced point and today's pulls (holger.grow.v1 TwinRow 9, 10, 15, 16).
  uint64 applied_seq = 9;
  uint64 head_seq = 10;
  uint64 pulls_today = 11;
  int64 last_pull_unix = 12;
}

// One paired downstream (holger.grow.v1 DownstreamRow, field for field).
message AdminDownstreamRow {
  string name = 1;
  // `sha256:<64 hex>` of the paired client certificate.
  string fingerprint = 2;
  // ok | behind | stalled | silent — the worst repository's; `silent` when never heard.
  string state = 3;
  bool stalled = 4;
  // The largest repository lag.
  uint64 lag_entries = 5;
  int64 last_seen_unix_ms = 6;
  uint64 seen_ago_ms = 7;
  uint32 every_secs = 8;
  repeated AdminDownstreamRepo repositories = 9;
  // Today's pulls (UTC day, since this process started) and the last one.
  uint64 requests_today = 10;
  uint64 bytes_today = 11;
  int64 last_pull_unix = 12;
}

// One paired repository's sync point (holger.grow.v1 DownstreamRepoRow).
message AdminDownstreamRepo {
  string repository = 1;
  // ok | behind | stalled | silent — TwinRow.state's words and rule.
  string state = 2;
  uint64 lag_entries = 3;
  uint64 applied_seq = 4;
  uint64 head_seq = 5;
  // When the last complete cycle of this repository ended; 0 = never.
  int64 last_complete_unix_ms = 6;
}

message AdminGrowMember {
  uint32 index = 1;
  string device = 2;
  string partition = 3;
  uint64 disk_bytes = 4;
  uint64 set_bytes = 5;
  uint64 offered_bytes = 6;
  bool larger = 7;
}

message AdminGrowth {
  // False when this process holds no growth plane (a bare service).
  bool present = 1;
  string phase = 2;
  string why = 3;
  uint64 epoch = 4;
  bool serving = 5;
  bool engine_present = 6;
  bool hotplug = 7;
  uint64 in_flight = 8;
  int64 since_unix_ms = 9;
  string detail = 10;
  string set_uuid = 11;
  uint64 total_bytes = 12;
  repeated AdminGrowMember members = 13;
  string members_verdict = 14;
  // THE HISTORY: the boot stack's grow journal — its phase, the steps it
  // took in order, the members a partial growth left behind, where it lives.
  string journal_phase = 15;
  repeated string journal_steps = 16;
  repeated uint32 journal_partial_members = 17;
  string journal_detail = 18;
  string journal_path = 19;
  // The capacity ladder's verdict over this machine.
  SetGrowth capacity_growth = 20;
  string capacity_growth_reason = 21;
  string data_set = 22;
}

message AdminClockSource {
  string source = 1;
  bool answered = 2;
  int64 offset_ms = 3;
  string error = 4;
}

message AdminClockSync {
  // timer | boot | fed | …
  string trigger = 1;
  int64 at_unix_ms = 2;
  // stepped | slewed | already-right | apply-failed | no-quorum | …
  string outcome = 3;
  bool vouches = 4;
  int64 offset_ms = 5;
  string detail = 6;
  repeated AdminClockSource sources = 7;
}

message AdminClock {
  // Whether a verified answer from the front stands behind holger's clock.
  bool trusted = 1;
  // The correction held in-process (the machine's clock was not moved), ms.
  int64 owed_ms = 2;
  // The configuration in force, in words (or why it is refused).
  string config = 3;
  int64 estate_unix_ms = 4;
  int64 system_unix_ms = 5;
  // Absent (not synced yet) when `has_last` is false.
  bool has_last = 6;
  AdminClockSync last = 7;
  bool has_boot = 8;
  AdminClockSync boot = 9;
}

message AdminSelfRegistration {
  // False when this build or box does not register a plugin at all.
  bool active = 1;
  // The last line the registration said, and whether it was taken.
  string line = 2;
  bool registered = 3;
  bool warning = 4;
  int64 at_unix_ms = 5;
}

message AdminEntitlement {
  string repository = 1;
  // The fact's caps, `name=value`, as signed.
  repeated string caps = 2;
  // active | grace | suspended | … (monetize's State), when paid until (Unix
  // ms; 0 = open-ended), and where the fact came from (operator | payment:…).
  string state = 3;
  uint64 paid_until_unix_ms = 4;
  string source = 5;
}

message AdminPlugins {
  // What this boot loaded (ServerProfile's census, verbatim).
  repeated LoadedModule modules = 1;
  repeated string granted_capabilities = 2;
  uint32 meters = 3;
  // True when this is the metered build (it carries monetize's seam).
  bool metered_build = 4;
  // The metered build's entitlement store: configured with monetize's key?
  bool entitlements_configured = 5;
  repeated AdminEntitlement entitlements = 6;
  AdminSelfRegistration self_registration = 7;
}

message AdminAudit {
  // everything | aggregated | off
  string mode = 1;
  // "directory <path>" | "memory" | "none".
  string source = 2;
  uint64 segments = 3;
  uint64 total_rows = 4;
  uint64 total_bytes = 5;
  int64 first_ts_nanos = 6;
  int64 last_ts_nanos = 7;
  string error = 8;
}

message AdminLogs {
  // False when this process carries no installer log (not an installer-built box).
  bool present = 1;
  // The installer's log door: "install" (the installer is still at work) or
  // "log-door" (closed: the same listener serves only GET /install/log to the
  // installer's certificate).
  string door = 2;
  bool closed = 3;
  uint64 lines_total = 4;
  uint64 lines_dropped = 5;
  // The newest lines, oldest first.
  repeated string tail = 6;
}

message AdminPayments {
  // The Stripe webhook's last ACCEPTED delivery is kept by monetize on the
  // front, not by holger; 0 with `note` saying where it is not measured.
  int64 stripe_last_delivery_unix_ms = 1;
  string note = 2;
}

// ─── AdmissionService — who may take how much of the door ──────────────────
//
// The download/upload admission (`server/lib/src/admission.rs`) over the shared
// trait `edda_admit::Admit`: the policy (`pass-through` | `usage-share`), each
// consumer's usage over the window, and what `Admit::plan` answers for one.
// READ-ONLY and ADMIN only, like `JobService`: the rows name principals and
// paired peers (an anonymous consumer is only ever its per-day token label).
service AdmissionService {
  // `admit plan` for one consumer and one kind of work, now.
  rpc AdmitPlan(AdmitPlanRequest) returns (AdmitPlanResponse);
  // Every consumer the usage book holds, heaviest window first.
  rpc ListUsage(ListUsageRequest) returns (ListUsageResponse);
}

// The six units a box runs out of (`edda_admit::Cost`). Bytes except cpu_ms.
message CostRow {
  uint64 cpu_ms = 1;
  uint64 disk_read = 2;
  uint64 disk_write = 3;
  uint64 net_in = 4;
  uint64 net_out = 5;
  uint64 upstream_fetch = 6;
}

// One work kind's numbers for one consumer.
message WorkUsageRow {
  // `download` | `upload` | `pull_through` | `mirror_asset`.
  string work = 1;
  uint64 requests = 2;
  uint64 refused = 3;
  CostRow estimated = 4;
  CostRow actual = 5;
}

message UsageRow {
  // `principal:<sub>` | `peer:<name>` | `anon:<day>:<8 hex>`.
  string consumer = 1;
  // Actual cost over the policy's window, and today (UTC).
  CostRow window = 2;
  CostRow today = 3;
  repeated WorkUsageRow by_work = 4;
  // The class the last plan gave it (`full` | `reduced` | `floor` | `queued`,
  // empty before any plan), and the plan's sentence.
  string class = 5;
  string why = 6;
  uint32 in_flight = 7;
}

// The policy's learnt estimate for one work kind (`Admit::estimates`).
message EstimateRow {
  string kind = 1;
  uint64 samples = 2;
  uint64 hinted = 3;
  CostRow mean = 4;
  // Cost of one GB of hinted size; absent before any hinted sample.
  optional CostRow per_gb = 5;
}

message AdmitPlanRequest {
  // `principal:<sub>` | `peer:<name>` | `anonymous` (a probe token for today).
  string consumer = 1;
  // `download` (default) | `upload` | `pull_through` | `mirror_asset`.
  string work = 2;
  // Size hint in bytes; 0 = none.
  uint64 size_hint = 3;
}

message AdmitPlanResponse {
  string consumer = 1;
  string work = 2;
  string class = 3;
  CostRow estimate = 4;
  string why = 5;
  // Bytes left in the window; absent = unlimited.
  optional uint64 budget_left = 6;
  // Concurrent requests this class gets at the door; absent = no share
  // enforced (no `admission:` block).
  optional uint32 share = 7;
  string policy = 8;
  repeated EstimateRow estimates = 9;
  // The consumer's usage row (absent when the book has none).
  optional UsageRow usage = 10;
}

message ListUsageRequest {
  // Cap the rows; 0 = all.
  int32 limit = 1;
}

message ListUsageResponse {
  string policy = 1;
  repeated UsageRow consumers = 2;
  // Requests in flight through the door right now, all consumers.
  uint32 in_flight_total = 3;
  // The door's ceiling and the per-class shares; absent = none enforced.
  optional uint32 max_in_flight = 4;
  optional uint32 share_full = 5;
  optional uint32 share_reduced = 6;
  optional uint32 share_floor = 7;
  // Whether proxy pull-through runs on the job queue.
  bool pull_through_queue = 8;
}

// ── the security page (lane HOL-SEC, 2026-10-27) ──────────────────────────────

message SecuritySummaryRequest {}

message SecuritySummaryReply {
  // One line over the page, in the server's words.
  string headline = 1;
  // The sections, in the order the page shows them (keys: chain, sources, scanners,
  // approvals, held, access, sync, events).
  repeated SecuritySection sections = 2;
}

message SecuritySection {
  // A stable key (`chain`, `scanners`, …); the console keys its markup on it, never its words.
  string key = 1;
  string title = 2;
  // One or two sentences under the title.
  string summary = 3;
  // What to say when there are no rows.
  string empty = 4;
  repeated SecurityRow rows = 5;
}

message SecurityRow {
  // What the row is about: a step, a source, a scanner, an artifact, a repository, an event.
  string name = 1;
  // One short word for its state (`required`, `quarantined`, `public`, …).
  string state = 2;
  // `good` | `warn` | `bad` | `info`: how the state reads.
  string tone = 3;
  // The rest, in plain words: why, what it checks, who, when.
  string detail = 4;
  // The artifact the row is about, when it is one (the console links its security view).
  ArtifactId artifact = 5;
}

// ── the estate board (lane E3, 2026-10-19; Gunnar's Admin.Topology pattern) ──

message TopologyRequest {}

message TopologyReply {
  // This box first, then the others in a stable order: front, primary, twins,
  // downstreams, each by name.
  repeated TopologyBox boxes = 1;
  // This server's own clock when it answered (Unix ms).
  int64 server_unix_ms = 2;
  // The silence bound every `state` below was judged against, in ms.
  uint64 silent_after_ms = 3;
  // The estate's name from the pairing feed; empty on a holger nobody paired.
  string estate = 4;
  // The links between the boxes: front → primary, primary → twin, primary →
  // each downstream. Each carries the state of the box it leads INTO.
  repeated TopologyLink links = 5;
  // RETIRED (Holger v2.0 H2): always empty. The server draws nothing; holger-ui
  // draws the board from `boxes` and `links`. Kept so the tag is never reused.
  string svg = 6;
  // **Every consumer this box serves** (lane A13): named and anonymous, as the
  // door admission's book counts them. Unset = this build keeps no book.
  TopologyConsumers consumers = 7;
  // **The board in one line, holger's words** (lane A13): the line drawn under
  // the title — how many upstreams and downstreams, and every consumer — for a
  // console's text alternative, rendered as it came.
  string summary = 8;
}

// **Who consumes this box** (lane A13, 2026-10-23), from the door admission's
// usage book: every request asks it once, whatever the policy.
message TopologyConsumers {
  // False when this build keeps no book: then the numbers below are not counts.
  bool counted = 1;
  // Authenticated principals with a request in the window (durable: the book's
  // ledger rows survive a restart).
  uint64 principals = 2;
  // Paired downstream holgers with a request in the window.
  uint64 peers = 3;
  // Anonymous clients TODAY (UTC day): each is a per-day keyed hash that dies
  // at midnight, so there is no window for them, by design. Since this process
  // started when it started today.
  uint64 anonymous_today = 4;
  // The window the named counts cover, in days.
  uint32 window_days = 5;
}

// **One box.** Every field is evidence this holger holds, never a guess: a box it
// has no channel to read stays `unknown`, which is a different fact from `silent`.
message TopologyBox {
  // A short name: the pairing name (`front`, `main`, `twin`), a twin's principal,
  // a downstream's pairing name, or this box's own pairing name.
  string name = 1;
  // `front` | `primary` | `twin` | `downstream` | `upstream` (lane A13: a
  // registry this box proxies, or a holger it mirrors).
  string role = 2;
  // Where the evidence says it is (`host` or `host:port`); empty when unknown.
  string address = 3;
  // `up` (it answered, or reported, within the bound) · `behind` (a twin or a
  // downstream that reports and does not catch up) · `silent` (expected, and not
  // heard within the bound, or never — and a box HELD for its recharge, which
  // serves nothing) · `unknown` (nothing here can ask it).
  string state = 4;
  // One sentence for a human: how holger knows, and since when.
  string why = 5;
  // True for the box that answered this call.
  bool this_box = 6;
  // Wall clock of the newest evidence (Unix ms); 0 = none.
  int64 last_seen_unix_ms = 7;
  // `server_unix_ms - last_seen_unix_ms` at answer time; 0 when never seen.
  uint64 seen_ago_ms = 8;
  // The box's data volume, as measured (this box: statvfs of its data root; a
  // twin: its own, carried on its report). False = not measured, and then the
  // two numbers are 0 and must not be read.
  bool disk_measured = 9;
  uint64 disk_total_bytes = 10;
  uint64 disk_used_bytes = 11;
  // Where the evidence came from: `self`, `pairing`, `report` (a twin's
  // TwinReport, or on a twin its own follow of the primary), `downstream`.
  repeated string sources = 12;
  // The WireGuard overlay address (wg0) and the utility address, from the pairing
  // feed; empty when not fed.
  string overlay_address = 13;
  string utility_address = 14;
  // **The state in a few words, holger's**: `answering`, `following`, `behind 120
  // entries`, `silent for 4 min`, `never heard`, `not asked`, `held for its
  // recharge from twin`, `fenced`. Coarse on purpose (whole minutes once past
  // one), so the answer only changes when the state does.
  string headline = 15;
  // A twin or a downstream: the twin's own word (TwinRow.state: ok · behind ·
  // stalled · silent, M2's `twin_state`) and its lag in log entries (M2's
  // `lag_entries`); empty / 0 for any other box.
  string twin_state = 16;
  uint64 lag_entries = 17;
  // True when the box is HELD: a main awaiting its recharge from its twin (M3's
  // boot hold), or a fenced primary. Drawn dark, with the word `held`.
  bool held = 18;
  // When this box's mutual-TLS credential to its peer expires (Unix s; the
  // twin's `twin` credential on a twin); 0 = none held or not readable here.
  int64 cert_not_after_unix = 19;
  // When the pairing itself expires (the estate's key, lane E2); 0 = not known.
  int64 pairing_not_after_unix = 20;  // **Packages fetched** (lane A13): a downstream — artifacts delivered to it,
  // ever (durable) and today; an upstream — artifacts this box took from it
  // since this boot (`fetched_today` unused). 0 for any other box.
  uint64 fetched_total = 21;
  uint64 fetched_today = 22;
  // An upstream only: `proxy` (a repository another one names in
  // `ron_upstreams`: crates.io, PyPI, npm, a remote harbour) or `mirror` (a
  // `mirror.ron_sources` entry: another holger). Empty for any other box.
  string upstream_kind = 23;
  // An upstream only: the local repositories it feeds.
  repeated string feeds = 24;
  // A downstream only: how it reaches this holger, `front` (the front's public
  // door) or `wireguard` (the front's downstream interface; lane HOL-WGDS).
  string via = 25;
}

message TopologyLink {
  // Box names, as in TopologyBox.name.
  string from = 1;
  string to = 2;
  // `serves` (front → primary) · `follows` (primary → twin) · `pulls` (primary →
  // downstream) · `feeds` (upstream → this box, lane A13).
  string kind = 3;
  // The state of the box the link leads into (TopologyBox.state).
  string state = 4;
}

// ── the box's import doors (lane W4, 2026-10-19) ──

// One piece of a streamed bundle. `first_import` is read from every chunk: any
// chunk that sets it establishes an absent anti-rollback floor with this bundle.
message ImportBundleChunk {
  bytes data = 1;
  bool first_import = 2;
}

message ImportIntelBundleReply {
  // `new` or `idempotent-reimport`.
  string outcome = 1;
  string bundle_id = 2;
  uint64 sequence = 3;
  // YYYY-MM-DD of the advisory snapshot.
  string snapshot_at = 4;
  uint64 advisories = 5;
  string message = 6;
}

// One artifact a bundle import landed.
message ImportedArtifactRow {
  string artifact = 1;
  string sha256 = 2;
  // AVAILABLE.
  string state = 3;
  string repository = 4;
  // `landed` or `already-available`.
  string outcome = 5;
}

message ImportBundleReply {
  // `new` or `idempotent-reimport`.
  string outcome = 1;
  string bundle_id = 2;
  uint64 sequence = 3;
  // `airgap-export` or `update-bundle`.
  string kind = 4;
  repeated ImportedArtifactRow artifacts = 5;
  string message = 6;
}