Skip to content

feat(#87)!: K1 — KeyState + pure key-state fold (sans-io KERI core)#134

Merged
joeldsouzax merged 35 commits into
mainfrom
87-keystate-fold
Jul 6, 2026
Merged

feat(#87)!: K1 — KeyState + pure key-state fold (sans-io KERI core)#134
joeldsouzax merged 35 commits into
mainfrom
87-keystate-fold

Conversation

@joeldsouzax

Copy link
Copy Markdown
Contributor

Closes #87.

Summary

  • Builds the foundation of the sans-io KERI core in the keri-rs crate: a KeyState value type and a pure decide/apply fold (validateAcceptedapply, plus fold) — keripy's Kever, minus the database. Consumes only cesr's public API.
  • validate enforces KERI structural rules + threshold arithmetic over an already-verified signer index-set for inception, rotation (incl. the next-key commitment hash), and interaction. It performs no signature verification — an explicit upstream trust boundary documented on the fold. Delegated events (dip/drt) are rejected fail-closed and deferred to K4 (K4 · Full delegation validation over KelProvider (completes #83 under sans-io) #90).
  • apply is an infallible fold; the Accepted typestate makes "an established event with no prior state" unrepresentable — no panic/unreachable arm.
  • Simple and weighted signing thresholds via exact rational arithmetic (checked; fails closed on overflow, zero denominators, malformed/empty thresholds).

Validated against keripy

A KEL generated by keripy's own Kever is folded by keri-rs and matches keripy's state field-for-field (differential.rs + keripy-generated corpus) — a true cross-implementation check, incl. a Basic D-prefix path cesr's own builders never emit.

Breaking / notable

  • cesr (breaking): removed the vestigial, logic-free cesr::keri::KeyState; computed key state now lives in keri-rs.
  • cesr (additive): standard derives (Debug/PartialEq/Eq; Clone on Seqner/Number) on core primitives.
  • keri-rs enables cesr's crypto feature only for the next-key-commitment hash, not signature verification.

Quality

Each phase spec- and adversarially reviewed (soundness probes on threshold arithmetic, inception, rotation commitment). A principal-level audit (two independent auditors, every finding backed by a runnable artifact) drove a behavior-preserving refactor: shared fold/rules.rs (dedup security checks); apply consumes Accepted (removes a redundant KeyState clone/transition); dropped a compiler-proven-dead lifetime; iterator-based satisfied_by; added witness cut/add tests.

Follow-ups

#129 · #130 · #132 · #133; notes on #88 (K2) and #90 (K4).

Test plan

  • nix flake check green locally (clippy, fmt, taplo, audit, deny, nextest across feature combos, doctest, wasm32, no_std)
  • 45 keri-rs tests + keripy differential pass
  • CI confirms

🤖 Generated with Claude Code

joeldsouzax and others added 30 commits July 5, 2026 23:46
Design for the sans-io KERI core foundation: a KeyState value type and a
pure decide/apply fold in the keri-rs crate (not cesr, for semver isolation).

Locked decisions:
- validate() does threshold arithmetic only; crypto verify stays upstream at
  the byte seam (keeps the fold pure + serialization-agnostic).
- Serialization-generic via one borrowed KeriEvent (decision A), Cow<[T]> lists;
  no Event trait until a second backend arrives (YAGNI).
- Weighted thresholds included, satisfied in keri-rs over the public Tholder
  enum (exact rational arithmetic) — zero cesr change.
- K1 cesr footprint = remove the vestigial owned cesr::keri::KeyState (breaking).

Spawns #129 (zero-copy event input) and #130 (lean Tholder) as follow-up cards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Grounding the plan surfaced that rotation's next-key-commitment check
requires hashing revealed keys (digest(key.qb64b) vs committed ndigers),
which lives behind cesr's `crypto` feature. Corrected the spec: keri-rs
enables `crypto` for that hash only (serialization-agnostic, not IO, not a
signature verify); signature verification still stays upstream at the seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phased TDD plan for KeyState + the pure fold: threshold satisfaction first
(simple + weighted, exact rational), then Rejection/KeyState types, then
validate/apply per ilk (inception, interaction, rotation incl next-key
commitment), sequence/boundary/differential tests, no_std/wasm gate.

Spec refinements found during plan self-review:
- KeyState.prefix is Identifier<'a> (not Prefixer<'a>) — self-addressing AIDs
  are Saiders.
- apply narrows the KeriEvent variant once in fold::apply, so no ilk apply
  carries an unreachable arm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BREAKING: computed key state moves to the keri-rs crate (folded KeyState<'a>).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Threshold satisfaction is security-critical; the u64::try_from(len) branch
returned true on failure (fail-open). Compare distinct-count in usize space
and treat a threshold exceeding usize::MAX as unsatisfied — removes the sole
fail-open path (was unreachable, but keeps the function fail-closed throughout).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An inception carrying an empty weighted threshold ("kt":[]) was accepted with
no signatures: satisfied_by returned true for Weighted(vec![]) (vacuous), and
inception validation only checked the Simple threshold arm. Fix both layers:

- threshold::satisfied_by returns false for an empty clause-list.
- inception::check_keys_and_threshold rejects a weighted threshold that is
  empty, has an empty clause, or whose flattened weight count exceeds the key
  count (keripy eventing.py: reject when tholder.size > len(keys)).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…state

Change Accepted from a struct into a #[non_exhaustive] enum whose variants carry
the already-narrowed inner event (and, for later phases, the prior state). Phase
4 defines only Accepted::Inception { event, delegator, resolved_witnesses }.

apply drops its Option<KeyState> parameter and matches the enum, so an
established event with no prior state is no longer representable — deleting the
stub_state fabrication that returned a bogus KeyState (empty keys, Simple(0),
transferable:false). inception::apply now takes the narrowed &InceptionEvent,
Option<&Prefixer>, and the resolved witnesses. The rotation/interaction apply
stubs are removed (re-added with the new variants in Phases 5/6); their validate
stubs remain.

Variants are #[non_exhaustive] so downstream crates can neither construct nor
exhaustively destructure Accepted, preserving the fold-only construction
invariant the struct's pub(crate) fields gave.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the `Accepted::Interaction` variant (carrying the narrowed
`InteractionEvent` plus a boxed clone of the prior `KeyState`) and
implement the interaction fold step:

- `interaction::validate` enforces the keripy `eventing.py` interaction
  rules: estOnly forbids interactions, sn must be exactly prior.sn+1,
  prior-event digest must match the state's latest SAID, signer indices
  must be in range, and the signing threshold must be met.
- `interaction::apply` advances only sn/said/ilk; keys, thresholds,
  next-key commitment, witnesses, config, delegator, transferability,
  and lastEst all carry over unchanged.

API adaptations (called out per active-development discipline):
- `validate`'s `state` param is now `Option<&KeyState<'a>>` (was
  `<'_>`): the returned `Accepted<'a>` carries a clone of the prior
  state, so the prior must share the event lifetime.
- `Accepted::Interaction.prior` is `Box<KeyState<'a>>` (not bare
  `KeyState<'a>`) to satisfy `clippy::large_enum_variant` without an
  unauthorized `#[allow]`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 6 of the K1 KeyState fold. Adds the `Accepted::Rotation` variant and
implements `rotation::validate`/`apply`, enforcing the security-critical
next-key commitment: revealed keys must hash to the prior state's committed
digests and satisfy the prior next-key threshold. The commitment check fails
closed on any digest-build error.

Validation rules: sequential sn, prior-digest match, well-formed new key
set/threshold, next-key commitment, witness cut/add validity + witness
threshold bound, and new signing-threshold satisfaction. Rotation is an
establishment event, so `apply` rolls keys/next/witnesses forward and advances
the last-establishment pointer.

This is the STRICT full-rotation form (positional match, revealed count ==
committed count). keripy's `Kever.exposeds` maps revealed keys to committed
digests via the siger dual-index (`ondex`) and is partial-rotation capable;
that is a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
keripy requires witness removals and additions to be disjoint. Previously an
overlapping prefix was removed then silently re-added (a no-op "keep"),
diverging from keripy's disjointness rule. Reject the overlap as InvalidEvent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build icp -> ixn@1 -> rot@2 -> ixn@3 in order against the running
state (validate + apply to learn each SAID), then drive the same owned
events through the public fold() and assert the final state: sn 3,
latest ilk Ixn, keys rotated to k1 at sn 2, last establishment at sn 2,
next-key commitment to k2.

Also adds an inception_with_witnesses fixture to common/mod.rs (used by
the boundary sweep in the following commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One test per Rejection path not covered by the per-ilk fold tests, each
asserting the exact RejectionReason:
- duplicate inception (validate(Some, Inception)) -> InvalidEvent
- interaction with stale prior digest -> PriorDigestMismatch
- interaction with no signatures -> MissingSignatures
- signer index out of range -> InvalidEvent
- witness threshold (TOAD) > witness count -> InvalidEvent

The empty-weighted-threshold path is already covered in fold_inception
and is not duplicated (noted in a comment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 8 of K1 KeyState: prove the keri-rs fold agrees with the keripy
reference Kever on a happy-path single-sig transferable KEL
(inception -> rotation -> interaction).

- scripts/keripy_keystate_gen.py: keripy-driven generator. Builds and signs
  the KEL with real Ed25519 signers, folds it through keri.core.eventing.Kever,
  and emits keri/tests/corpus/keystate.jsonl — event bytes (base64 of
  serder.raw) plus keripy's authoritative folded state. Pin
  v2.0.0.dev5-1030-gde59bc7d, V1 JSON.
- keri/tests/differential.rs: base64-decodes each event, parses via cesr's
  public serder deserialize_event, folds via the real keri::fold, and asserts
  the resulting KeyState (prefix/sn/keys/next_keys/toad/witnesses) matches the
  keripy-derived expected values with exact assert_eq!. Expected values come
  from keripy's Kever, never from this crate's fold (non-circular).
- keri dev-deps: serde, serde_json, base64 (test-only).

Finding: cesr::serder::deserialize_event parses keripy's real V1 JSON KEL
bytes, including a basic (Ed25519 `D`-code) prefix — keripy's default
single-key derivation, which cesr's own InceptionBuilder does not emit
(it always produces self-addressing prefixes). No serder gap found.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- taplo: reorder dev-deps in keri/Cargo.toml (base64 alphabetized)
- rustfmt: reflow next_keys/witnesses collect chains in keri/tests/differential.rs
- typos: allowlist keripy's `ser` serialization kwarg in _typos.toml
- clippy: .err().expect() -> .expect_err() in cesr matter builder tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delegated inception/rotation (`dip`/`drt`) folding requires verifying the
delegator's authorizing seal against the delegator's KEL — that is K4
(delegation) scope, which K1 has neither the KEL nor an escrow for. The
prior dispatch routed `dip`/`drt` through the base-event path, whose
`apply` hardcoded `latest_ilk = Icp`/`Rot` (wrong for delegated events)
and, worse, accepted them without any delegation authorization.

`validate` now fails closed: `DelegatedInception`/`DelegatedRotation` are
rejected as the new `RejectionReason::DelegationUnsupported` regardless of
prior state. The now-unreachable delegated arms in the `narrow` helpers are
dropped, and the `delegator` param/field is removed from `Accepted::Inception`
and `inception::apply` (K1's fold always yields `KeyState.delegator = None`;
K4 will populate it).

BREAKING CHANGE: adds `RejectionReason::DelegationUnsupported` (enum is
`#[non_exhaustive]`); removes the `delegator` field from `Accepted::Inception`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The crate-level doc still described the K0 skeleton ("no KERI types yet").
Replace it with an accurate summary of the pure decide/apply key-state fold
(validate -> Accepted -> apply, plus fold) and state the two trust
boundaries: signatures are verified upstream (the fold reads indices only),
and delegation authorization is deferred to K4 (delegated events rejected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add fold_delegation.rs: build real `dip`/`drt` events via serder's
DelegatedInception/DelegatedRotationBuilder, deserialize, and assert
`validate` rejects them as `DelegationUnsupported` in every state case.

Add fold_props.rs: three proptest cases exercising the real
validate/apply/fold — sn advances by exactly one per event, interactions
preserve establishment state, and the inception signing-threshold boundary
(accept iff >= t distinct in-range indices, else MissingSignatures).

Add `inception_multi`, `delegated_inception`, `delegated_rotation` fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… their data)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
joeldsouzax and others added 5 commits July 6, 2026 16:46
…lone

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…apply

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ocations

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@joeldsouzax joeldsouzax enabled auto-merge (squash) July 6, 2026 21:03
@joeldsouzax joeldsouzax merged commit 0ba0c85 into main Jul 6, 2026
4 of 5 checks passed
@devrandom-release-plz devrandom-release-plz Bot mentioned this pull request Jul 6, 2026
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.

K1 · KeyState + pure key-state fold (Kever, minus the database)

1 participant