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

nordisk/edda

crates/nornir-wire/proto/nornir.proto

// nornir.proto — single source of truth for `nornir-server`'s wire surface.
//
// Generated bindings produced by build.rs via tonic-build (server + client).
// Every existing CLI / MCP capability has a one-for-one RPC here so that
//   - `nornir-server` is the sole holder of the iceberg warehouse lock
//   - `nornir-mcp` becomes a thin tonic client (Phase 8)
//   - `urdr-threads` (viz) likewise becomes a thin tonic client (followup)
//
// Auth: every RPC except Health.Ping must carry
//   `authorization: Bearer <NORNIR_SERVER_TOKEN>`
// in its grpc metadata. Enforced by a tonic interceptor.

syntax = "proto3";

package nornir.v1;

// -----------------------------------------------------------------------
// Common
// -----------------------------------------------------------------------

message Empty {}

// (repo, k=v, …) — generic key-value bag used by a few responses that
// don't merit their own bespoke message (e.g. Bench metric maps).
message KV {
  string key = 1;
  string value = 2;
}

message KVF {
  string key = 1;
  double value = 2;
}

// -----------------------------------------------------------------------
// Health — the only unauthenticated RPC.
// -----------------------------------------------------------------------

service Health {
  rpc Ping (Empty) returns (PingResponse);
}

message PingResponse {
  string status = 1;          // always "ok"
  string version = 2;         // CARGO_PKG_VERSION
  uint32 repo_count = 3;      // # of [repo.*] entries currently loaded
}

// -----------------------------------------------------------------------
// Repos — what was POST /repos/* on the axum server.
// -----------------------------------------------------------------------

service Repos {
  rpc List       (Empty)                returns (RepoList);
  rpc Register   (RegisterRepoRequest)  returns (RegisterRepoResponse);
  rpc Unregister (UnregisterRepoRequest) returns (UnregisterRepoResponse);
  rpc Reload     (Empty)                returns (ReloadResponse);
}

message RepoSummary {
  string name = 1;
  string remote = 2;
  string history = 3;
  string readme = 4;
}

message RepoList {
  repeated RepoSummary repos = 1;
}

message RegisterRepoRequest {
  string name    = 1;
  string remote  = 2;  // empty = unset
  string history = 3;
  string readme  = 4;
  string path    = 5;
}

message RegisterRepoResponse {
  string name         = 1;
  string config_path  = 2;
  uint32 repo_count   = 3;
}

message UnregisterRepoRequest {
  string name = 1;
}

message UnregisterRepoResponse {
  string name         = 1;
  uint32 repo_count   = 2;
}

message ReloadResponse {
  string config_path  = 1;
  uint32 repo_count   = 2;
}

// -----------------------------------------------------------------------
// Guard — chmod-based forbidden-path enforcement.
// -----------------------------------------------------------------------

service Guard {
  rpc Status  (Empty) returns (GuardReport);
  rpc Apply   (Empty) returns (GuardReport);
  rpc Release (Empty) returns (GuardReport);
  // Tamper-evidence read: verify the tree against the recorded manifest.
  rpc Verify  (Empty) returns (GuardVerifyResponse);
}

message GuardPath {
  string path     = 1;
  bool   exists   = 2;
  bool   writable = 3;
  bool   changed  = 4;
}

message GuardReport {
  repeated GuardPath paths = 1;
  uint32 changed_count = 2;
}

// guard_verify result. `paths_json` carries the serialized [VerifyStatus] list
// (the structured per-path Drift enum is a read-only diagnostic, not modeled in
// proto — the client re-emits it verbatim).
message GuardVerifyResponse {
  bool   intact      = 1;
  string recorded_at = 2;
  string paths_json  = 3;
}

// -----------------------------------------------------------------------
// Bench
// -----------------------------------------------------------------------

service Bench {
  rpc History (RepoOnly) returns (BenchHistoryResponse);
  // Append a freshly-computed BenchRun. The CLI runs benches locally
  // (the only place that can — they need the dev's hardware) and ships
  // the result over for the server to persist into iceberg.
  rpc Submit  (SubmitBenchRequest) returns (SubmitBenchResponse);
}

message SubmitBenchRequest {
  string repo = 1;
  BenchRun run = 2;
}

message SubmitBenchResponse {
  string run_id = 1;
}

message RepoOnly {
  string repo = 1;
}

message BenchResult {
  string name = 1;
  repeated KVF metrics = 2;
}

message BenchRun {
  string date         = 1;
  string timestamp    = 2; // RFC3339, may be empty
  string version      = 3;
  string machine      = 4;
  uint32 cores        = 5;
  repeated BenchResult results = 6;
  repeated TestOutcome tests   = 7;
  // The run's identity, minted by the PRODUCER (uuid v7) when the run was
  // measured — NOT by the server that stores it. Empty on a receipt written
  // before 2026-09-20, which is what tells the store "this one cannot be
  // deduplicated". Carrying it over the wire is what makes `Bench.Submit`
  // idempotent; without it the remote path would still double-write.
  string id           = 8;
  // `nornir_bench::machine::Machine` as JSON — what the BOX is (hostname, cpu
  // model, physical cores, logical threads, RAM, GPU, kernel, OS). A JSON
  // string rather than a nested message so the block can gain a field without
  // a wire change on both sides; empty when the producer did not look.
  string machine_facts_json = 9;
}

message TestOutcome {
  string name        = 1;
  bool   passed      = 2;
  double duration_ms = 3; // 0 when absent
  bool   has_duration = 4; // distinguishes "0 ms" from "not recorded"
  string message     = 5; // empty when absent
}

message BenchHistoryResponse {
  string repo = 1;
  repeated BenchRun runs = 2;
}

// -----------------------------------------------------------------------
// Telemetry: client-side activity submitted to the server's warehouse (the
// server holds the redb lock; thin clients can't write it directly). A thin
// `nornir-mcp` submits its per-call telemetry here so the served workspace's
// `mcp_requests` table — and the viz 📞 MCP tab — show real usage. The
// `nornir-workspace` header selects the workspace.
// -----------------------------------------------------------------------
service Telemetry {
  rpc SubmitMcpCalls    (SubmitMcpCallsRequest)    returns (SubmitMcpCallsResponse);
  // Client-run bake-off / test results submitted to the served workspace's
  // warehouse (the server holds the lock) → the viz 🏆 Leaderboard / 🧪 Test
  // tabs fill on the live server. Same pattern as Bench.Submit.
  rpc SubmitBakeoff     (SubmitBakeoffRequest)     returns (SubmitCountResponse);
  rpc SubmitTestResults (SubmitTestResultsRequest) returns (SubmitCountResponse);
  // Client-run release-op DAG rows submitted to the served workspace's
  // `release_events` (the server holds the lock) → the viz 🚀 Release tab fills
  // on the live server. The fat-CLI `nornir release run` executes on the dev's
  // hardware, so its events are minted locally and shipped here. Same pattern
  // as SubmitBakeoff / SubmitTestResults.
  rpc SubmitReleaseEvents (SubmitReleaseEventsRequest) returns (SubmitCountResponse);
  // Thin-client job-ledger upsert: a fat op runs on the client's hardware but,
  // when pointed at a server (no local FS access to its jobs.redb), submits each
  // lifecycle record here so the server applies it to its `jobs.redb`. `jobs_json`
  // is a JSON array of `jobs::JobRecord`. Mirrors SubmitReleaseEvents. Backs the
  // 🧬 nornir tab's job list for ops triggered from a thin client.
  rpc SubmitJobs (SubmitJobsRequest) returns (SubmitCountResponse);
  // Client-run BUILD provenance + build-info submitted to the served workspace's
  // warehouse (the server holds the lock) → the `build_provenance` + `build_info`
  // tables fill on the live server, surfacing natively in the viz warehouse deck.
  // A dwarves/nornir build executes on the client's hardware and mints these
  // records there; this is how they reach the locked server warehouse (previously
  // build-info rode `test_results` as an interim). Same pattern as SubmitBakeoff.
  //
  // SubmitBuildProvenance ships each full `BuildProvenance` as JSON (like
  // SubmitJobs) so the record can gain fields without a proto bump; the server
  // reconstructs it and appends one `build_provenance` ledger row per record.
  rpc SubmitBuildProvenance (SubmitBuildProvenanceRequest) returns (SubmitCountResponse);
  // SubmitBuildInfo ships the exploded §f3 rows (manifest + feed) typed 1:1.
  rpc SubmitBuildInfo (SubmitBuildInfoRequest) returns (SubmitCountResponse);
  // A forge's Runa step files what it MEASURED — a test step's per-test
  // outcomes, a bench run, a coverage export — into the served workspace's
  // warehouse (`test_outcomes`; `bench_runs`/`bench_results`; `coverage`/
  // `coverage_fn`; a traced run → `fn_trace_events`/`fn_trace_runs`; a
  // profile → `fn_profile`), keyed by (repo, commit): the facts the Mímir lenses
  // `weak_tests`, `bench_gaps` and `change_risk` and the 3D map's replay read. Idempotent on `run_id`:
  // a filing whose run is already in its table is accepted as 0 rows.
  rpc SubmitStepFeed (SubmitStepFeedRequest) returns (SubmitCountResponse);
}

// One test case a Runa test step ran.
message StepTestOutcomePb {
  string name         = 1; // its path inside its crate (`cart::tests::adds_up`)
  string krate        = 2; // the crate it lives in
  string status       = 3; // pass | fail | ignored
  double duration_ms  = 4;
  bool   has_duration = 5; // distinguishes "0 ms" from "not recorded"
  string message      = 6; // the failure line; empty when none
}
// What one Runa step measured, for one (repo, commit). ONE payload is set, by `kind`.
message SubmitStepFeedRequest {
  string repo          = 1; // the workspace member the checkout is
  string commit        = 2; // the checkout's HEAD when the step ran
  string run_id        = 3; // minted by the producer, the same for a retried filing
  int64  ts_micros     = 4;
  string kind          = 5; // tests | bench | coverage | trace | profile
  repeated StepTestOutcomePb tests = 6;
  string bench_json    = 7; // kind=bench: one `nornir_bench::BenchRun` as JSON
  string coverage_json = 8; // kind=coverage: one `nornir_coverage::CoverageReport` as JSON
  string source        = 9; // where it was measured (`runa <pipeline> <run> #<seq> <step>`)
  // An `only: Affected` step: what the Mímir graph selected, as JSON — `{mode,
  // baseline, why, selected, skipped: [runner name], changed: [{file, line,
  // end_line, fqn}]}`. Empty = every test ran (an `All` step). kind=tests: each
  // skipped name is filed as a `skipped` case; kind=coverage: the run is merged
  // with the repo's previous coverage for every function that did not change.
  string selection_json = 10;
  // kind=trace: one self-standing chunk of a traced run — `nornir_trace::wire::ChunkFiling`
  // as JSON (its functions, chunk n of m, the run's facts) + its events (`NTR1` varint stream);
  // kind=profile: one `nornir_trace::wire::ProfileFiling` as JSON, no events.
  string trace_json    = 11;
  bytes  trace_events  = 12;
}
// A JSON array of `jobs::JobRecord` to upsert into the served workspace's job
// ledger. JSON (not a typed Pb) so the record can gain fields without a proto
// bump — same JSON contract `Viz.Jobs` returns.
message SubmitJobsRequest { string jobs_json = 1; }
message McpCallPb {
  int64  ts_micros  = 1;
  string tool       = 2;
  string status     = 3;  // "ok" | "err"
  int64  latency_ms = 4;
}
message SubmitMcpCallsRequest { repeated McpCallPb calls = 1; }
message SubmitMcpCallsResponse { uint32 accepted = 1; }
message SubmitCountResponse { uint32 accepted = 1; }

message AgentModelRunPb {
  string run_id         = 1;
  int64  ts_micros      = 2;
  string agent          = 3;
  string model          = 4;
  string prompt_id      = 5;
  string prompt         = 6;
  string output         = 7;
  double latency_ms     = 8;
  int64  tokens_in      = 9;
  int64  tokens_out     = 10;
  double tokens_per_s   = 11;
  double score          = 12;
  bool   ok             = 13;
  string error          = 14;  // "" = none
  double cost_usd       = 15;
  int64  mcp_tool_calls = 16;
}
message SubmitBakeoffRequest { repeated AgentModelRunPb runs = 1; }

message TestResultPb {
  string run_id      = 1;
  string repo        = 2;
  string suite       = 3;
  string test_name   = 4;
  string status      = 5;
  double duration_ms = 6;
  int64  ts_micros   = 7;
  string message     = 8;
  string aspect      = 9;
  double metric      = 10;
}
message SubmitTestResultsRequest { repeated TestResultPb rows = 1; }

message ReleaseEventPb {
  string run_id              = 1;
  int64  seq                 = 2;
  int64  ts_micros           = 3;
  string component           = 4;
  string repo                = 5;
  string op                  = 6;
  string phase               = 7;
  string status              = 8;
  string detail              = 9;
  // depends_on is Option<Vec<String>>: `has_depends_on` distinguishes a real
  // (possibly empty) list from None (a root op with no upstream).
  repeated string depends_on = 10;
  bool   has_depends_on      = 11;
  // elapsed_ms is Option<i64>: only `phase = end` rows carry a value.
  int64  elapsed_ms          = 12;
  bool   has_elapsed_ms      = 13;
}
message SubmitReleaseEventsRequest { repeated ReleaseEventPb rows = 1; }

// Each element is one `BuildProvenance::to_json()` (JSON, not a typed Pb, so the
// record can gain fields without a proto bump — same contract as SubmitJobs).
message SubmitBuildProvenanceRequest { repeated string records_json = 1; }

// One §f3 build-info row (manifest or feed). Columns map 1:1 to the `build_info`
// table / the design's §f3 Arrow schema. `blob` is the only nullable column:
// `has_blob` distinguishes None (SQL NULL) from an empty inline payload, the same
// proto-3 presence trick SubmitReleaseEvents uses for its Options.
message BuildInfoRowPb {
  string build_id   = 1;
  string step_id    = 2;
  int64  ts_micros  = 3;
  string kind       = 4;  // provenance | dep | artifact | log | metric | step
  string level      = 5;
  string name       = 6;
  string value      = 7;
  string content_id = 8;
  bytes  blob       = 9;
  bool   has_blob   = 10;
}
message SubmitBuildInfoRequest { repeated BuildInfoRowPb rows = 1; }

// -----------------------------------------------------------------------
// Release: gates, order, and the live progress stream.
// -----------------------------------------------------------------------

service Release {
  rpc GatePathPatches (RepoOnly) returns (GateResult);
  rpc GateNexusFloor  (RepoOnly) returns (GateResult);
  rpc GateNoRegression(RepoOnly) returns (GateResult);
  rpc GateDocsFresh   (RepoOnly) returns (GateResult);
  rpc GateAll         (RepoOnly) returns (GateAllResult);
  rpc Order           (OrderRequest) returns (OrderResponse);
  // Regression time-bisect: last-good → first-bad boundary + suspect range.
  rpc Trace           (TraceQuery)   returns (JsonResponse);

  // Server-streaming. Replaces the SSE endpoint. The server tails the
  // active release-run-*.events.ndjson and emits one ProgressEvent per
  // line; the stream closes on RunEnd.
  rpc Progress (Empty) returns (stream ProgressEvent);
}

message GateResult {
  string gate    = 1;
  string status  = 2;          // "pass" | "fail"
  string message = 3;          // empty on pass
  string version = 4;          // optional
  double max_drop_pct = 5;     // optional, set by no_regression
}

message GateFailure {
  string name  = 1;
  string error = 2;
}

message GateAllResult {
  string repo = 1;
  repeated string passed = 2;
  repeated GateFailure failed = 3;
}

message OrderRequest {
  string workspace = 1;        // default "workspace_holger"
}

message TraceQuery {
  string repo      = 1;
  string workspace = 2;        // empty = every workspace
}

message OrderResponse {
  repeated string order = 1;
  string source = 2;           // "iceberg" | "fallback"
  string snapshot_id = 3;      // empty when source=fallback
}

// Discriminated union for the live release-run event log.
message ProgressEvent {
  oneof kind {
    RunStart    run_start    = 1;
    RepoStart   repo_start   = 2;
    PhaseStart  phase_start  = 3;
    PhaseEnd    phase_end    = 4;
    BinaryStart binary_start = 5;
    TestPass    test_pass    = 6;
    TestFail    test_fail    = 7;
    BinaryDone  binary_done  = 8;
    RepoEnd     repo_end     = 9;
    RunEnd      run_end      = 10;
  }
}
message RunStart    { string run_id = 1; string workspace = 2; }
message RepoStart   { string repo = 1; string sha = 2; }
message PhaseStart  { string repo = 1; string phase = 2; }
message PhaseEnd    { string repo = 1; string phase = 2; bool ok = 3; uint64 duration_ms = 4; }
message BinaryStart { string repo = 1; string binary = 2; }
message TestPass    { string repo = 1; string binary = 2; string name = 3; }
message TestFail    { string repo = 1; string binary = 2; string name = 3; }
message BinaryDone  { string repo = 1; string binary = 2; uint32 passed = 3; uint32 failed = 4; }
message RepoEnd     { string repo = 1; bool ok = 2; }
message RunEnd      { string run_id = 1; bool ok = 2; }

// -----------------------------------------------------------------------
// Search / Index
// -----------------------------------------------------------------------

service Search {
  rpc Query (SearchRequest) returns (SearchResponse);
}

message SearchRequest {
  string query  = 1;
  string corpus = 2;  // optional, e.g. "docs"|"code"|"bench_history"|...
  string repo   = 3;  // optional
  uint32 limit  = 4;  // 0 = default (20)
}

message SearchHit {
  string id       = 1;
  string corpus   = 2;
  string repo     = 3;
  string path     = 4;
  float  score    = 5;
  string snippet  = 6;
  string title    = 7;
}

message SearchResponse {
  repeated SearchHit hits = 1;
}

service Index {
  rpc Stats          (Empty) returns (IndexStatsResponse);
  rpc Snapshot       (SnapshotRequest) returns (SnapshotResponse);
  // Client-streaming upload: CLI builds the tantivy index locally
  // (heavy CPU, lots of small files) and streams the blobs up to the
  // server, which writes them into iceberg under its single-writer
  // lock. First frame must carry `meta`; remaining frames are pure
  // `chunk` bytes. The server emits one ack at end-of-stream.
  rpc UploadSnapshot (stream IndexBlobFrame) returns (SnapshotResponse);
}

message IndexStatsResponse {
  uint64 total = 1;
  repeated KV by_corpus = 2;   // value parses as u64; cheap to print
}

message SnapshotRequest {
  string repo      = 1;        // default "_workspace"
  string workspace = 2;        // default "workspace_holger"
  string git_sha   = 3;        // required
  string branch    = 4;        // default "unknown"
}

message SnapshotResponse {
  string snapshot_id  = 1;
  string workspace    = 2;
  string repo         = 3;
  string git_sha      = 4;
  string branch       = 5;
  string schema_hash  = 6;
  uint64 blob_count   = 7;
  uint64 total_bytes  = 8;
}

message IndexBlobMeta {
  string workspace = 1;
  string repo      = 2;
  string git_sha   = 3;
  string branch    = 4;
}

message IndexBlobFile {
  string rel_path  = 1;     // path under the snapshot root
  uint64 size_bytes = 2;    // declared size, server validates
}

// One frame in an UploadSnapshot stream. The first frame MUST carry
// `meta`. After that, frames alternate `file` (start of a new file)
// followed by zero or more `chunk` frames carrying its bytes.
message IndexBlobFrame {
  oneof body {
    IndexBlobMeta meta  = 1;
    IndexBlobFile file  = 2;
    bytes         chunk = 3;
  }
}

// -----------------------------------------------------------------------
// Introspect (DWARF)
// -----------------------------------------------------------------------

service Introspect {
  rpc Symbols      (BinaryOnly)        returns (SymbolList);
  rpc SymbolLookup (SymbolLookupRequest) returns (SymbolList);
  rpc DefinedIn    (DefinedInRequest)  returns (SymbolList);
  rpc Callers      (CallQuery)         returns (NameList);
  rpc Callees      (CallQuery)         returns (NameList);
  rpc PathBetween  (PathBetweenRequest) returns (NameList);
}

message BinaryOnly { string binary = 1; }
message SymbolLookupRequest {
  string binary  = 1;
  string pattern = 2;
  uint32 limit   = 3;   // 0 = 50 default
}
message DefinedInRequest {
  string binary = 1;
  string suffix = 2;
}
message CallQuery {
  string binary = 1;
  string name   = 2;
}
message PathBetweenRequest {
  string binary = 1;
  string from   = 2;
  string to     = 3;
}

message Symbol {
  string name             = 1;
  string name_demangled   = 2;
  string name_mangled     = 3;
  string file             = 4;
  uint32 line             = 5;
  uint64 size_bytes       = 6;
  string krate            = 7;
}

message SymbolList { repeated Symbol symbols = 1; }
message NameList   { repeated string names   = 1; }

// -----------------------------------------------------------------------
// Knowledge — read-back queries over the persisted syn symbol/call graph
// (knowledge::scan --persist → iceberg). The pure-Rust counterpart to
// Introspect: no compiled binary required, always reads the latest
// snapshot for `repo`.
// -----------------------------------------------------------------------

service Knowledge {
  rpc SymbolLookup (KnowledgeSymbolQuery)   returns (KnowledgeSymbols);
  rpc DefinedIn    (KnowledgeSymbolQuery)   returns (KnowledgeSymbols);
  rpc Callers      (KnowledgeCallQuery)     returns (KnowledgeCalls);
  rpc Callees      (KnowledgeCallQuery)     returns (KnowledgeCalls);
  rpc CallPath     (KnowledgeCallPathQuery) returns (NameList);
}

message KnowledgeSymbolQuery {
  string repo  = 1;
  string arg   = 2;   // name substring (SymbolLookup) | file suffix (DefinedIn)
  uint32 limit = 3;   // 0 = default
}

message KnowledgeCallQuery {
  string repo  = 1;
  string name  = 2;   // function name (callee for Callers, caller for Callees)
  uint32 limit = 3;   // 0 = default
}

// Shortest call chain from `from` to `to` over call_edges (BFS, identifier
// granularity — last path segment). Returns NameList of idents along the
// path; empty when unreachable.
message KnowledgeCallPathQuery {
  string repo = 1;
  string from = 2;
  string to   = 3;
}

message KnowledgeSymbol {
  string crate_name  = 1;
  string module_path = 2;
  string item_kind   = 3;
  string item_name   = 4;
  string visibility  = 5;
  string file        = 6;
  uint32 line        = 7;
  uint32 doc_lines   = 8;
  string signature   = 9;   // empty when none
}

message KnowledgeSymbols { repeated KnowledgeSymbol symbols = 1; }

message KnowledgeCall {
  string crate_name   = 1;
  string caller_path  = 2;
  string callee_ident = 3;
  string call_kind    = 4;
  string file         = 5;
  uint32 line         = 6;
}

message KnowledgeCalls { repeated KnowledgeCall calls = 1; }

// -----------------------------------------------------------------------
// Docs
// -----------------------------------------------------------------------

service Docs {
  rpc Init    (RepoOnly)              returns (DocsResponse);
  rpc Render  (RepoOnly)              returns (DocsResponse);
  rpc Check   (RepoOnly)              returns (DocsResponse);
  rpc History (DocsHistoryRequest)    returns (DocsHistoryResponse);
  // Render + typst-export the assembled README (Export) or the whole doc set
  // (Book) for <repo>, write the artifact under <repo>/docs/, and historize it
  // in the iceberg doc_exports table. The server must be built with the
  // `docs-export` feature (typst); otherwise these return UNIMPLEMENTED.
  rpc Export  (DocsExportRequest)     returns (DocsExportResponse);
  rpc Book    (DocsExportRequest)     returns (DocsExportResponse);
}

message DocsExportRequest {
  string repo   = 1;
  string format = 2;   // pdf | html | md, default pdf
}

message DocsExportResponse {
  string repo      = 1;
  string format    = 2;
  uint64 bytes     = 3;
  string out       = 4;   // artifact path written on the server
  string sha256    = 5;
  string git_sha   = 6;
  string export_id = 7;
  repeated string sources = 8;  // book only (the docs folded in); empty for export
}

message DocsResponse {
  string repo    = 1;
  string status  = 2;          // "ok" | "drift" | …
  repeated string artifacts = 3;
  string detail  = 4;
}

message DocsHistoryRequest {
  string repo    = 1;
  string doc     = 2;   // optional, e.g. "README"
  string version = 3;   // optional, e.g. "0.1.0"
  string format  = 4;   // optional: pdf|html|md
  uint32 limit   = 5;   // 0 = 50 default
}

message DocExport {
  string doc          = 1;
  string version      = 2;
  string format       = 3;
  string path         = 4;
  string exported_at  = 5;
  uint64 size_bytes   = 6;
}

message DocsHistoryResponse {
  repeated DocExport entries = 1;
}

// -----------------------------------------------------------------------
// Funnel — intake DAG used by `nornir-mcp` today as in-proc state, but
// promoted to server-owned state so the MCP can become a thin client.
// -----------------------------------------------------------------------

service Funnel {
  rpc SubmitIdea (SubmitIdeaRequest)  returns (IdRef);
  rpc CreatePlan (CreatePlanRequest)  returns (IdRef);
  rpc AddNode    (AddNodeRequest)     returns (IdRef);
  rpc Link       (LinkRequest)        returns (Empty);
  rpc SetStatus  (SetStatusRequest)   returns (Empty);
  rpc Next       (Empty)              returns (NextStepList);
  rpc Show       (Empty)              returns (FunnelDump);
  // EPIC FUNNEL-INTAKE (FI2): browse submitted items newest-first, optionally
  // filtered by kind (idea|error) and/or triage status.
  rpc History    (FunnelHistoryRequest) returns (FunnelHistoryResponse);
}

message IdRef { string id = 1; }

message SubmitIdeaRequest {
  string text      = 1;
  string source    = 2; // optional, e.g. "agent:claude"
  string item_kind = 3; // FI: "idea" (default) | "error"; empty = idea
}

// FI2 — history query. Empty kind/status = no filter.
message FunnelHistoryRequest {
  string kind   = 1;   // "" | "idea" | "error"
  string status = 2;   // "" | "untriaged" | "accepted" | "dropped" | "planned"
  uint32 limit  = 3;   // 0 = 200 default
}

// FI2 — one submitted item with its derived triage/plan status, newest-first.
message FunnelHistoryItem {
  string id          = 1;
  string item_kind   = 2;   // "idea" | "error"
  string text        = 3;
  string source      = 4;
  string submitted_at = 5;  // RFC3339
  string status      = 6;   // untriaged | accepted | dropped | planned
  repeated string plan_ids = 7;  // plans refining this item (its lineage)
}

message FunnelHistoryResponse {
  repeated FunnelHistoryItem items = 1;
}

message CreatePlanRequest {
  string idea_id = 1;
  string summary = 2;
}

message AddNodeRequest {
  string plan_id            = 1;
  string kind               = 2;
  string title              = 3;   // optional
  string prompt             = 4;   // optional
  repeated string targets   = 5;
  repeated string needs     = 6;   // node ids to depend on
}

message LinkRequest {
  string plan_id = 1;
  string from    = 2;
  string to      = 3;
}

message SetStatusRequest {
  string plan_id = 1;
  string node_id = 2;
  string status  = 3;   // ready|active|blocked|done|abandoned
  string why     = 4;   // required when blocked|abandoned
}

message NextStep {
  string plan_id = 1;
  string node_id = 2;
  string kind    = 3;
  string summary = 4;   // the plan's summary (same as topo::NextStep.summary)
  string prompt  = 5;   // node prompt_excerpt; empty = none
  repeated string targets = 6;
}

message NextStepList {
  repeated NextStep steps = 1;
}

message FunnelDumpIdea {
  string id     = 1;
  string text   = 2;
  repeated FunnelDumpPlan plans = 3;
  string source = 4;
  string item_kind = 5;  // FI: "idea" (default) | "error"
}

message FunnelDumpPlan {
  string id      = 1;
  string idea_id = 2;
  string summary = 3;
  repeated FunnelDumpNode nodes = 4;
  string status  = 5;   // PlanStatus Debug repr (Draft|Active|…)
}

message FunnelDumpNode {
  string id     = 1;
  string kind   = 2;
  string title  = 3;
  string status = 4;
  repeated string deps = 5;
}

message FunnelDump {
  repeated FunnelDumpIdea ideas = 1;
}

// -----------------------------------------------------------------------
// Mimir — cross-repo dependency-graph queries. Each method returns the SAME
// JSON the embedded MCP tools emit (computed by the shared `mimir` lib module),
// carried as a string so the wire stays schema-light for these read-only
// diagnostics. The server builds + caches the WorkspaceGraph per workspace.
// -----------------------------------------------------------------------
service Mimir {
  rpc DepsOf           (DepQuery)      returns (JsonResponse);
  rpc DependentsOf     (DepQuery)      returns (JsonResponse);
  rpc AffectedByChange (AffectedQuery) returns (JsonResponse);
  rpc BuildOrder       (Empty)         returns (JsonResponse);
  rpc DepPath          (DepPathQuery)  returns (JsonResponse);
  rpc ExternalDepUsers (CrateQuery)    returns (JsonResponse);
  rpc Svg              (Empty)         returns (JsonResponse); // body is static SVG text, not JSON
  rpc RepoOverview     (RepoOnly)      returns (JsonResponse);
  rpc SecurityScan     (RepoOnly)      returns (JsonResponse); // SBOM+vuln+license of a workspace repo (server-side cargo metadata + OSV)
  rpc ChangedSinceLastRelease (Empty)  returns (JsonResponse);

  // ── Mimir LENSES (the QUERY half lifted from dwarves::mimir, L5a) ────────
  // Each is a READ over Mimir returning ranked findings as JSON. Every response
  // carries an `evidence_note` naming missing feeds by table — an empty ranked
  // list and an unfed table must never draw the same.
  rpc GenericCost    (GenericCostQuery) returns (JsonResponse); // symbol_sizes × binary_facts (× scip_occurrences)
  rpc ZombieFeatures (LensQuery)        returns (JsonResponse); // feature_gate_facts × call_edges × symbol_sizes
  rpc ChangeRisk     (LensQuery)        returns (JsonResponse); // git_heat_facts × call_edges × coverage × semver_diffs
  rpc SimilarCode    (LensQuery)        returns (JsonResponse); // embeddings × symbol_facts (× scip_occurrences) — relocated dwarves::dupes
  rpc SemanticClones (LensQuery)        returns (JsonResponse); // embeddings_norm × fn_shape_events × code room — the model over the NORMALISED body (names → roles), cosine ≥ floor AND agreement of the normalised-body signal (exact/near) or the structural one; names never decide a pair (the name-driven view is SameIntent)
  rpc WeakTests      (LensQuery)        returns (JsonResponse); // test_outcomes × git_heat_facts × coverage_fn
  rpc BenchGaps      (LensQuery)        returns (JsonResponse); // git_heat_facts × call_edges × coverage_fn × bench_results
  rpc Villa          (LensQuery)        returns (JsonResponse); // call_edges × symbol_facts — IMPURE/shell-out heresy (std::process::Command, exec/system, external tools)

  // ── the CHANGE / ANOMALY lens family (over the TEMPORAL graph) ───────────
  // Signal-processing + statistical outlier detection over the per-node change
  // TIME-SERIES (git_heat_facts, ALL snapshots). Same evidence_note discipline.
  rpc Churn          (ChurnQuery)      returns (JsonResponse); // git_heat_facts × symbol_facts — frequency filter (high/low/band-pass), churn×complexity hotspot rank
  rpc Anomaly        (AnomalyQuery)    returns (JsonResponse); // git_heat_facts (all snapshots) — z-score/EWMA outliers: stable-node churn, spikes, breadth, new-coupling proxy
  rpc WorldImpact    (LensQuery)       returns (JsonResponse); // semver_diffs × call_edges × dep_graph_edges — changes ranked by world + constellation downstream
  rpc BinaryDrift    (LensQuery)        returns (JsonResponse); // symbol_sizes × binary_facts − symbol_facts × call_edges (+ dwarf_* edges): compile-time-injection detector
  rpc DataReach      (LensQuery)        returns (JsonResponse); // warehouse_access_edges × reversed call_edges × symbol_facts.visibility: sensitive-data reachability / leak paths (Gemini #2)
  rpc Trust          (TrustQuery)       returns (JsonResponse); // call_edges walked forward from the entry surface under the bottleneck (⊕=max over paths, ⊗=min weakest-hop) trust semiring: provenance-trust act/hold/refuse (Gemini 2A)
  rpc NameCollision  (LensQuery)        returns (JsonResponse); // symbol_facts × call_edges — CPG graph-soundness self-audit: leaf fn names defined in >1 place that the string-name matcher conflates; fraction of edges pointing at an ambiguous callee (idea #34)
  rpc SymbolIdentity (LensQuery)        returns (JsonResponse); // scip_occurrences — the SUBSTRATE invariant gate under every duplicate claim: is `symbol` actually an identity (one definition per symbol per snapshot)? Splits OURS (bare `local <id>` = pre-fix ingest rows, names the repos to re-ingest) from UPSTREAM (rust-analyzer defining one global moniker twice, which no re-ingest cures). Unfed reads `unknown`, never `sound`
  rpc UndocApi       (UndocApiQuery)    returns (JsonResponse); // symbol_facts(pub,doc_lines=0) × git_heat_facts(commits_90d/authors) — undocumented public API in churny files, ranked by recent churn (idea #16)
  rpc ReachableVuln  (LensQuery)        returns (JsonResponse); // vuln_findings × sbom_components × call_edges — b1 baddie-detection: a CVE present in a repo's components is refuse 🔴 only if the vulnerable crate is actually CALLED (reachability gate), present-but-unreachable is hold 🟠
  rpc Subclone       (LensQuery)        returns (JsonResponse); // SOURCE door: sub-item clone regions by winnowing (shared runs SMALLER than the item) — repo-scoped BY DESIGN: repo="" answers the refusal that names the askable repos
  rpc GroupTwins     (LensQuery)        returns (JsonResponse); // SOURCE door: the same semantic GROUP of statements in two functions of ONE repo (source text × item spans × embeddings_groups) — repo-scoped BY DESIGN: repo="" answers the refusal that names the askable repos
  // ── the clone signals (2026-10-17): clones by what a function DOES, not what it is
  // called. SemanticClones above is now the NORMALISED model (names → roles) held by
  // signal 1 or 2; the raw model, names included, is SameIntent (name-driven, labelled).
  rpc ExactClones      (LensQuery)      returns (JsonResponse); // fn_shape_events — the same normalised body (names → roles, literals → kinds)
  rpc NearClones       (LensQuery)      returns (JsonResponse); // fn_shape_events — MinHash/LSH over the normalised bodies' role shingles
  rpc StructuralClones (LensQuery)      returns (JsonResponse); // code room — IDF-weighted Jaccard of param/ret/used types, fields read, callees
  rpc RoleNeighbours   (LensQuery)      returns (JsonResponse); // code room + fn_role_vectors — FastRP role vectors, nearest by role
  rpc SameIntent       (LensQuery)      returns (JsonResponse); // embeddings (raw text) — NAME-DRIVEN: functions that read alike
  rpc SemanticTwins    (LensQuery)      returns (JsonResponse); // v1's semantic twins on v2 (one vector per function, stable ids, a why per pair): Type-3 copies cos ≥ 0.92 AND structural ≥ 0.50; Type-4 twins cos ≥ 0.882 AND structural ≤ 0.35 AND mutual kNN k=8
  rpc ReimplementsShared (LensQuery)    returns (JsonResponse); // the copy/twin pairs (SemanticTwins, SimilarCode) × DEPENDS_ON: crate A depends on crate B and A holds a copy of a B function — call B's instead (B is the library)
  rpc SourceWindow   (SourceWindowQuery) returns (JsonResponse); // a windowed source read: `context` lines each side of `line` from `repo`'s server-held checkout of `file` — the arch/jump-to-source preview for a thin (no-local-checkout) client. Body is JSON {start_line:u32, lines:[string]}.
}

message JsonResponse  { string json = 1; }
message DepQuery      { string repo = 1; bool transitive = 2; }
message AffectedQuery { repeated string repos = 1; }
message DepPathQuery  { string from = 1; string to = 2; }
message CrateQuery    { string krate = 1; }
// A Mimir-lens query: optional `repo` filter (""=all repos) + a result `limit`
// (0 = a per-lens default).
message LensQuery        { string repo = 1; uint32 limit = 2; }
// GenericCost also takes the instantiation floor below which a generic is not
// bloat (0 = default of 2).
message GenericCostQuery { string repo = 1; uint32 min_instantiations = 2; uint32 limit = 3; }
// Churn frequency-filter query: `band` is "high"/"low"/"band" (default high-pass);
// `cutoff_low`/`cutoff_high` are change-frequency (commits_90d) thresholds.
message ChurnQuery       { string repo = 1; string band = 2; int64 cutoff_low = 3; int64 cutoff_high = 4; uint32 limit = 5; }
// Anomaly query: `z_threshold` 0 = the 3.0 (three-sigma) default.
message AnomalyQuery     { string repo = 1; double z_threshold = 2; uint32 limit = 3; }
// Trust query: `act_at`/`hold_at` are the decision floors (0 = the 0.66 / 0.33
// defaults); inherited trust ≥ act_at ⇒ act, ≥ hold_at ⇒ hold, else refuse.
message TrustQuery       { string repo = 1; double act_at = 2; double hold_at = 3; uint32 limit = 4; }
// UndocApi query: `min_commits_90d` is the churn floor (0 = the default of 2).
message UndocApiQuery    { string repo = 1; int64 min_commits_90d = 2; uint32 limit = 3; }
// SourceWindow query: read `context` lines each side of 1-based `line` from
// `file` inside the server's checkout of `repo`. An empty `repo` asks the server
// to try each workspace member's checkout and return the first that contains
// `file` (the thin arch client resolves a symbol to a bare `src/…` path with no
// member attached). `context` 0 = a single line. Response JSON is
// `{ "start_line": <1-based u32 of the first line>, "lines": [<string>…] }`;
// an unreadable/absent file yields `{ "start_line": 0, "lines": [] }` (never an
// error) — the exact `(start_line, lines)` shape of `arch::source_window`.
message SourceWindowQuery { string repo = 1; string file = 2; uint32 line = 3; uint32 context = 4; }

// -----------------------------------------------------------------------
// Dwarf — warehouse-backed (persisted) DWARF symbol/callgraph queries. The
// airgapped counterpart to Introspect: no binary, reads the `dwarf_*` tables at
// the latest snapshot or a pinned `sha`. Same DwarfFacts methods the embedded
// MCP uses; results carried as JSON.
// -----------------------------------------------------------------------
service Dwarf {
  rpc SymbolLookup (DwarfQuery)     returns (JsonResponse);
  rpc DefinedIn    (DwarfQuery)     returns (JsonResponse);
  rpc Callers      (DwarfQuery)     returns (JsonResponse);
  rpc Callees      (DwarfQuery)     returns (JsonResponse);
  rpc CallPath     (DwarfPathQuery) returns (JsonResponse);
}

message DwarfQuery {
  string repo  = 1;
  string arg   = 2;   // name substring (lookup) | file suffix (defined_in) | fn name (callers/callees)
  string sha   = 3;   // empty = latest snapshot
  uint32 limit = 4;   // 0 = default
}

message DwarfPathQuery {
  string repo = 1;
  string from = 2;
  string to   = 3;
  string sha  = 4;    // empty = latest snapshot
}

// -----------------------------------------------------------------------
// Vector — semantic (embedding) code search over the materialized
// embeddings in the warehouse. The server embeds the query with the SAME
// jina-v2-base-code model the CLI uses, then scans the repo's vectors at the
// requested git SHA (time-travel). Requires the server built with an embedder
// (`embed-tract` or `embed-ort`); otherwise UNIMPLEMENTED. Result carried as
// JSON ({repo, hits:[{score,file,start_line,end_line}]}) to stay schema-light.
// -----------------------------------------------------------------------
service Vector {
  rpc Search (VectorSearchRequest) returns (JsonResponse);
}

message VectorSearchRequest {
  string repo  = 1;
  string query = 2;
  string sha   = 3;   // empty = latest snapshot
  uint32 limit = 4;   // 0 = 10 default
}

// -----------------------------------------------------------------------
// Embedder — one box's embedder lent to another over the wire: a GPU worker
// (`nornir vector serve-embed`) turns texts into vectors for a server that
// has no GPU of its own ($NORNIR_EMBED_REMOTE on that server). The worker
// holds no warehouse and no state: the caller cuts the texts, decides what
// is missing (content hash), and stores the vectors. Profile first — a caller
// refuses a worker whose model space (`profile_id`) is not its own, so two
// spaces never mix in one table.
// -----------------------------------------------------------------------
service Embedder {
  rpc Profile (Empty)        returns (EmbedProfile);
  rpc Embed   (EmbedRequest) returns (EmbedReply);
}

message EmbedProfile {
  string model_name    = 1;
  string weights_sha   = 2;
  string tokenizer_sha = 3;
  string pooling       = 4;
  bool   normalize     = 5;
  uint32 dim           = 6;
  string dtype         = 7;
  string profile_id    = 8;   // ModelProfile::id() of the fields above
  string model_id      = 9;   // registry id (e.g. jina-v2-base-code)
  string backend       = 10;  // what the worker embeds on (e.g. "ort CUDA (NVIDIA GPU)")
  string device        = 11;  // the device, as the worker names it
}

message EmbedRequest {
  repeated string texts = 1;
  string profile_id     = 2;  // the space the caller stores into; the worker refuses another
}

message EmbedReply {
  uint32 dim      = 1;
  uint32 count    = 2;        // == texts.len()
  bytes  values   = 3;        // count x dim little-endian f32, in request order
  uint64 embed_ms = 4;        // wall time of the forward passes on the worker
}

// -----------------------------------------------------------------------
// Viz — the time-travel visualizer (`urdr-threads`) as a thin client. The
// server builds the full swim-lane Timeline (release lineage + dep-graph
// snapshots + bench history) from the warehouse it owns and returns it as
// JSON (the viz model's serde shape); the egui app deserializes it, so it
// runs either embedded (own warehouse) or remote (this RPC).
// -----------------------------------------------------------------------
service Viz {
  rpc Timeline (VizTimelineRequest) returns (JsonResponse);
  // ReleaseEvents — the `release_events` op-DAG rows for the served workspace,
  // the SAME data `query_release_events(&wh, EventSelector::All)` returns
  // embedded, serialized as a JSON array of ReleaseEventRow. Backs the 🚀
  // Release tab + the 📡 Live Run pane's durable hydrate in thin mode.
  rpc ReleaseEvents (VizTimelineRequest) returns (JsonResponse);
  // BakeoffResults — the `agent_model_runs` bake-off rows for the served
  // workspace (the SAME data `query_agent_model_runs(&wh, BakeoffSelector::All)`
  // returns embedded), as a JSON array of AgentModelRunRow. Backs the 🏆
  // Leaderboard tab in thin mode.
  rpc BakeoffResults (VizTimelineRequest) returns (JsonResponse);
  // Knowledge — the server-side knowledge-map scan summary for the served
  // workspace: it runs `knowledge::scan_all` over each member's checkout
  // (`<workspace_root>/git/<member>`) and returns the per-crate bubble rows the
  // 🗺 Knowledge tab renders, as JSON. The thin client has no local checkout to
  // scan, so the scan runs on the server (which owns the cloned repos).
  rpc Knowledge (VizTimelineRequest) returns (JsonResponse);
  // TestResults — every `test_results` row for the served workspace, the SAME
  // data `query_test_results(&wh, TestSelector::All)` returns embedded,
  // serialized as a JSON array of TestResultRow. Backs the 🧪 Test tab in thin
  // mode (the server holds the redb lock, so clients go through this RPC).
  rpc TestResults (VizTimelineRequest) returns (JsonResponse);
  // TestMatrix — the BUILD-FREE test matrix for the served workspace: the
  // discovered test inventory (syn scan) plus every test_results row, as JSON
  // {inventory:[TestInventoryRow], results:[TestResultRow]}. The thin 🧪 Test
  // tab joins them into the ok|fail|X tri-state matrix identically to the fat
  // path (the server holds the redb lock, so clients go through this RPC).
  rpc TestMatrix (VizTimelineRequest) returns (JsonResponse);
  // BenchTelemetry — bench_telemetry + bench_runs rows for the served workspace as
  // JSON {telemetry:[BenchTelemetryRow], runs:[BenchRun]}; backs the 📡 Bench LIVE panel in thin mode.
  rpc BenchTelemetry (VizTimelineRequest) returns (JsonResponse);
  // CloneEvents — the recent `clone_events` populate outcomes for the served
  // workspace (the SAME data `query_clone_events(&wh, CloneSelector::Workspace)`
  // returns embedded), serialized as a JSON array of CloneEventRow. Backs the 🧬
  // nornir root pane's per-member populate status (ok / failed + error + when) in
  // thin mode, so a thin client sees *why* a member didn't populate. (PLAN #6)
  rpc CloneEvents (VizTimelineRequest) returns (JsonResponse);
  // Architecture — the EPIC ARCH wiring board for the served workspace. The
  // server reads each member's latest persisted `architecture_wiring` snapshot
  // and merges them into ONE workspace-wide ArchGraph
  // (`arch::warehouse::merged_arch_from_warehouse`), returned as JSON
  // `{ graph: ArchGraph, members_with_data: [..], errors: [..] }` (the
  // `arch_tab::ArchPayload` serde shape). The thin viz can't open the warehouse
  // (the server holds the redb lock), so the 🏛 Architecture tab reads through
  // this RPC in remote mode. Empty/missing → an empty graph (never an error).
  rpc Architecture (VizTimelineRequest) returns (JsonResponse);
  // Jobs — the unified job ledger for the served workspace (the SAME data
  // `JobStore::list(JobSelector::Workspace)` returns embedded), serialized as a
  // JSON array of `jobs::JobRecord`, newest-first. Backs the 🧬 nornir tab's job
  // list in thin mode. PAGINATED: `JobsRequest.limit` caps rows (server default
  // ~200) so a long-lived ledger never overflows the 4 MiB gRPC decode cap (the
  // bug that hit the unpaginated CloneEvents on big workspaces).
  rpc Jobs (JobsRequest) returns (JsonResponse);
  // Coverage — the latest source-coverage run for ONE repo in the served
  // workspace (the SAME data `coverage::latest(&wh, repo)` returns embedded plus
  // the overall-line% history), serialized as JSON
  // `{ rows:[CoverageRow], fn_rows:[CoverageFnRow], history:[f64] }`. The repo is
  // carried in `VizTimelineRequest.workspace` (free here — the served workspace
  // comes from metadata). The thin viz can't open the warehouse (the server holds
  // the redb lock), so the 📐 Coverage panel reads through this RPC in remote
  // mode — identical to the Release/Test/Bench-LIVE thin split.
  rpc Coverage (VizTimelineRequest) returns (JsonResponse);
  // HolgerRegistry — the embedded (married) holger dev-pair registry the server
  // serves: it runs `holger_embed::read_registry(<holger-cache>, …)` on the
  // server's own data dir (the same one its in-process holger serves) and
  // returns the per-repo summary rows (crates-io / cache / sparring with their
  // crate/artifact counts + recent ids), serialized as a JSON array of
  // `holger_embed::RepoSummary`. The thin viz has no local holger to read, so
  // the 📦 Holger pane reads through this RPC in remote mode. A server built
  // without `embed-holger` (or with no data dir) returns an empty array.
  rpc HolgerRegistry (VizTimelineRequest) returns (JsonResponse);
}

message VizTimelineRequest {
  string workspace = 1;   // release-time workspace name (keys release_lineage)
}

message JobsRequest {
  string workspace = 1;   // served workspace whose ledger to read
  string kind      = 2;   // optional jobs::kind filter; empty = all kinds
  uint32 limit     = 3;   // max rows, newest-first; 0 = server default (~200)
}

// Generic warehouse browser: list tables + scan one to display rows. Lets a
// remote viz (or any thin client) see every Iceberg table the server owns —
// the server holds the redb lock, so clients go through here.
service Warehouse {
  rpc Tables (Empty) returns (WarehouseTables);
  rpc Scan   (WarehouseScanRequest) returns (WarehouseScan);
  // TableStats — per-table row count + on-disk byte size + last-populated
  // snapshot for EVERY table, read cheaply off each table's current Iceberg
  // snapshot summary (no data scan). Backs the dwarves Mímir "Warehouse Health"
  // backoffice page (real numbers, not green boxes) and the populate-completeness
  // oracle. `complete=false` + `empty_regressions` name index types that filled
  // ZERO rows outside the documented empty-until-triggered allowlist — i.e. a
  // populate silently skipped them (embeddings/HNSW is the 2026-07-24 case).
  rpc TableStats (Empty) returns (WarehouseTableStats);
  // ScanVectors — the TYPED BULK leg, for the one column shape `Scan` structurally
  // cannot carry.
  //
  // `Scan` is a *stringified* preview: every cell goes through ONE renderer whose
  // catch-all is `format!("<{}>", arr.data_type())`. A `list<float32>` column matches
  // no scalar arm, so `embeddings.vector` arrives as the literal Arrow TYPE NAME —
  // the SAME constant string for every row, carrying zero floats. It is not truncated
  // and it is not dropped; it is replaced by something that merely LOOKS like a cell.
  // Downstream that made the dupes SEMANTIC tier and leafwatch's semantic leg
  // permanently unavailable on every thin client: hash tiers only, structurally blind
  // to "the same thing, written differently".
  //
  // A 768-float vector per row is bulk data, so it is NOT papered over with a wider
  // string: it travels as raw little-endian IEEE-754 f32 bytes (4 B/float — a
  // stringified `0.123456,…` run is 2–3× that and lossy), server-STREAMED in batches
  // so a constellation-scale table never has to fit one message.
  //
  // The two failure states a consumer must never conflate are distinguishable here by
  // construction:
  //   * UNIMPLEMENTED  ⇒ *this transport cannot carry vectors* (pre-ScanVectors server);
  //   * a stream that completes with zero vectors ⇒ *the warehouse holds 0 embeddings*
  //     (the embedder never ran).
  rpc ScanVectors (WarehouseVectorRequest) returns (stream WarehouseVectorBatch);
}
message WarehouseTables { repeated string names = 1; }
message WarehouseTableStat {
  string table            = 1;
  int64  row_count        = 2;   // -1 = unknown (no snapshot / unreadable)
  int64  byte_size        = 3;   // total-files-size from the snapshot summary
  int64  data_files       = 4;
  int64  last_snapshot_ms = 5;   // 0 = never populated
  bool   empty            = 6;   // row_count<=0 && data_files<=0
  bool   allowlisted      = 7;   // documented empty-until-triggered
  string reason           = 8;   // why it is legitimately empty (if allowlisted)
}
message WarehouseTableStats {
  repeated WarehouseTableStat stats = 1;
  bool   complete                   = 2;   // no empty regression outside allowlist
  repeated string empty_regressions = 3;   // expected index types that filled 0 rows
  string summary                    = 4;   // one-line human roll-up
}
message WarehouseScanRequest {
  string table = 1;
  uint32 limit = 2;   // 0 = server default
}
message WarehouseRow { repeated string cells = 1; }
message WarehouseScan {
  repeated string columns = 1;
  repeated WarehouseRow rows = 2;
}
message WarehouseVectorRequest {
  string table   = 1;   // "" = "embeddings"
  string key_col = 2;   // "" = "content_hash"
  string vec_col = 3;   // "" = "vector"
  uint32 limit   = 4;   // 0 = every row (the semantic tier needs the WHOLE table)
}
message WarehouseVector {
  string key           = 1;   // content_hash
  uint32 dims          = 2;
  bytes  values        = 3;   // dims × little-endian f32; len MUST be dims*4
  // The weights-SHA-sensitive embedding-space id. Carried because a cosine measured
  // in one vector space says NOTHING about another; a consumer that mixes profiles
  // produces confident nonsense. Reported, not enforced here.
  string model_profile = 4;
}
message WarehouseVectorBatch {
  repeated WarehouseVector vectors = 1;
  uint64 rows_scanned              = 2;   // cumulative SOURCE rows read so far
  bool   clipped                   = 3;   // `limit` cut the scan short
}

// -----------------------------------------------------------------------
// Workspaces — the server's **registry** of tracked workspaces (monitored /
// pushed / external). Server-global (one redb at $NORNIR_ROOT), so unlike the
// per-workspace services these don't read the `nornir-workspace` header. Backs
// `nornir workspace …` in client mode and the viz list/select window, so the
// picker works embedded (own warehouse) and remote alike.
// -----------------------------------------------------------------------
service Workspaces {
  rpc List     (Empty)                    returns (WorkspaceList);
  rpc Get      (WorkspaceName)            returns (WorkspaceRecord);
  rpc Register (RegisterWorkspaceRequest) returns (WorkspaceRecord);
  rpc Remove   (WorkspaceName)            returns (Empty);
  rpc Fetch    (WorkspaceFetchRequest)    returns (WorkspaceFetchReport);
  // PopulateStatus — the per-workspace populate ROLL-UP for the WHOLE registry
  // (PO1): for every served workspace, the latest clone/fetch outcome per member
  // collapsed into one verdict (green = all members' latest ok · red = ≥1 latest
  // error · stale = never populated), plus the first failing member + its error.
  // Returns a JSON array of `RosterRow`-shaped objects (name/mode/members/
  // last_synced/populate/failing_member/last_error). This is a COMPACT summary —
  // NOT the full clone_events ledger — so it can't overflow the gRPC decode cap
  // the way the unpaginated `Viz.CloneEvents` does on a big workspace
  // (PF-cloneevents); one round-trip backs the whole Nornir-tab roster. Server-
  // global (reads the registry + each workspace's warehouse), like the other
  // Workspaces.* verbs. CLI twin: `nornir workspace ls --status`.
  rpc PopulateStatus (Empty) returns (JsonResponse);
  // **THE ONE REFRESH.** Clear-and-repopulate the DERIVED half of a served
  // workspace's warehouse and re-enqueue the chain that rebuilds it.
  //
  // Auto-population already exists (a landed snapshot submits one jera chain job
  // per lens, deduped by key, superseded by generation), and `nornir warehouse
  // reset` is NOT its verb: reset empties the whole root and REFUSES while the
  // server holds the catalog lock — the exact case a running server is. This is
  // the verb that works through the served handle.
  //
  // Contract:
  //   * takes the workspace's populate gate (the SAME gate `Fetch` and the poll
  //     sweep take), so it cannot run beside a populate/republish. Held mid-flight
  //     ⇒ FAILED_PRECONDITION naming the in-flight job id.
  //   * `purge = true` DROPS the derived lens tables for this workspace — the set
  //     is read off the lens registry (`lens_cache::LENSES` → `lens_<name>`, plus
  //     the `lens_runs` ledger that would otherwise short-circuit the refold), so
  //     a fourteenth lens is covered without touching this verb. It NEVER touches
  //     a raw scan / snapshot / base table: those are the inputs a refold reads.
  //   * with or without `purge`, the feed chain and the lens chain are
  //     re-enqueued. Every lens job already in flight at this generation comes
  //     back as a DUPLICATE carrying its job id; when NOTHING was newly queued
  //     that is the named refusal, not a silent success.
  // CLI twin: `nornir workspace repopulate <ws> [--purge]`. Door: the ONE
  // Refresh button in dwarves-ui's warehouse pane.
  rpc Repopulate (WorkspaceRepopulateRequest) returns (WorkspaceRepopulateReport);
}

// `Workspaces.Repopulate` — the one Refresh.
message WorkspaceRepopulateRequest {
  string name  = 1;
  // Drop the derived lens tables before re-enqueueing (see the rpc comment).
  // false = re-enqueue only.
  bool   purge = 2;
  // `BuildWorkspace { only: Affected }` (`nornir workspace build --affected`):
  // the consume this queues reads only the members that are behind and
  // compiles (dumping nothing) only the members that depend on them — Mímir's
  // affected_by_change over the members' manifests. false = every member.
  bool   affected = 3;
}

message WorkspaceRepopulateReport {
  string workspace = 1;
  // Whether `purge` was honored.
  bool   purged    = 2;
  // The derived tables actually dropped, named — never a hand list, always the
  // lens registry's own answer. Empty without `purge`.
  repeated string dropped = 3;
  // The lens-chain generation the re-enqueue submitted at.
  string generation = 4;
  // jera job ids NEWLY queued by this call (feeds + lenses).
  repeated string queued = 5;
  // jera job ids that were ALREADY in flight for this generation (the ChainQueue
  // dedup path). `queued` empty + this non-empty ⇒ `refusal` is set.
  repeated string in_flight = 6;
  // The NAMED refusal, when nothing was re-enqueued because the chain is already
  // in flight. Empty when work was queued. (A gate collision is a
  // FAILED_PRECONDITION status instead — this field is the dedup half.)
  string refusal = 7;
}

message WorkspaceName {
  string name = 1;
  // Only honored by `Remove`: when true, also `remove_dir_all` the workspace's
  // on-disk `builds/` dir (warehouse + catalog.redb + jobs.redb), not just the
  // registry row. Default false = registry-only (back-compat). Ignored by the
  // other verbs (Get/Fetch) that reuse this message.
  bool purge = 2;
}

// Fetch a workspace's remotes now. `force` republishes the warehouse even when
// no git member changed (e.g. the warehouse was cleared and must be rebuilt);
// otherwise the warehouse is rebuilt only when a member's SHA moved.
// `background` returns right after the (fast) git clone WITHOUT the heavy
// warehouse rebuild — the server's poll loop then builds it in the background
// (single-writer, safe). This is the `populate` path: instant clone, async build.
message WorkspaceFetchRequest {
  string name       = 1;
  bool   force      = 2;
  bool   background = 3;
}

message WorkspaceMember {
  string name          = 1;
  string remote        = 2;
  string git_ref       = 3;
  string last_seen_sha = 4;
  string last_synced   = 5;
  string sync_state    = 6;
  // AUT6 working-tree freshness of the server's clone. last_seen_sha can only
  // see committed state; this digest sees uncommitted edits. "clean" sentinel
  // when clean, empty when never computed.
  string worktree_digest = 7;
  bool   worktree_dirty  = 8;
}

message WorkspaceRecord {
  string name             = 1;
  string mode             = 2;   // "monitored" | "pushed" | "external"
  string descriptor       = 3;
  string poll             = 4;
  string current_snapshot = 5;
  repeated WorkspaceMember members = 6;
  string created_at       = 7;
  string updated_at       = 8;
}

message WorkspaceList { repeated WorkspaceRecord workspaces = 1; }

message RegisterWorkspaceRequest {
  string name       = 1;
  string descriptor = 2;
  string mode       = 3;   // monitored | pushed | external; default pushed
  string poll       = 4;
  // EAGER by default: record the workspace AND immediately fetch (clone members)
  // + build the warehouse inline — the same pipeline the poll loop runs on a
  // change — so members + the snapshot are populated NOW, not after a tick. Set
  // `lazy = true` for the old record-only behaviour (scripted/daemon flows that
  // explicitly defer population to the poll loop). The poll loop then handles
  // UPDATES on git HEAD change.
  bool   lazy       = 5;
  // THIN-MODE descriptor SHIP: when the client discovered a LOCAL
  // `nornir-workspace.toml` (e.g. via `nornir workspace add <name>` with no
  // `--descriptor`, standing in the workspace dir) the SERVER — a different
  // host/user — cannot read that local path. So the client reads the file
  // content here and the server writes it to its OWN descriptors dir
  // (`<home>/.nornir/descriptors/<name>.toml`) and registers pointing at THAT
  // server-local path, so the poll loop can re-read it. Empty = keep the
  // path-passing behaviour (explicit `--descriptor` that is a git URL or an
  // already-server-readable path); `descriptor` then carries the path as before.
  string descriptor_content = 6;
}

message WorkspaceFetchReport {
  string workspace        = 1;
  uint32 fetched          = 2;
  repeated string changed = 3;
  repeated string errors  = 4;   // "member: message"
  string snapshot         = 5;   // new warehouse snapshot id if republished, else ""
}

// -----------------------------------------------------------------------
// Ops: the *write-side* heavy operations the viz operation BUTTONS fire —
// the run-side counterparts of the read-only Viz.* / Bench.History / Release
// gate RPCs. Tests, benches and the release-grade gate run where the source
// checkout lives — i.e. server-side over the monitored clones — and persist
// straight into the served workspace's warehouse (the server owns the lock),
// exactly like `Workspaces.Fetch`/`republish` and `Release.GateAll` already
// do their heavy work in a spawn_blocking. Each verb is a NAMED `Ops.Verb`
// handler (layer-boundary-discoverability LAW): a button → one RPC → one
// reusable core fn. CLI parity: `nornir test all` / `nornir bench run` /
// `nornir release run` route here in client mode. The `nornir-workspace`
// header selects the workspace. These are HEAVY/long-running — the viz gates
// them behind a confirm + a "heavy" warning.
// -----------------------------------------------------------------------
service Ops {
  // Run the test matrix (`run_aspect` over each repo's checkout) and persist
  // `test_results`. `repo` empty = the FULL matrix across every configured
  // repo (the heavy, release-grade run); a repo name = just that repo.
  rpc RunTestMatrix (RunTestMatrixRequest) returns (RunOpResult);
  // Run the benches (`bench run`) for `repo` (empty = the first/primary repo)
  // and persist a `BenchRun` into `bench_runs` + the JSONL receipt.
  rpc RunBench      (RunBenchRequest)      returns (RunOpResult);
  // Run the release-grade heavy gate: the full release gate set
  // (`Release.GateAll`) across the build order, persisting `release_events`
  // so the 🚀 Release / 📡 Live panes light up. `repo` empty = every repo.
  rpc RunRelease    (RunReleaseRequest)    returns (RunOpResult);
}

message RunTestMatrixRequest {
  string repo     = 1;            // empty = full matrix across all repos
  string aspects  = 2;            // comma list (empty = the default aspect set)
  string features = 3;            // NORNIR_TEST_FEATURES for the unit subprocess
                                  // (e.g. "testmatrix" → functional-status rows);
                                  // empty = the server's own env / default set
}

message RunBenchRequest { string repo = 1; }   // empty = primary repo

message RunReleaseRequest { string repo = 1; } // empty = whole build order

// The common result for every Ops.* run: an overall green/red verdict, a
// human summary line (the same text the CLI prints), the per-target outcomes
// (repo → status), and the warehouse run id the rows landed under so the viz
// can deep-link to the rendered rows it just produced.
message RunOpResult {
  bool   ok                = 1;  // false on any red target / hard error
  string summary           = 2;  // one-line human summary (CLI parity)
  repeated OpTarget targets = 3; // per-repo / per-target outcome
  string run_id            = 4;  // warehouse run id of the persisted rows
}

message OpTarget {
  string name    = 1;            // repo / target name
  string status  = 2;            // "pass" | "fail" | "skip"
  string message = 3;            // detail / error (empty on clean pass)
}

// -----------------------------------------------------------------------
// CodeRoom — the ONE door of the code room's readers (Mímir v2). A question is
// a surface name of `nornir::code_room::reader::SURFACES` (`Knowledge.Symbols`,
// `Viz.CrateGraph`, `Coderoom.SnapshotDiff`, …) and its request as JSON; the
// answer is the reader's JSON object. It ALWAYS carries `state`:
//
//   complete | partial | empty_fact | empty_gap   an answer, with `evidence`
//                                                  (scope, inputs, what is MISSING, notes)
//   not_wired | not_consumed | no_room | lake_unavailable | driver_not_available |
//   not_linked | invalid_argument | internal      a REFUSAL: `refused` = true and
//                                                  `note` = the sentence a person reads
//
// A refusal is a body, not a gRPC error: a thin client that draws a pane needs
// the state by name, and a status code cannot carry it. Authentication and
// transport failures stay gRPC errors. The `nornir-workspace` header selects
// the workspace; none selected (or one that is not served) answers `no_room`.
// -----------------------------------------------------------------------
service CodeRoom {
  rpc Answer (CodeRoomQuestion) returns (JsonResponse);
}

message CodeRoomQuestion {
  string surface     = 1;   // a name of the reader's surface list
  string params_json = 2;   // the surface's request as a JSON object; empty = {}
}

// -----------------------------------------------------------------------
// Saga — record/replay tape INGEST + read-back (EPIC SAGA / task #42). A THIN
// producer (Njord, a leaf app with no local warehouse) ships its captured
// SagaEvents here; the server lands them in the served workspace's Mimir
// `saga_events` table (the RECEIVER, via WarehouseTapeStore) and hands the
// recorded sessions back for the dwarves tape-recorder to replay. The
// `nornir-workspace` header selects the workspace. Same
// client-writes-through-the-locked-server pattern as Telemetry.Submit*.
// -----------------------------------------------------------------------
service Saga {
  // Land a producer's captured events into the warehouse. Ack = count accepted
  // (SubmitCountResponse) so a caller can detect a partial/rejected batch.
  rpc RecordEvents (RecordSagaEventsRequest) returns (SubmitCountResponse);
  // The painful-first Sessions roll-up, as a JSON array of SagaSessionSummary
  // (schema-light, like the Viz.* reads — the same rows the dwarves tape pane
  // and the nornir viz Sessions pane render). Mirrors SagaTapeStore::list_sessions.
  rpc ListSessions (Empty)                   returns (JsonResponse);
  // One session's events, scoped + seq-ordered, for replay. Mirrors
  // SagaTapeStore::read_session.
  rpc ReadSession  (SagaSessionQuery)        returns (SagaEventList);
}

// One captured moment — the wire twin of `nornir_saga::SagaEvent` (all 16
// replay-relevant fields; `kind` is the lowercase tag nav|click|log|error|state).
message SagaEventPb {
  uint64 seq         = 1;
  uint64 ts_micros   = 2;
  string session_id  = 3;
  string user        = 4;
  string kind        = 5;
  string route       = 6;
  string component   = 7;
  string action      = 8;
  string detail      = 9;
  string state_json  = 10;
  string level       = 11;
  string span_id     = 12;
  string parent      = 13;
  string app         = 14;
  string app_version = 15;
  string git_sha     = 16;
}

message RecordSagaEventsRequest {
  string session_id = 1;          // optional hint; each event carries its own
  repeated SagaEventPb events = 2;
}

message SagaSessionQuery { string session_id = 1; }
message SagaEventList    { repeated SagaEventPb events = 1; }