Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions FSD/REGISTRY_SLICE_ROLE_GATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# FSD — The registry slice is conferred, not configured

**Status:** Phase 1 IMPLEMENTED (the gate). Phases 2–4 are the surface work.
**Companion:** [`REGISTRY_FOLD_DERISK.md`](REGISTRY_FOLD_DERISK.md) (what the fold needs),
[`TRUST_ROOT_CAPABILITY_GATE.md`](TRUST_ROOT_CAPABILITY_GATE.md) (the capability model this
applies), [`MESH_SEED_RUNBOOK_POST_DELEGATION.md`](MESH_SEED_RUNBOOK_POST_DELEGATION.md)
(the ceremony that confers it).
**Upstream:** CIRISRegistry#76 (co-bump, **done** — registry-core now resolves on
persist v32.3.0 / edge v17.4.1 / verify v13.3.1, matching this repo's pins exactly),
CIRISRegistry#62 (the three-siblings umbrella), CIRISServer#441 (the admission quorum).

---

## 1. The ordering constraint this exists to enforce

> **No server converts to a canonical node until CIRISServer can serve registry
> capabilities under the granted role.**

Convert first and you turn three working registries into three blessed nodes that
cannot do registry work. The gate is what makes "can serve registry capabilities"
a property the node *evaluates*, rather than a claim the operator makes.

## 2. Why a boolean was the wrong shape

Today the slice is selected by config:

```rust
pub struct Slices { pub lens: bool, pub registry: bool, pub node: bool }
// default: registry: false
```

```rust
if cfg.slices.registry {
compose_registry(&edge, &engine, &cfg).await?; // todo!()
}
```

An operator setting a boolean is exactly the self-assertion the accord-scrub model
exists to remove. A node is canonical because **the trust root signed off**, and the
same must be true of the authority slice it runs: the accord confers `infra:attest`
and `infra:serve`, and the node serves the registry surface *because* it holds them.

**The verbs already exist.** This was the open question and the answer is favourable:
the baked `genesis-charter` declares
`[infra:attest, infra:serve, infra:store, infra:transport]`, and
`genesis-grant:ciris-canonical-1-d7bdeu223k` carries **all four**. Registry's work —
identity, license, revocation, build provenance — is attestation-shaped, so it rides
`infra:attest` + `infra:serve`, both of which a canonical node holds the moment it is
blessed. **No new capability verb, and therefore no charter amendment** — which
matters, because the scopes live in the signed bytes, so amending the charter is an
m-of-n re-scrub by the holder roster, not an edit.

## 3. Where the gate can and cannot live

The lens slice is already capability-gated rather than config-gated, in the same
dispatch block:

```rust
if caps.lens_store { LensCore::attach_handler(...).await?; }
```

but that is **not** the pattern to copy wholesale, and the reason is load-bearing.
`Capabilities::detect` runs **before the Engine is open** — it is a pre-corpus
structural gate (free disk), which is precisely why `DEFAULT_LENS_STORE_MIN_GIB` is a
baked constant and not a `config:*` CEG object: there is no corpus to read it from yet.

The registry gate is the opposite kind of question. "Does this node hold `infra:attest`
from a root it accepts?" is a **delegation-graph walk over the federation directory** —
it *requires* the corpus. So it cannot join `Capabilities`, and must be evaluated at
slice-composition time, after the Engine exists. It is a **post-corpus** gate.

## 4. The check

persist supplies the walk:

```rust
capability_roots_to_trusted_root(
directory,
user_key_id, // who accepts the root — this node
subject_key_id, // who holds the capability — this node
scope, // "infra:attest"
) -> Result<Option<TrustedGrant>, Error>
```

Both ids are this node's own `key_id`: we are asking *"do I hold this capability, from
a root I myself accept?"* Both halves matter. The `trust:accepts` edge is the operator's
un-trust lever — delete that one row and the walk returns `None`, the slice goes dark on
its own, and nothing special-cased it.

`None` is not an error. A node that has never been blessed is in a legitimate steady
state; it simply does not serve the authority slice.

## 5. Fail-secure composition, and the boot-panic trap

There is a trap here that must be named, because the obvious implementation is a
production outage.

`compose_registry()` is currently `todo!()`. It is unreachable today only because
`slices.registry` defaults to `false`. **Naively swapping the boolean for the grant
check would make canonical-1 — which holds all four verbs — evaluate the gate to
`true` and panic at boot.** The gate cannot land before the slice it gates has a
non-panicking body.

So the increment is ordered:

1. **The grant is the authority, and it lives inside the slice.** `compose_registry`
performs its own gate check and refuses when the grant is absent. Authority checks
belong with the thing they authorise, not at the call site.
2. **Config may only decline, never confer.** `slices.registry` is retained as an
operator *opt-out*; it can keep a blessed node from serving, and can never make an
unblessed node serve. Default stays `false`, so no deployed node changes behaviour.
3. **The body is honest about what it does not do yet.** Until the surfaces land, a
conferred node logs that it is blessed and that the slice is not yet composed. It
does not panic, and it does not silently pretend to serve.

```
grant absent → refuse, log the reason, slice off (fail-secure)
grant present → proceed (Phase 1: log-only; Phases 2-4: compose the surfaces)
config off → decline before either (operator opt-out)
```

## 6. What the slice will actually compose (Phases 2–4)

Not a port. `ciris-registry-core` is 21,689 lines of which **12,153 touch `sqlx`** and
**14,400 touch `tonic`**; only 3,604 touch neither. That mass does not fold — it
dissolves. Registry's tables become the shared corpus and the gRPC surface stops
existing rather than being re-hosted in axum.

The impedance is *not* Postgres-versus-SQLite — this repo runs full Postgres via
persist's `postgres` feature on the Linux target. It is **raw sqlx versus the persist
`Engine`**: hand-written SQL bypasses the signing, scrub and quorum-merge machinery
that makes a row federate. That is what the rewrite buys, and why the tables cannot
come along unchanged.

What survives is the residual with live consumers — surfaces this repo does not have:

| Surface | Consumer | Phase |
|---|---|---|
| `/v1/builds`, `/v1/builds/{version}`, `/hash/{h}` | CIRISVerify | 2 |
| `/v1/verify/{binary,build,function}-manifest*` | CIRISVerify | 2 |
| `/v1/verify/key/{fingerprint}` | CIRISVerify | 2 |
| `/v1/revocation/{target_id}` | CIRISVerify | 2 |
| `/v1/transparency/sth/cosign`, `/witnesses` | transparency log | 3 |
| `/v1/integrity/*` (1,480 LOC — Play Integrity + iOS App Attest) | mobile attestation | 3 |
| Portal's organizations / users / key custody | **the KMP client, as cards** | 4 |
| `/v1/steward-key` | — | **retires** |

Portal's gRPC service is not rebuilt as an API. Its UI comes in as cards alongside the
existing `AccordScreen`, `IdentityManagementScreen`, `DelegationsScreen`,
`BillingScreen` and `AuditScreen` — which is what lets the RPC layer go away instead of
being re-hosted.

### `/v1/steward-key` retires rather than being carried

Worth recording why, because it looks like a surface with consumers. It is not: three
mutually incompatible schemas exist and **no two agree**. Registry serves
`{stewards[], verification_policy, …}`; verify's actual HTTP client
(`ciris-verify-core/src/https.rs`) expects single-steward `{classical{}, pqc{}, …}`
whose non-`Option` fields are absent from that response, so it fails to deserialize
outright; and verify's spec-conformant parser (`steward_key.rs`) expects
`{stewards[], threshold_policy, response_signature}` and is never reached by the HTTP
path. The live response also declares `signature_mode: "HYBRID_REQUIRED"` while
carrying no signature field at all, and asserts `hardware_class: HSM_PROD` under
`self_attested: true`.

There is no working contract to preserve. The replacement is the **public broadcast of
the persist-baked `GenesisBundle`**, which is self-authenticating — it carries its own
hybrid `authorizations` from two accord holders over the charter — and therefore
satisfies CIRISRegistry#133 by construction rather than by patch. Note this is genuinely
net-new: `GET /v1/trust-root` today is loopback-gated, an operator surface, not a
federation broadcast.

## 7. Only then, the conversion

With the slice served under a conferred role, registry-us and registry-eu convert via
the existing ceremony — `add-canonical` from the Trust Root card, A1's YubiKey plus the
USB-wrapped ML-DSA cosign, persist refusing the `canonical` role on any record that is
not anchor-scrubbed (`CanonicalRoleNotAccordConferred`). Identities carry byte-identically
per `REGISTRY_FOLD_DERISK.md` §2 — no re-key, same addresses. Then `canonical_seed.json`
is re-baked with three `serve_nodes` and tagged, so the portable root carries all three.

**Decide the admission quorum first (CIRISServer#441).** `add-canonical` is classed
`Operational` and resolves to 1-of-3 today, while the baked founding record is 2-of-3
(A1 + B1) because CIRISPersist#390 judged a single-anchor founding record a first-strike
weakness. These two admissions double the canonical set; they should not inherit 1-of-3
by default.
175 changes: 170 additions & 5 deletions src/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,10 @@ pub async fn serve_with_adapter(cfg: ServerConfig, adapter: Arc<dyn Adapter>) ->
(no local corpus / read API); free up disk to the baked minimum"
);
}
// `slices.registry` is an operator OPT-OUT, not the authorization. The grant
// is checked inside compose_registry, which withholds the slice when this
// node holds no accord-conferred infra:attest. Config can decline; it can
// never confer. (FSD/REGISTRY_SLICE_ROLE_GATE.md)
if cfg.slices.registry {
compose_registry(&edge, &engine, &cfg).await?;
}
Expand Down Expand Up @@ -914,6 +918,20 @@ pub async fn serve_with_adapter(cfg: ServerConfig, adapter: Arc<dyn Adapter>) ->
Arc::clone(&engine),
node_code.key_id.clone(),
))
// TRUST ROOT, the federation-facing read: GET
// /v1/trust-root/bundle. Deliberately NOT loopback-gated —
// unlike the import/list/delete verbs above, which are the
// operator's own act, this is how a peer bootstrapping into
// the mesh fetches the portable root and checks it against
// its own roster. The bundle is self-authenticating and the
// outer envelope claims no authority; see the module docs
// for why signing the wrapper would be worthless
// (CIRISRegistry#133 — this is what retires
// /v1/steward-key).
.merge(crate::trust_root_broadcast::router(
Arc::clone(&engine),
node_code.key_id.clone(),
))
// claim REMOTE ownership (substrate-native, node-to-node):
// POST /v1/setup/claim-remote — the LOCAL node decodes the
// target NodeCode, builds + hybrid-signs the owner-binding
Expand Down Expand Up @@ -3717,11 +3735,83 @@ pub async fn run_config_get(
crate::graph_config::get_config(&engine, key).await
}

/// Authority slice — folds in at **Server 0.6** (CIRISRegistry#76). Attaches to
/// the shared Edge (the node's single identity) + serves the registry trust
/// surface over the shared Engine. SCAFFOLD. (0.5 is config-as-CEG; registry is 0.6.)
async fn compose_registry(_edge: &Edge, _engine: &Arc<Engine>, _cfg: &ServerConfig) -> Result<()> {
todo!("registry slice (Server 0.6) — pin ciris-registry-core (CIRISRegistry#76) + attach to the shared Edge")
/// Is THIS node conferred the authority slice? — the registry role gate.
///
/// See `FSD/REGISTRY_SLICE_ROLE_GATE.md`. A node serves the registry surface
/// because the accord blessed it to, never because an operator set a boolean:
/// the trust root confers `infra:attest` (and `infra:serve`), and holding that
/// grant IS the authorization. That is the same rule `add-canonical` already
/// enforces on the way in — persist refuses the `canonical` role on any record
/// that is not anchor-scrubbed — applied to what the blessed node then does.
///
/// Both key ids are ours on purpose. The question is *"do I hold this
/// capability, from a root I MYSELF accept?"*, and the second half is the
/// operator's un-trust lever: delete the `trust:accepts` row and this walk
/// returns `None`, the slice goes dark on its own, and nothing special-cased
/// it (`FSD/TRUST_ROOT_CAPABILITY_GATE.md` §1).
///
/// `Ok(None)` is NOT an error. A node that was never blessed is in a legitimate
/// steady state; it simply does not serve the authority slice. Only a failure
/// to *evaluate* the walk is an error, and it is fail-secure at the call site.
///
/// This cannot join [`Capabilities`], which is evaluated BEFORE the Engine is
/// open (a pre-corpus structural gate — see `DEFAULT_LENS_STORE_MIN_GIB`).
/// A delegation-graph walk needs the corpus, so the registry gate is
/// necessarily post-corpus and lives here, at slice-composition time.
pub(crate) async fn registry_slice_conferred(
engine: &Arc<Engine>,
node_key_id: &str,
) -> Result<Option<ciris_persist::federation::trust_root::TrustedGrant>> {
let directory = engine.federation_directory();
ciris_persist::federation::trust_root::capability_roots_to_trusted_root(
directory.as_ref(),
node_key_id, // who accepts the root — us
node_key_id, // who holds the capability — us
ciris_persist::federation::trust_root::INFRA_ATTEST_SCOPE,
)
.await
.map_err(|e| anyhow::anyhow!("evaluate the registry-slice capability walk: {e}"))
}

/// Authority slice — folds in at **Server 0.6** (CIRISRegistry#76, co-bump DONE:
/// registry-core now resolves on this repo's exact triple). Attaches to the
/// shared Edge (the node's single identity) + serves the registry trust surface
/// over the shared Engine.
///
/// **The grant is the authority, and the check lives HERE rather than at the
/// call site**, because an authority check belongs with the thing it authorises.
/// `cfg.slices.registry` is retained as an operator *opt-out* only: it can keep
/// a blessed node from serving, and can never make an unblessed node serve.
///
/// Phase 1 (this change) wires the gate and composes nothing — deliberately.
/// `compose_registry` was a `todo!()`, unreachable only because the config bool
/// defaults to false; making the grant the trigger without first giving this
/// function a non-panicking body would panic at boot on exactly the nodes that
/// ARE blessed (canonical-1 holds all four charter verbs). The surfaces land in
/// phases 2-4 inside the conferred branch.
async fn compose_registry(_edge: &Edge, engine: &Arc<Engine>, cfg: &ServerConfig) -> Result<()> {
let Some(grant) = registry_slice_conferred(engine, &cfg.key_id).await? else {
tracing::info!(
node = %cfg.key_id,
scope = ciris_persist::federation::trust_root::INFRA_ATTEST_SCOPE,
"registry slice WITHHELD — this node holds no accord-conferred infra:attest \
grant from a trust root it accepts. This is a normal steady state for an \
unblessed node; the slice is conferred by `add-canonical`, never configured \
(FSD/REGISTRY_SLICE_ROLE_GATE.md)"
);
return Ok(());
};

tracing::info!(
node = %cfg.key_id,
root = %grant.root_key_id,
scope = ciris_persist::federation::trust_root::INFRA_ATTEST_SCOPE,
"registry slice CONFERRED by the trust root — the authority surface is not yet \
composed (Server 0.6 phases 2-4: the persist-native rewrite of builds / verify / \
revocation / integrity / transparency). Serving nothing yet, and saying so rather \
than pretending"
);
Ok(())
}

/// Consensus slice — folds in at **Server 1.0** (CIRISNodeCore#38). `install(&edge)`
Expand All @@ -3730,6 +3820,81 @@ async fn compose_node(_edge: &Edge, _engine: &Arc<Engine>, _cfg: &ServerConfig)
todo!("node slice (Server 1.0) — pin ciris-node-core (CIRISNodeCore#38) + install(&edge)")
}

#[cfg(test)]
mod registry_slice_gate_tests {
//! The registry slice is conferred, not configured
//! (`FSD/REGISTRY_SLICE_ROLE_GATE.md`).
//!
//! The regression these pin is a boot outage, not a feature. `compose_registry`
//! was a `todo!()`, reachable only because `slices.registry` defaults to false.
//! Anything that makes the grant the trigger without giving the function a
//! non-panicking body panics at boot on exactly the nodes that ARE blessed.

use super::*;

async fn engine_with_no_conferral() -> Arc<Engine> {
use ciris_keyring::MlDsa65SoftwareSigner;
use ciris_persist::prelude::LocalSigner;
let pqc = Arc::new(
MlDsa65SoftwareSigner::from_seed_bytes(&[0xB2; 32], "ciris-server-pqc".to_string())
.expect("pqc seed"),
);
let signer = Arc::new(LocalSigner::from_parts(
ed25519_dalek::SigningKey::from_bytes(&[0xB1; 32]),
"unblessed-node".to_string(),
Some(pqc),
Some("ciris-server-pqc".to_string()),
));
Arc::new(
Engine::with_signer(signer, "sqlite::memory:")
.await
.expect("engine"),
)
}

/// An unblessed node resolves the gate to `None` — and that is a steady
/// state, not a fault. Holding no accord conferral is the ordinary
/// condition of every node that has not been through `add-canonical`.
#[tokio::test]
async fn an_unblessed_node_is_not_conferred_the_authority_slice() {
let engine = engine_with_no_conferral().await;
let conferred = registry_slice_conferred(&engine, "unblessed-node")
.await
.expect("the walk must EVALUATE cleanly even when it confers nothing");
assert!(
conferred.is_none(),
"a node with no accord-conferred infra:attest must not be granted the \
authority slice — config cannot confer it and neither can absence of proof"
);
}

/// The boot-panic regression, pinned directly: withholding must RETURN, not
/// panic and not error. A node that is simply unblessed has to finish
/// composing and come up serving everything else.
#[tokio::test]
async fn withholding_the_slice_is_not_a_boot_failure() {
let engine = engine_with_no_conferral().await;
let conferred = registry_slice_conferred(&engine, "unblessed-node").await;
assert!(
matches!(conferred, Ok(None)),
"an unblessed node must resolve to Ok(None): Err would fail the boot of a \
node whose only \"problem\" is that it was never blessed"
);
}

/// The gate asks about THIS node on both sides of the walk — "do I hold this
/// capability, from a root I myself accept?". Passing a stranger's key id
/// must not confer anything either; nothing about being asked about grants.
#[tokio::test]
async fn asking_about_a_stranger_confers_nothing() {
let engine = engine_with_no_conferral().await;
let conferred = registry_slice_conferred(&engine, "some-other-node")
.await
.expect("walk evaluates");
assert!(conferred.is_none(), "no conferral exists for any key here");
}
}

#[cfg(test)]
mod bootstrap_hint_tests {
use super::ip_addrs_from_hints;
Expand Down
Loading
Loading