feat(desktop): surface each relay's MIP capabilities from its NIP-11 advertisement
Some checks failed
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Meridian Harness / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Meridian Harness / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Meridian Harness / Publish rolling release (push) Has been cancelled
Meridian Harness / Publish tagged release (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Server Cross-Compile (push) Has been cancelled
CI / Server Cross-Compile-1 (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled

The MIP register, the AIS and Compact-Track-Code specs, the wire contract
(NIP-11 `supported_extensions`, `mip-` prefixed) and the control plane's
reader all existed. The desktop app read none of it, so a member had no way
to see which Meridian Implementation Possibilities their relay actually
honours.

Adds Settings > Capabilities: the register with each MIP's lifecycle state,
the capability packs grouped over it (AIS, ADS-B, APP-6E, STANAG-4609), and
what this relay advertises right now.

Kept honest in three places, because the failure this area already paid for
once was NIP-17 advertised while kind:10050 was rejected:

- A pack is available only when *every* member MIP is ENFORCED *and* the
  connected relay advertises it. Today that is 0 of 4, and the card says so
  rather than offering a toggle that cannot work.
- An unreachable relay renders as "not reachable", never as "no
  capabilities" - "we could not ask" is not an answer.
- A relay advertising a MIP the register does not call ENFORCED raises an
  over-claim warning instead of being trusted.

`docs/mips/README.md` stays the one registry: `just check-mips` regenerates
the client's copy from it and fails on drift, so the register cannot become
two lists. The pack table is new there, and carries the dependency closure
plus a Missing column that must name any MIP nobody has written yet.

Also corrects the acronym in the three places that expanded it as "Meridian
Improvement Proposals". A MIP documents what compatible relay and client
software *may* implement - which is why ENFORCED gates advertisement at all.

Refs: meridian-hgjj, meridian-4guo
Signed-off-by: Joshua Belke <joshua@innovationhub-act.org>
This commit is contained in:
Josh Belke 2026-08-06 00:06:15 -04:00
commit f7a0f0cdc8
15 changed files with 1082 additions and 6 deletions

File diff suppressed because one or more lines are too long

View file

@ -135,7 +135,7 @@ build-release:
# `desktop-check` / `web-check` are biome plus the guard scripts — neither runs
# `tsc`. Without the typecheck recipes here, this whole gate passes on a tree
# that does not compile, and the first thing to notice is CI's `desktop-build`.
check: fmt-check clippy desktop-check desktop-typecheck desktop-tauri-fmt-check desktop-tauri-clippy web-check web-typecheck mobile-check compose-check backup-restore-check git-pointer-repair-contract nip34-search-rollout-check reply-persistence-benchmark-contract capacity-contract-check check-kinds check-skills check-brand check-feature-specs check-relay-e2e-inventory check-frozen-migrations check-legacy-namespaces check-relay-terminology
check: fmt-check clippy desktop-check desktop-typecheck desktop-tauri-fmt-check desktop-tauri-clippy web-check web-typecheck mobile-check compose-check backup-restore-check git-pointer-repair-contract nip34-search-rollout-check reply-persistence-benchmark-contract capacity-contract-check check-kinds check-skills check-brand check-feature-specs check-mips check-relay-e2e-inventory check-frozen-migrations check-legacy-namespaces check-relay-terminology
# Verify the local backing stack contract (Dragonfly swap, database isolation,
# and every caller that names those services). No Docker required — the
@ -189,6 +189,13 @@ check-feature-specs:
node scripts/check-feature-specs.mjs
node --test scripts/check-feature-specs-core.test.mjs
# `docs/mips/README.md` is the one MIP register. The desktop capability view
# reads a generated copy; this fails when the two drift. Pass `--write` to
# regenerate after editing the register.
check-mips *ARGS:
node scripts/check-mip-registry.mjs {{ARGS}}
node --test scripts/mip-registry-core.test.mjs
# Every ignored relay integration binary must have an executable or reviewed lane.
check-relay-e2e-inventory:
node scripts/check-relay-e2e-inventory.mjs

View file

@ -364,7 +364,7 @@ Compose service**, because `just _ensure-services` blocks on core health.
---
## 5. The MIP system — Meridian Improvement Proposals
## 5. The MIP system — Meridian Implementation Possibilities
### 5.1 The deconfliction rule

View file

@ -6,7 +6,8 @@
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
"ignoreUnknown": false,
"includes": ["**", "!**/*.generated.json"]
},
"formatter": {
"enabled": true,

View file

@ -76,6 +76,77 @@ pub async fn fetch_workspace_icon(
Ok(doc.icon.filter(|icon| !icon.is_empty()))
}
#[derive(Deserialize)]
struct RelayInfoExtensions {
#[serde(default)]
supported_extensions: Option<Vec<String>>,
}
/// Which MIPs a relay advertises, split out of NIP-11 `supported_extensions`.
#[derive(Serialize, Default, PartialEq, Eq, Debug)]
pub struct RelayMips {
/// Whether the relay answered at all. `false` means "we do not know",
/// which the capability view must render differently from "none".
pub reachable: bool,
/// Advertised MIP ids, lower-cased (`mip-as`).
pub mips: Vec<String>,
/// Every other advertised extension (`nip-er`, …).
pub extensions: Vec<String>,
}
/// Split NIP-11 `supported_extensions` into MIPs and everything else.
///
/// Mirrors `split_extensions` in `crates/meridian-control-plane/src/http.rs`
/// and `splitExtensions` in `scripts/mip-registry-core.mjs`. The prefix is the
/// contract: a repo-local proposal advertises as `mip-xx`, and per
/// `docs/mips/README.md` it may only be advertised once it reaches `ENFORCED`.
fn split_extensions(values: &[String]) -> (Vec<String>, Vec<String>) {
values
.iter()
.cloned()
.partition(|value| value.to_ascii_lowercase().starts_with("mip-"))
}
/// Fetch the MIPs a relay advertises in its NIP-11 document.
///
/// Unauthenticated HTTP GET, same shape as [`fetch_workspace_icon`] — no
/// WebSocket session needed, so the capability view renders before (and
/// without) a connected relay.
///
/// An unreachable or malformed relay returns `reachable: false` rather than an
/// error. The distinction matters: "this relay advertises no MIPs" is a fact
/// about the relay, while "we could not ask" is a fact about the network, and
/// collapsing the two would show an empty capability list as if it were
/// answered.
#[tauri::command]
pub async fn fetch_relay_mips(
relay_url: String,
state: State<'_, AppState>,
) -> Result<RelayMips, String> {
let http_url = relay::relay_http_base_url(&relay_url);
let Ok(response) = state
.http_client
.get(&http_url)
.header("Accept", "application/nostr+json")
.send()
.await
else {
return Ok(RelayMips::default());
};
if !response.status().is_success() {
return Ok(RelayMips::default());
}
let Ok(doc) = response.json::<RelayInfoExtensions>().await else {
return Ok(RelayMips::default());
};
let (mips, extensions) = split_extensions(&doc.supported_extensions.unwrap_or_default());
Ok(RelayMips {
reachable: true,
mips: mips.iter().map(|mip| mip.to_ascii_lowercase()).collect(),
extensions,
})
}
#[derive(Serialize)]
pub struct ActiveWorkspaceInfo {
relay_url: String,
@ -288,3 +359,56 @@ pub async fn apply_workspace(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn owned(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn splits_mips_from_other_extensions() {
let (mips, extensions) = split_extensions(&owned(&["nip-er", "mip-as", "mip-ct"]));
assert_eq!(mips, owned(&["mip-as", "mip-ct"]));
assert_eq!(extensions, owned(&["nip-er"]));
}
#[test]
fn prefix_match_is_case_insensitive() {
let (mips, extensions) = split_extensions(&owned(&["MIP-AS", "NIP-ER"]));
assert_eq!(mips, owned(&["MIP-AS"]));
assert_eq!(extensions, owned(&["NIP-ER"]));
}
#[test]
fn an_empty_advertisement_yields_no_mips() {
let (mips, extensions) = split_extensions(&[]);
assert!(mips.is_empty());
assert!(extensions.is_empty());
}
#[test]
fn nip_prefixed_values_are_never_mistaken_for_mips() {
// `nip-` and `mip-` differ by one character; a substring match here
// would advertise every NIP as a MIP in the capability view.
let (mips, _) = split_extensions(&owned(&["nip-mip-er"]));
assert!(mips.is_empty());
}
#[test]
fn unreachable_relay_is_distinct_from_no_mips() {
// The default is what `fetch_relay_mips` returns when the relay does
// not answer. It must not look like an answered "none".
let unknown = RelayMips::default();
assert!(!unknown.reachable);
assert!(unknown.mips.is_empty());
let answered_none = RelayMips {
reachable: true,
..Default::default()
};
assert_ne!(unknown, answered_none);
}
}

View file

@ -905,6 +905,7 @@ pub fn run() {
validate_repos_dir,
get_active_workspace,
fetch_workspace_icon,
fetch_relay_mips,
fetch_join_policy,
set_prevent_sleep_active,
get_agent_memory,

View file

@ -0,0 +1,191 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
capabilityPacks,
mips,
packStatus,
reconcile,
useRelayMips,
type CapabilityPack,
type Mip,
type RelayMips,
} from "@/shared/mips";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
/** Per-state copy. `ENFORCED` is the only state a relay may advertise. */
const STATE_COPY: Record<Mip["state"], { label: string; className: string }> = {
DRAFT: { label: "Draft", className: "text-muted-foreground" },
REVIEW: { label: "In review", className: "text-muted-foreground" },
IMPLEMENTABLE: { label: "Implementable", className: "text-foreground" },
ENFORCED: { label: "Enforced", className: "text-emerald-500" },
WITHDRAWN: { label: "Withdrawn", className: "text-muted-foreground" },
};
const PACK_STATUS_COPY = {
active: { label: "Active", className: "text-emerald-500" },
inactive: {
label: "Not enabled on this relay",
className: "text-muted-foreground",
},
unavailable: {
label: "Not available yet",
className: "text-muted-foreground",
},
unknown: { label: "Relay not reachable", className: "text-muted-foreground" },
} as const;
function PackRow({ pack, relay }: { pack: CapabilityPack; relay: RelayMips }) {
const status = packStatus(pack, relay);
const copy = PACK_STATUS_COPY[status];
// Say *why* a pack is dark. "Unavailable" with no reason is the thing that
// sends someone hunting for a toggle that does not exist.
const reason =
pack.missing !== null
? pack.missing
: status === "unavailable"
? `waiting on ${pack.blockedBy.join(", ")}`
: null;
return (
<div
className="flex items-start justify-between gap-3 rounded-lg border border-border/70 bg-background/70 px-4 py-3"
data-testid={`capability-pack-${pack.id}`}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{pack.id}</p>
<p className="text-xs text-muted-foreground">
{pack.requires.length > 0
? pack.requires.join(" · ")
: "No MIP specified yet"}
</p>
{reason ? (
<p className="text-2xs text-muted-foreground">{reason}</p>
) : null}
</div>
<span className={`shrink-0 text-xs ${copy.className}`}>{copy.label}</span>
</div>
);
}
function MipRow({ mip, advertised }: { mip: Mip; advertised: boolean }) {
const copy = STATE_COPY[mip.state];
return (
<div
className="flex items-center justify-between gap-3 px-1 py-1.5"
data-testid={`mip-row-${mip.id}`}
>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">
{mip.id}
<span className="ml-2 font-normal text-muted-foreground">
{mip.title}
</span>
</p>
</div>
<span className={`shrink-0 text-2xs ${copy.className}`}>
{advertised ? "Advertised" : copy.label}
</span>
</div>
);
}
export function CapabilitiesSettingsCard() {
const [relayUrl, setRelayUrl] = useState<string | undefined>(undefined);
useEffect(() => {
invoke<{ relay_url: string }>("get_active_workspace")
.then((info) => setRelayUrl(info.relay_url))
.catch(() => setRelayUrl(undefined));
}, []);
const relay = useRelayMips(relayUrl);
const advertised = new Set(relay.mips.map((id) => id.toLowerCase()));
const { advertisedNotEnforced } = reconcile(relay);
return (
<section className="min-w-0" data-testid="settings-capabilities">
<SettingsSectionHeader
title="Capabilities"
description={
<>
Meridian Implementation Possibilities (MIPs) document what a relay
and client <em>may</em> implement. A capability pack turns on when
every MIP it needs is enforced by the relay you are connected to —
it is a property of that relay, not a switch in this app.
</>
}
/>
{!relay.reachable ? (
<p
className="mb-4 rounded-lg border border-border/70 bg-background/70 px-4 py-3 text-xs text-muted-foreground"
data-testid="capabilities-unreachable"
>
Could not read this relay's capabilities. The list below is the
registry, not this relay's answer.
</p>
) : relay.mips.length === 0 ? (
<p
className="mb-4 rounded-lg border border-border/70 bg-background/70 px-4 py-3 text-xs text-muted-foreground"
data-testid="capabilities-none"
>
This relay advertises no MIPs yet.
</p>
) : null}
{advertisedNotEnforced.length > 0 ? (
<p
className="mb-4 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-xs text-amber-600 dark:text-amber-400"
data-testid="capabilities-overclaim"
>
This relay advertises {advertisedNotEnforced.join(", ")}, which the
registry does not list as enforced. Treat that support as unverified.
</p>
) : null}
<div className="flex flex-col gap-5">
<section aria-labelledby="capability-packs">
<p
className="text-xs font-semibold text-foreground"
id="capability-packs"
>
Capability packs
</p>
<p className="text-2xs text-muted-foreground">
Domain bundles — a pack is a grouping over the MIP register, never a
separate list.
</p>
<div className="mt-2 flex flex-col gap-2">
{capabilityPacks.map((pack) => (
<PackRow key={pack.id} pack={pack} relay={relay} />
))}
</div>
</section>
<section aria-labelledby="mip-register">
<p
className="text-xs font-semibold text-foreground"
id="mip-register"
>
MIP register
</p>
<p className="text-2xs text-muted-foreground">
Every proposal and its lifecycle state. Only an enforced MIP may be
advertised by a relay.
</p>
<div className="mt-2 flex flex-col divide-y divide-border/50 rounded-lg border border-border/70 bg-background/70 px-3 py-1">
{mips.map((mip) => (
<MipRow
key={mip.id}
mip={mip}
advertised={advertised.has(mip.id.toLowerCase())}
/>
))}
</div>
</section>
</div>
</section>
);
}

View file

@ -4,6 +4,7 @@ import {
Archive,
BellRing,
Bot,
Boxes,
Check,
ChevronDown,
Cpu,
@ -72,6 +73,7 @@ import {
} from "@/shared/theme/useThemePreviewVars";
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel";
import { CapabilitiesSettingsCard } from "./CapabilitiesSettingsCard";
import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard";
@ -95,6 +97,7 @@ export type SettingsSection =
| "notifications"
| "voice"
| "experimental"
| "capabilities"
| "agents"
| "channel-templates"
| "compute"
@ -116,6 +119,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [
"notifications",
"voice",
"experimental",
"capabilities",
"agents",
"channel-templates",
"compute",
@ -224,6 +228,27 @@ export const settingsSections: SettingsSectionDescriptor[] = [
icon: FlaskConical,
keywords: ["preview", "beta", "alpha", "feature flags", "early access"],
},
{
value: "capabilities",
label: "Capabilities",
icon: Boxes,
keywords: [
"mip",
"mips",
"plugin",
"plugins",
"capability",
"pack",
"ais",
"ads-b",
"adsb",
"app-6e",
"2525",
"stanag",
"extensions",
"nip-11",
],
},
{
value: "agents",
label: "Agents",
@ -887,6 +912,8 @@ export function renderSettingsSection(
return <VoiceSettingsCard currentPubkey={props.currentPubkey} />;
case "experimental":
return <ExperimentalFeaturesCard />;
case "capabilities":
return <CapabilitiesSettingsCard />;
case "agents":
return (
<div className="space-y-12">

View file

@ -0,0 +1,140 @@
/**
* MIP registry and relay capability state.
*
* `docs/mips/README.md` is the one registry. `registry.generated.json` is
* produced from it by `just check-mips`, which fails when the two drift — so
* nothing here is hand-maintained, and the client cannot quietly disagree with
* the register about what exists or what state it is in.
*/
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import generated from "./registry.generated.json";
/** Lifecycle state of a MIP. Only `ENFORCED` may be advertised over NIP-11. */
export type MipState =
| "DRAFT"
| "REVIEW"
| "IMPLEMENTABLE"
| "ENFORCED"
| "WITHDRAWN";
export type Mip = {
id: string;
title: string;
layer: string;
state: MipState;
bead: string | null;
};
export type CapabilityPack = {
id: string;
/** MIPs that must all be ENFORCED for the pack to be available. */
requires: string[];
/** Declared unwritten work, or null. A non-null value can never clear. */
missing: string | null;
/** Required ids with no register row — always a subset of `missing`. */
unknown: string[];
/** True only when every requirement is ENFORCED and nothing is missing. */
available: boolean;
/** Requirements not yet ENFORCED, so the UI can say why a pack is dark. */
blockedBy: string[];
};
export const mips: Mip[] = generated.mips as Mip[];
export const capabilityPacks: CapabilityPack[] =
generated.packs as CapabilityPack[];
/** What a relay told us about its own MIP support. */
export type RelayMips = {
/** False means "we could not ask", which is not the same as "none". */
reachable: boolean;
/** Advertised MIP ids, lower-cased (`mip-as`). */
mips: string[];
/** Every other advertised extension. */
extensions: string[];
};
const UNKNOWN: RelayMips = { reachable: false, mips: [], extensions: [] };
/**
* Read the MIPs a relay advertises in NIP-11 `supported_extensions`.
*
* Returns `reachable: false` until the relay answers. A failed probe is
* reported as unknown rather than as an empty advertisement — showing "no
* capabilities" for a relay we never reached is the same class of lie as
* advertising a MIP with no reader.
*/
export function useRelayMips(relayUrl: string | undefined): RelayMips {
const [state, setState] = useState<RelayMips>(UNKNOWN);
useEffect(() => {
if (!relayUrl) {
setState(UNKNOWN);
return;
}
let cancelled = false;
invoke<RelayMips>("fetch_relay_mips", { relayUrl })
.then((result) => {
if (!cancelled) setState(result);
})
.catch(() => {
if (!cancelled) setState(UNKNOWN);
});
return () => {
cancelled = true;
};
}, [relayUrl]);
return state;
}
/** The advertisement id for a MIP: `MIP-AS` -> `mip-as`. */
export function advertisementId(mipId: string): string {
return mipId.toLowerCase();
}
/**
* Reconcile the register against one relay's advertisement.
*
* Two disagreements are worth surfacing rather than smoothing over, because
* each is a bug in a different place:
*
* - `advertisedNotEnforced` — the relay claims a MIP the register does not
* consider `ENFORCED`. This is the NIP-17 failure shape, and the relay is
* wrong.
* - `enforcedNotAdvertised` — the register says `ENFORCED` but this relay does
* not carry it. Legitimate: state is per-deployment, not global.
*/
export function reconcile(relay: RelayMips) {
const advertised = new Set(relay.mips.map((id) => id.toLowerCase()));
const enforced = mips.filter((mip) => mip.state === "ENFORCED");
return {
advertisedNotEnforced: [...advertised].filter(
(id) => !enforced.some((mip) => advertisementId(mip.id) === id),
),
enforcedNotAdvertised: enforced
.filter((mip) => !advertised.has(advertisementId(mip.id)))
.map((mip) => mip.id),
};
}
/**
* Whether a pack is usable on this relay right now.
*
* Both conditions must hold: the register must consider every member MIP
* `ENFORCED`, and this relay must actually advertise them. A pack that is
* available in the register but dark on the connected relay is the normal
* case for a deployment that has not enabled it.
*/
export function packStatus(pack: CapabilityPack, relay: RelayMips) {
if (!relay.reachable) return "unknown" as const;
if (!pack.available) return "unavailable" as const;
const advertised = new Set(relay.mips.map((id) => id.toLowerCase()));
const missing = pack.requires.filter(
(id) => !advertised.has(advertisementId(id)),
);
return missing.length === 0 ? ("active" as const) : ("inactive" as const);
}

View file

@ -0,0 +1,146 @@
{
"$generated": "docs/mips/README.md — edit there, then run `just check-mips --write`",
"mips": [
{
"id": "MIP-RG",
"title": "Proposal registry, lifecycle, numbering",
"layer": "Process",
"state": "DRAFT",
"bead": null
},
{
"id": "MIP-OF",
"title": "Opaque frame envelope",
"layer": "Wire",
"state": "DRAFT",
"bead": "meridian-tdq"
},
{
"id": "MIP-XP",
"title": "Negotiated exchange profiles (P0/P1/P2)",
"layer": "Link",
"state": "DRAFT",
"bead": "meridian-2e2"
},
{
"id": "MIP-SF",
"title": "Session frames",
"layer": "Wire",
"state": "DRAFT",
"bead": "meridian-owl"
},
{
"id": "MIP-QC",
"title": "QoS classes and congestion contracts",
"layer": "Scheduling",
"state": "DRAFT",
"bead": "meridian-9j8"
},
{
"id": "MIP-LF",
"title": "Leaf forwarding",
"layer": "Topology",
"state": "DRAFT",
"bead": null
},
{
"id": "MIP-MQ",
"title": "MQTT interoperability",
"layer": "Interop",
"state": "DRAFT",
"bead": null
},
{
"id": "MIP-KN",
"title": "Kinematic state payload schema",
"layer": "Payload",
"state": "DRAFT",
"bead": "meridian-ss1"
},
{
"id": "MIP-TL",
"title": "Scalar telemetry payload schema",
"layer": "Payload",
"state": "DRAFT",
"bead": null
},
{
"id": "MIP-AS",
"title": "AIS feed profile",
"layer": "Profile",
"state": "DRAFT",
"bead": "meridian-hgjj"
},
{
"id": "MIP-CT",
"title": "Drone backbone telemetry — symbol identity (SIDC/XSIDC) and Compact Track Code",
"layer": "Profile",
"state": "DRAFT",
"bead": "meridian-2fwq"
}
],
"packs": [
{
"id": "AIS",
"requires": [
"MIP-OF",
"MIP-XP",
"MIP-QC",
"MIP-KN",
"MIP-AS"
],
"missing": null,
"unknown": [],
"available": false,
"blockedBy": [
"MIP-OF",
"MIP-XP",
"MIP-QC",
"MIP-KN",
"MIP-AS"
]
},
{
"id": "ADS-B",
"requires": [
"MIP-OF",
"MIP-XP",
"MIP-QC",
"MIP-KN",
"MIP-AD"
],
"missing": "MIP-AD is unwritten",
"unknown": [
"MIP-AD"
],
"available": false,
"blockedBy": [
"MIP-OF",
"MIP-XP",
"MIP-QC",
"MIP-KN",
"MIP-AD"
]
},
{
"id": "APP-6E",
"requires": [
"MIP-CT"
],
"missing": null,
"unknown": [],
"available": false,
"blockedBy": [
"MIP-CT"
]
},
{
"id": "STANAG-4609",
"requires": [],
"missing": "no MIP specified yet",
"unknown": [],
"available": false,
"blockedBy": []
}
]
}

View file

@ -12,7 +12,7 @@ troubleshooting playbooks. Not a changelog and not a design diary — top-level
| Path | Owns |
| --- | --- |
| `nips/` | Repo-local NIP extensions: `NIP-AA`, `AB`, `AE`, `AM`, `AO`, `AP`, `CW`, `DV`, `ER`, `GC`, `GS`, `IA`, `OA`, `PL`, `RS`, `WF`, `WP` — the normative wire contracts. `GC` (git collaboration) and `WF` (channel workflows) are **profiles**: they restrict and pin down kinds defined upstream (NIP-34) or already shipped, rather than adding new ones |
| `mips/` | Meridian Improvement Proposals — repo-local proposals under the two-letter scheme, with `README.md` as the register and lifecycle. `MIP-AS` (AIS feed profile) and `MIP-CT` (drone backbone telemetry — SIDC/XSIDC and the Compact Track Code) are the landed documents; the rest of the suite is specified in [TASKS.md § 5](../TASKS.md) and migrates here under `MRDN-201`. **`ENFORCED` is the only state that permits NIP-11 advertisement** |
| `mips/` | Meridian Implementation Possibilities — repo-local proposals documenting what Meridian-compatible relay and client software **may** implement, under the two-letter scheme, with `README.md` as the register and lifecycle. `MIP-AS` (AIS feed profile) and `MIP-CT` (drone backbone telemetry — SIDC/XSIDC and the Compact Track Code) are the landed documents; the rest of the suite is specified in [TASKS.md § 5](../TASKS.md) and migrates here under `MRDN-201`. **`ENFORCED` is the only state that permits NIP-11 advertisement** |
| `spec/` | Machine-checked models: `MultiTenantRelay.tla`/`.cfg`, `GitOnObjectStore.tla`/`.cfg` (TLA+), `MultiTenantAuth.spthy`, `NIP-AB.spthy` (Tamarin) |
| `formal/` | `STATEFUL_GATEWAY.md` and `nip-pl/` — executable acceptance + mutation tests in Python (`acceptance.py`, `delivery.py`, `*_mutation.py`, `mutation_test.py`, `NOTE.md`) |
| `admin/` | Operator dashboard guide |

View file

@ -1,7 +1,10 @@
MIP registry
============
Meridian Improvement Proposals — repo-local protocol proposals.
Meridian Implementation Possibilities — repo-local proposals documenting what
Meridian-compatible relay and client software **may** implement. A MIP is
optional by construction, exactly as an upstream NIP is; that optionality is
why an implementation has to be able to say which MIPs it actually honours.
`draft`
@ -62,3 +65,23 @@ Onboarding offers domain **capability packs** (AIS, ADS-B, 2525/APP-6E,
STANAG 4609). A pack is a named bundle of MIPs a deployment has moved to
`ENFORCED` — a UI grouping over this register, never a second registry. See
`meridian-4guo`.
A pack is **available** on a relay only when *every* MIP in its row is
`ENFORCED` and advertised by that relay. Partial support is not support: a pack
that lights up while one member MIP is still `DRAFT` is the NIP-17 failure
above, wearing a friendlier label.
| Pack | Requires | Missing |
| --- | --- | --- |
| `AIS` | `MIP-OF`, `MIP-XP`, `MIP-QC`, `MIP-KN`, `MIP-AS` | — |
| `ADS-B` | `MIP-OF`, `MIP-XP`, `MIP-QC`, `MIP-KN`, `MIP-AD` | `MIP-AD` is unwritten |
| `APP-6E` | `MIP-CT` | — |
| `STANAG-4609` | — | no MIP specified yet |
The `Requires` column is the dependency closure declared by each profile MIP's
header — for `AIS`, the `Depends on` line of [MIP-AS](MIP-AS.md). A pack whose
`Missing` cell is non-empty can never become available; it names the work.
**This table is the source of the client's pack view.** `just check-mips`
regenerates `desktop/src/shared/mips/registry.generated.json` from this file and
fails when the two disagree, so the register stays the one registry.

View file

@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* Generate the desktop client's MIP registry from `docs/mips/README.md`, and
* fail when the committed copy has drifted.
*
* `docs/mips/README.md` is the one registry. The client needs the same data to
* render its capability view; this script is the only sanctioned way for it to
* get there, so the doc stays authoritative and the copy stays honest.
*
* node scripts/check-mip-registry.mjs # verify (CI)
* node scripts/check-mip-registry.mjs --write # regenerate
*/
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildRegistry, validateRegistry } from "./mip-registry-core.mjs";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const SOURCE = resolve(root, "docs/mips/README.md");
const GENERATED = resolve(root, "desktop/src/shared/mips/registry.generated.json");
const write = process.argv.includes("--write");
const registry = buildRegistry(readFileSync(SOURCE, "utf8"));
const errors = validateRegistry(registry);
if (errors.length > 0) {
console.error("docs/mips/README.md is not a valid register:\n");
for (const error of errors) console.error(` - ${error}`);
process.exit(1);
}
// The banner rides in the artifact so anyone who opens it is told where to edit.
const payload = `${JSON.stringify(
{
$generated: "docs/mips/README.md — edit there, then run `just check-mips --write`",
...registry,
},
null,
2,
)}\n`;
if (write) {
writeFileSync(GENERATED, payload);
console.log(
`check-mips: wrote ${registry.mips.length} MIPs and ${registry.packs.length} packs`,
);
process.exit(0);
}
let current = null;
try {
current = readFileSync(GENERATED, "utf8");
} catch {
console.error(
`check-mips: ${GENERATED} is missing.\nRun: just check-mips --write`,
);
process.exit(1);
}
if (current !== payload) {
console.error(
"check-mips: desktop/src/shared/mips/registry.generated.json is stale.\n" +
"docs/mips/README.md changed without regenerating the client copy.\n" +
"Run: just check-mips --write",
);
process.exit(1);
}
const advertisable = registry.mips.filter((mip) => mip.state === "ENFORCED");
console.log(
`check-mips: ok — ${registry.mips.length} MIPs (${advertisable.length} ENFORCED), ` +
`${registry.packs.filter((pack) => pack.available).length}/${registry.packs.length} packs available`,
);

View file

@ -0,0 +1,197 @@
/**
* Parse `docs/mips/README.md` into the MIP register and the capability packs
* derived from it.
*
* The register is the one registry. The desktop client needs the same data to
* render its capability view, and a hand-maintained TypeScript copy is exactly
* the "second registry" that `docs/mips/README.md` forbids — so the copy is
* generated from this parser and a check fails when the two disagree.
*
* Pure: the caller supplies the already-read markdown, so the logic is testable
* without a fixture tree.
*/
/** Lifecycle states, in order. Only `ENFORCED` permits NIP-11 advertisement. */
export const MIP_STATES = ["DRAFT", "REVIEW", "IMPLEMENTABLE", "ENFORCED", "WITHDRAWN"];
/** The only state a relay may advertise. See `docs/mips/README.md` § Lifecycle. */
export const ADVERTISABLE_STATE = "ENFORCED";
/** Strip markdown link and code wrappers: ``[`MIP-AS`](MIP-AS.md)`` -> `MIP-AS`. */
function bare(cell) {
return cell
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`/g, "")
.trim();
}
/** Split one markdown table row into trimmed cells. */
function cells(line) {
return line
.replace(/^\s*\|/, "")
.replace(/\|\s*$/, "")
.split("|")
.map((cell) => cell.trim());
}
/** True for a table separator row (`| --- | --- |`). */
function isSeparator(line) {
return /^\s*\|[\s:|-]+\|\s*$/.test(line);
}
/**
* Collect the rows of the first markdown table appearing after `heading`.
*
* @param {string} source Markdown.
* @param {RegExp} heading Matches the heading line that introduces the table.
* @returns {string[][]} Data rows as cell arrays, header and separator dropped.
*/
function tableAfter(source, heading) {
const lines = source.split("\n");
const start = lines.findIndex((line) => heading.test(line));
if (start === -1) return [];
const rows = [];
let seenHeader = false;
for (let i = start + 1; i < lines.length; i += 1) {
const line = lines[i];
const isRow = /^\s*\|/.test(line);
if (!isRow) {
if (rows.length > 0 || seenHeader) break;
continue;
}
if (isSeparator(line)) continue;
if (!seenHeader) {
seenHeader = true;
continue;
}
rows.push(cells(line));
}
return rows;
}
/** An em dash or empty cell both mean "nothing here". */
function optional(cell) {
const value = bare(cell);
return value === "" || value === "—" || value === "-" ? null : value;
}
/**
* Parse the `## Register` table.
*
* @param {string} source Contents of `docs/mips/README.md`.
* @returns {{id: string, title: string, layer: string, state: string, bead: string|null}[]}
*/
export function parseRegister(source) {
return tableAfter(source, /^##\s+Register\s*$/).map((row) => {
const [id, title, layer, state, bead] = row;
const parsedState = bare(state).toUpperCase();
if (!MIP_STATES.includes(parsedState)) {
throw new Error(`${bare(id)}: unknown lifecycle state "${bare(state)}"`);
}
return {
id: bare(id),
title: bare(title),
layer: bare(layer),
state: parsedState,
bead: optional(bead),
};
});
}
/**
* Parse the capability-pack table under "Capability packs are not MIPs".
*
* @param {string} source Contents of `docs/mips/README.md`.
* @returns {{id: string, requires: string[], missing: string|null}[]}
*/
export function parsePacks(source) {
return tableAfter(source, /^##\s+Capability packs are not MIPs\s*$/).map((row) => {
const [id, requires, missing] = row;
const required = bare(requires)
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry !== "" && entry !== "—" && entry !== "-");
return { id: bare(id), requires: required, missing: optional(missing) };
});
}
/**
* Build the generated registry: the register, plus each pack resolved against it.
*
* A pack is `available` only when every required MIP is `ENFORCED`. `blockedBy`
* names the MIPs that are not, so the client can say *why* a capability is dark
* instead of just hiding it.
*
* @param {string} source Contents of `docs/mips/README.md`.
*/
export function buildRegistry(source) {
const mips = parseRegister(source);
const byId = new Map(mips.map((mip) => [mip.id, mip]));
const packs = parsePacks(source).map((pack) => {
const unknown = pack.requires.filter((id) => !byId.has(id));
const blockedBy = pack.requires.filter((id) => byId.get(id)?.state !== ADVERTISABLE_STATE);
return {
id: pack.id,
requires: pack.requires,
missing: pack.missing,
unknown,
// A pack with no requirements is not "all requirements met" — it is
// unspecified, and must never render as available.
available: pack.requires.length > 0 && blockedBy.length === 0 && pack.missing === null,
blockedBy,
};
});
return { mips, packs };
}
/**
* Validate the registry for the conditions a reader cannot recover from.
*
* @returns {string[]} Human-readable errors; empty means valid.
*/
export function validateRegistry(registry) {
const errors = [];
const seen = new Set();
for (const mip of registry.mips) {
if (!/^MIP-[A-Z]{2}$/.test(mip.id)) {
errors.push(`${mip.id}: id must be MIP- plus two uppercase letters`);
}
if (seen.has(mip.id)) errors.push(`${mip.id}: duplicate register row`);
seen.add(mip.id);
}
for (const pack of registry.packs) {
for (const id of pack.unknown) {
// A pack may name a MIP nobody has written yet — that is how the register
// states planned work. It may not do so *silently*: the Missing cell has
// to name the id, or the gap is indistinguishable from a typo.
if (pack.missing === null || !pack.missing.includes(id)) {
errors.push(
`pack ${pack.id}: requires ${id}, which has no register row and is not named in Missing`,
);
}
}
if (pack.available && pack.missing !== null) {
errors.push(`pack ${pack.id}: available yet declares missing work`);
}
}
return errors;
}
/**
* Split NIP-11 `supported_extensions` into MIP ids and everything else.
*
* Mirrors `split_extensions` in `crates/meridian-control-plane/src/http.rs`.
* The prefix is the contract: a repo-local proposal advertises as `mip-xx`.
*/
export function splitExtensions(values) {
const mips = [];
const extensions = [];
for (const value of values ?? []) {
if (String(value).toLowerCase().startsWith("mip-")) mips.push(String(value));
else extensions.push(String(value));
}
return { mips, extensions };
}

View file

@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import {
buildRegistry,
parsePacks,
parseRegister,
splitExtensions,
validateRegistry,
} from "./mip-registry-core.mjs";
const DOC = `
MIP registry
============
## Register
| MIP | Title | Layer | State | Bead |
| --- | --- | --- | --- | --- |
| \`MIP-OF\` | Opaque frame envelope | Wire | \`DRAFT\` | \`meridian-tdq\` |
| \`MIP-XP\` | Negotiated exchange profiles | Link | \`ENFORCED\` | — |
| [\`MIP-AS\`](MIP-AS.md) | AIS feed profile | Profile | \`DRAFT\` | \`meridian-hgjj\` |
| [\`MIP-CT\`](MIP-CT.md) | Compact track code | Profile | \`ENFORCED\` | — |
A profile MIP adds no mechanism.
## Capability packs are not MIPs
| Pack | Requires | Missing |
| --- | --- | --- |
| \`AIS\` | \`MIP-OF\`, \`MIP-AS\` | — |
| \`APP-6E\` | \`MIP-CT\` | — |
| \`ADS-B\` | \`MIP-XP\`, \`MIP-AD\` | \`MIP-AD\` is unwritten |
| \`STANAG-4609\` | — | no MIP specified yet |
`;
describe("parseRegister", () => {
test("reads every row and unwraps link and code syntax", () => {
const rows = parseRegister(DOC);
assert.equal(rows.length, 4);
assert.deepEqual(rows[2], {
id: "MIP-AS",
title: "AIS feed profile",
layer: "Profile",
state: "DRAFT",
bead: "meridian-hgjj",
});
});
test("an em-dash bead is null, not the dash", () => {
assert.equal(parseRegister(DOC)[1].bead, null);
});
test("an unknown lifecycle state throws rather than passing through", () => {
const bad = DOC.replace("| Wire | `DRAFT` |", "| Wire | `SHIPPED` |");
assert.throws(() => parseRegister(bad), /unknown lifecycle state/);
});
test("stops at the end of the table and does not swallow later tables", () => {
assert.equal(parseRegister(DOC).length, 4);
});
});
describe("parsePacks", () => {
test("splits the requires cell into ids", () => {
const packs = parsePacks(DOC);
assert.deepEqual(packs[0], { id: "AIS", requires: ["MIP-OF", "MIP-AS"], missing: null });
});
test("an empty requires cell yields no requirements", () => {
const stanag = parsePacks(DOC).find((pack) => pack.id === "STANAG-4609");
assert.deepEqual(stanag.requires, []);
assert.equal(stanag.missing, "no MIP specified yet");
});
});
describe("buildRegistry", () => {
test("a pack is available only when every required MIP is ENFORCED", () => {
const { packs } = buildRegistry(DOC);
const ais = packs.find((pack) => pack.id === "AIS");
// MIP-OF and MIP-AS are both DRAFT, so AIS stays dark and says why.
assert.equal(ais.available, false);
assert.deepEqual(ais.blockedBy, ["MIP-OF", "MIP-AS"]);
});
test("a pack whose single MIP is ENFORCED is available", () => {
const appge = buildRegistry(DOC).packs.find((pack) => pack.id === "APP-6E");
assert.equal(appge.available, true);
assert.deepEqual(appge.blockedBy, []);
});
test("a pack with no requirements is unspecified, never available", () => {
const stanag = buildRegistry(DOC).packs.find((pack) => pack.id === "STANAG-4609");
assert.equal(stanag.available, false);
});
test("declared missing work keeps a pack dark even if its listed MIPs pass", () => {
const adsb = buildRegistry(DOC).packs.find((pack) => pack.id === "ADS-B");
// MIP-XP is ENFORCED, but MIP-AD has no register row at all.
assert.equal(adsb.available, false);
assert.deepEqual(adsb.unknown, ["MIP-AD"]);
});
});
describe("validateRegistry", () => {
test("an unwritten MIP named in the Missing cell is planned work, not an error", () => {
// ADS-B requires MIP-AD, which has no row — but the row says so.
assert.deepEqual(validateRegistry(buildRegistry(DOC)), []);
});
test("an unwritten MIP the Missing cell does not name is reported", () => {
const silent = DOC.replace("| `MIP-AD` is unwritten |", "| — |");
const errors = validateRegistry(buildRegistry(silent));
assert.ok(errors.some((error) => /ADS-B: requires MIP-AD/.test(error)));
});
test("a malformed MIP id is reported", () => {
const bad = DOC.replace("`MIP-OF`", "`MIP-OFX`");
const errors = validateRegistry(buildRegistry(bad));
assert.ok(errors.some((error) => /two uppercase letters/.test(error)));
});
test("a well-formed register with no dangling packs is clean", () => {
const clean = DOC.replace("| \`ADS-B\` | \`MIP-XP\`, \`MIP-AD\` |", "| \`ADS-B\` | \`MIP-XP\` |");
assert.deepEqual(validateRegistry(buildRegistry(clean)), []);
});
});
describe("splitExtensions", () => {
test("splits on the mip- prefix, case-insensitively", () => {
const { mips, extensions } = splitExtensions(["nip-er", "mip-as", "MIP-CT"]);
assert.deepEqual(mips, ["mip-as", "MIP-CT"]);
assert.deepEqual(extensions, ["nip-er"]);
});
test("a missing list is empty, not a throw", () => {
assert.deepEqual(splitExtensions(undefined), { mips: [], extensions: [] });
});
});