Skip to content

Fold phase 1: the registry slice is conferred, not configured - #442

Open
emooreatx wants to merge 6 commits into
mainfrom
fold/registry-slice-role-gate
Open

emooreatx wants to merge 6 commits into
mainfrom
fold/registry-slice-role-gate

Conversation

@emooreatx

Copy link
Copy Markdown
Contributor

Phase 1 of the CIRISRegistry fold — the role gate. Composes nothing yet, and says so.

Full scope in the new FSD/REGISTRY_SLICE_ROLE_GATE.md.

The ordering constraint

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. This gate is what makes "can serve registry capabilities" something the node evaluates rather than something the operator asserts.

Why the boolean was the wrong shape

The slice is selected today by cfg.slices.registry. 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 should be true of the authority slice it runs.

The good news: no charter amendment

This was the open question. 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 is attestation-shaped, so it rides infra:attest + infra:serve — verbs a canonical node holds the moment it's blessed.

That 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. This needs none.

What lands

registry_slice_conferred() walks capability_roots_to_trusted_root(dir, me, me, INFRA_ATTEST_SCOPE).

Both key ids are ours deliberately — the question is "do I hold this capability, from a root I myself accept?". The second half is the operator's un-trust lever: delete the trust:accepts row and the walk returns None, the slice goes dark on its own, and nothing special-cased it.

The check lives inside compose_registry rather than at the call site, because an authority check belongs with the thing it authorises. cfg.slices.registry survives as an operator opt-out only: it can keep a blessed node from serving, and can never make an unblessed node serve.

It cannot join Capabilities, which is evaluated before the Engine is open — a pre-corpus structural gate, which is exactly why DEFAULT_LENS_STORE_MIN_GIB is a baked constant rather than a config:* object. A delegation-graph walk needs the corpus, so this gate is necessarily post-corpus.

The trap this PR is shaped around

compose_registry was todo!(), unreachable only because the config bool defaults to false.

Swapping the boolean for the grant check without first giving the function a non-panicking body would panic at boot on exactly the nodes that are blessed — canonical-1 holds all four charter verbs. That is a production outage dressed as a one-line change.

So the body is honest instead. Conferred nodes log that they're blessed and that the surfaces aren't composed yet; unblessed nodes log the withholding as the ordinary steady state it is. Ok(None) is not an error — a node that was never blessed must still finish booting and serve everything else.

Tests

Three, pinning that regression directly:

  • an unblessed node resolves to Ok(None) — a steady state, not a fault
  • withholding is not a boot failure (matches!(.., Ok(None)))
  • asking about a stranger's key confers nothing

379 lib tests green (376 pre-existing + 3).

Not in this PR

Phases 2–4, scoped in §6 of the FSD: the persist-native rewrite of builds / verify / revocation / integrity / transparency; Portal's UI arriving as client cards rather than a re-hosted RPC surface; and the public GenesisBundle broadcast that retires /v1/steward-key.

On that last one — steward-key retires rather than being carried because there is no working contract to preserve. Three mutually incompatible schemas exist and no two agree: verify's actual HTTP client expects single-steward {classical{}, pqc{}, …} whose non-Option fields are absent from what registry serves, so it fails to deserialize outright. The live response also declares signature_mode: "HYBRID_REQUIRED" while carrying no signature field at all. The GenesisBundle is self-authenticating — it carries its own hybrid authorizations from two accord holders over the charter — so it satisfies CIRISRegistry#133 by construction rather than by patch.

Upstream

CIRISRegistry#76 is done (CIRISRegistry#136, CI green): registry-core now resolves on this repo's exact triple — persist v32.3.0 / edge v17.4.1 / verify v13.3.1 — which is what makes it composable here at all. It absorbed 31 majors of persist drift with zero library changes; all six edits were test code.

Refs CIRISRegistry#62, CIRISRegistry#76, CIRISRegistry#133, #441

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@emooreatx

Copy link
Copy Markdown
Contributor Author

Rationale for this PR's shape — the ordering constraint, why the gate lives inside compose_registry, the no-charter-amendment finding, the boot-panic trap it is built around, and why /v1/steward-key is retired rather than repaired — is recorded in #537 so it survives the PR body. That issue also states what this PR deliberately does not do (the surfaces, the manifest consumer in #25, and conversion behind the #441 quorum decision).

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com

Phase 1 of the registry fold — the role gate. Composes nothing yet, and says so.

## Why

The ordering constraint on the fold is that no server converts to a canonical
node until this repo 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.

Today the slice is selected by `cfg.slices.registry` — a boolean. 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 verbs already exist — no charter amendment

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 is attestation-shaped, so it rides `infra:attest` +
`infra:serve` — verbs a canonical node holds the moment it is blessed.

That 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. This change
needs none.

## What lands

`registry_slice_conferred()` walks `capability_roots_to_trusted_root(dir, me,
me, INFRA_ATTEST_SCOPE)`. Both key ids are ours deliberately: 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 the walk
returns None, the slice goes dark on its own, nothing special-cased it.

The check lives INSIDE compose_registry rather than at the call site, because an
authority check belongs with the thing it authorises. `cfg.slices.registry`
survives as an operator opt-out only: it can keep a blessed node from serving,
and can never make an unblessed node serve.

It cannot join `Capabilities`, which is evaluated BEFORE the Engine is open (a
pre-corpus structural gate — cf. DEFAULT_LENS_STORE_MIN_GIB, a baked constant
precisely because there is no corpus to read config from yet). A delegation-graph
walk needs the corpus, so this gate is necessarily post-corpus.

## The trap this change is shaped around

`compose_registry` was `todo!()`, unreachable only because the config bool
defaults to false. Swapping the boolean for the grant check WITHOUT first giving
the function a non-panicking body would panic at boot on exactly the nodes that
are blessed — canonical-1 holds all four charter verbs.

So the body is honest instead: conferred nodes log that they are blessed and
that the surfaces are not composed yet; unblessed nodes log the withholding as
the ordinary steady state it is. Ok(None) is not an error — a node that was
never blessed must still finish booting and serve everything else.

Three tests pin it, including that regression directly: an unblessed node
resolves to Ok(None), withholding is not a boot failure, and asking about a
stranger's key confers nothing.

## Not in this change

The surfaces (phases 2-4): the persist-native rewrite of builds / verify /
revocation / integrity / transparency, Portal's UI arriving as client cards, and
the public GenesisBundle broadcast that retires /v1/steward-key. Scoped in
FSD/REGISTRY_SLICE_ROLE_GATE.md §6, including why steward-key retires rather
than being carried (three mutually incompatible schemas, none of which verify's
actual HTTP client can parse).

Upstream: CIRISRegistry#76 is DONE — registry-core now resolves on this repo's
exact triple (persist v32.3.0 / edge v17.4.1 / verify v13.3.1), which is what
made it composable here at all.

Refs CIRISRegistry#62, CIRISRegistry#76, CIRISRegistry#133, CIRISServer#441
Phase 2a. The federation-facing read of the GenesisBundle persist bakes: the
humanity-accord charter, its A1/B1/C1 holder roster, the infra:* scopes that
charter confers, and the serve-node grants issued under it.

## What this retires

CIRISRegistry published its own key as its own trust root at GET
/v1/steward-key. That endpoint is retired rather than repaired
(CIRISRegistry#133), and the reasons shape this module:

  - it carried NO signature at all while declaring
    `signature_mode: "HYBRID_REQUIRED"` — trust-root material arriving
    unauthenticated on the wire;
  - it asserted `hardware_class: HSM_PROD` under `self_attested: true`, a
    producer claim rather than evidence (CC 4.2.2.1);
  - and three mutually incompatible schemas for it exist across the fleet, no
    two of which agree. Verify's actual HTTP client expects single-steward
    `{classical{}, pqc{}, …}` whose non-Option fields are absent from what
    registry serves, so it fails to deserialize outright. Nothing downstream
    was successfully consuming it.

The replacement is not "the same thing, signed" — it is a different SHAPE of
claim. A node no longer publishes a root it asserts; it serves the root it was
conferred by, and that artifact carries its own proof.

## The invariant: authority lives inside `bundle`, nowhere else

The bundle is self-authenticating. Its `authorizations` are hybrid Ed25519 +
ML-DSA-65 signatures from accord holders over the charter, and
verify_bundle_quorum re-derives authority from the READER's own records rather
than from anything the bundle says about itself — a forged bundle carrying
attacker "holders" proves nothing (CIRISPersist#377).

Everything outside `bundle` — bundle_fingerprint, charter_root_key_id,
served_by — is unsigned convenience metadata: this node's unverified claim
about itself and about bytes it is relaying.

So there is deliberately NO response_signature. Signing the wrapper would prove
only that the relaying node said it, which is exactly what /v1/steward-key
proved and exactly what was worthless — and it would invite consumers to check
the envelope instead of the artifact. The field is named `served_by` rather
than anything that reads like an attestation, for the same reason. A test pins
this: response_signature / signature_mode / hardware_class must never appear on
the outer envelope.

## Public by design

Unlike trust_root_api's import/list/delete verbs — loopback-gated, because
choosing a node's trust root is the operator's own act — this is a federation
read and is mounted WITHOUT a loopback layer. A peer bootstrapping into the mesh
has to be able to fetch the root and check it against its own roster.

Everything in the bundle is public material: public keys, signatures, an
already-announced transport hint, and YubiKey PIV attestation certificates.
Scanned before exposing it; no private material, no seeds, no key material.

## Tests

Three, on the properties rather than the status code: the broadcast serves the
baked bundle through the same accessor everything else uses (not a second path
that could drift); the charter still confers infra:attest + infra:serve, pinned
HERE as well as at the consumer, because if it ever stops the registry slice's
role gate silently becomes unsatisfiable; and the outer envelope claims no
authority.

382 lib tests green. fmt + clippy -D warnings clean.

Refs CIRISRegistry#133, CIRISRegistry#62, CIRISServer#442
@emooreatx

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 0.5.218 (53d1ffb5): clean across 251 commits, no conflicts. cargo check --lib --tests is clean, cargo fmt --check is clean, and the six tests this PR adds pass (3 role-gate, 3 trust-root broadcast).

Upstream is ready for it: CIRISRegistry PR #140 puts ciris-registry-core on this repo's exact triple (persist v52.0.1 / edge v38.1.0 / verify v18.0.0), and its no-default-features build carries no sqlx, tonic or tokio-postgres. This answers the rebase-or-recut question the rc6 changelog raises for this PR.

@emooreatx
emooreatx force-pushed the fold/registry-slice-role-gate branch from b5beb15 to d5b4358 Compare October 1, 2026 14:09
emooreatx and others added 2 commits October 1, 2026 09:36
…core v4.0.0

Replaces the in-tree `trust_root_broadcast` module with registry-core's fold
router, mounted over this node's own Engine. `/v1/trust-root/bundle`,
`/v1/steward-key` and `/v1/agent_files/{kind}` are now the registry's handlers
rather than a copy of them.

The dependency is taken with `default-features = false`: registry-core's fold
build carries no sqlx and no tonic, and resolves the same persist/edge/verify
triple this crate pins, so the lock holds one of each substrate crate.

These are public tier-P reads. They are not loopback-gated and they are not
behind the registry-slice role gate; the gate governs what the node may attest,
not who may read a self-authenticating bundle.

tests/registry_slice_serves_the_trust_root.rs pins the composition: the router
takes this crate's `Arc<Engine>`, so a second substrate rev is a build error
here, and the outer envelope carries no signature of its own.

Refs CIRISRegistry#62, #537.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhhR4ntuczmABtdh6RifqF
…a mesh ladder

A node conferred the registry slice now holds build manifests and serves
/v1/builds. A pipeline publishes a Contribution and its manifest bytes to one
registry node; the row replicates, the other registry node pulls the bytes and
serves the build with provenance it re-verified against its own accepted root.

What it took, each found by the new `manifest` ladder:

- The slice could never be conferred. `slices.registry` defaulted to false while
  documented as an opt-out, and the gate asked about `cfg.key_id` (the alias)
  rather than the node's wire key, which is what the directory is keyed on.
- Commons blobs were declined on every node. Edge's store gate wants an
  operator's consent to hold the class and a roster of holders to pull from. A
  conferred node now consents, with the canonical servers in its directory as
  the roster plus CIRIS_BLOB_COMMONS_HOLDERS. Every other node declines as
  before. The roster is read once at spawn: edge v38's puller takes it by value.
- The harness bless granted the canonical infra:serve only, while its key record
  claimed infra:attest as well. The grant now carries both, as production's does.

`harness/mesh-repro/scenarios/manifest.sh`: two registry nodes and a bystander
on the synthetic trust root, eleven rungs, green. `examples/harness_ci_pipeline`
stands in for the pipeline: its own keypair, blessed by a
`delegates_to(root → pipeline, infra:attest)` grant, plus an unblessed twin the
door must refuse.

Also learned and encoded in the ladder: a first-run claim does not write the
owner's acceptance of the trust root, so until a restart or a trust-root import
peers treat the node as Attributed rather than Rooted and withhold every
third-party row from it (`owners_accept`).

ciris-registry-core is pinned by rev to CIRISRegistry#143 until that is tagged.

Refs #537, CIRISRegistry#62, CIRISVerify#299.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhhR4ntuczmABtdh6RifqF
@emooreatx

Copy link
Copy Markdown
Contributor Author

Pushed 0edbb2c7: the registry slice now holds and serves CEG-native build manifests, and a new mesh ladder proves it end to end on the synthetic trust root.

harness/mesh-repro/scenarios/manifest.sh, three docker nodes (two blessed for the registry slice, one bystander), final sample:

1.rooted=3 2.conferred=2 3.holds_commons=1 4.peered=2 5.owners_accept=3 6.refused=1
7.admitted=1 8.served_on_a=1 9.row_on_b=1 10.blob_on_b=1 11.unblessed_is_nowhere=1
→ SUCCESS: full chain green

A pipeline with its own keypair, blessed by delegates_to(root → pipeline, infra:attest), publishes a Contribution and its manifest bytes to registry A only. The Contribution, the pipeline's key and its grant replicate to registry B; B's puller fetches the blob (outcome=Stored { announced: true }), and B serves the build with provenance it re-verified against its own accepted root, the returned bytes hashing to the attested value. An unblessed pipeline with a valid signature is refused pipeline_not_blessed and served nowhere. The bystander holds the row and not the bytes. Row and blob reached B about five and a half minutes after publish, on the anti-entropy cadence.

Fixed on the way, each found by the ladder:

  • the slice could never be conferred (slices.registry defaulted off; the gate asked about the alias, not the wire key);
  • commons blobs were declined on every node, so a manifest could not move; a conferred node now consents, with the canonical roster as its holders;
  • the harness bless granted the canonical infra:serve only.

Filed: CIRISVerify#299 (verify v18's manifest Contribution is not storable by persist v52), CIRISEdge#785 (the commons holder roster is fixed at puller spawn), #710 (a first-run claim leaves the node Attributed, not Rooted).

ciris-registry-core is pinned by rev to CIRISRegistry#143 until that is merged and tagged. Local gates: fmt, clippy -D warnings with and without test-anchor, 604 lib tests.

emooreatx and others added 2 commits October 1, 2026 19:15
…ries manifest_size

The pipeline stand-in states the manifest's size in the facts it signs
(CIRISRegistry#143, CIRISVerify FSD-006 Q2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhhR4ntuczmABtdh6RifqF
Repins ciris-registry-core to the rev where a pipeline is blessed by a grant OR
by the accord role on its key record (CIRISRegistry#143). The accord's CI-key
ceremony blesses by role and writes no grant, so the walk-only door would have
refused every production pipeline.

The ladder now publishes three pipelines of its own and optionally a fourth:

- `blessed`: a delegation grant, no role on the record -> standing `delegation`.
- `ceremony`: infra:attest co-scrubbed onto the record, no grant, which is what
  the CI-key ceremony produces -> standing `accord_role`. Two new rungs,
  admitted and served by the second registry with its bytes.
- `unblessed`: refused, served nowhere.
- MAN_EXTERNAL_PIPELINE=<dir>: a Contribution minted by another producer, blessed
  here by its PUBLIC keys alone. Two more rungs when set.

Measured: 13/13 without the external pipeline; 15/15 with a Contribution minted
by CIRISVerify 19.0.0's `ciris-build-sign sign --emit-contribution` (201 under
accord_role on the first registry, manifest bytes served by the second and
hashing to the attested value).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhhR4ntuczmABtdh6RifqF

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant