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; }