@kinnet/protocol
v0.7.2
Published
Shared protocol types and Zod schemas for the Kinnet participant network: records, key-event logs, claims, relationships, grants, and revocations.
Maintainers
Readme
@kinnet/protocol
The record types and Zod schemas of the Participant Network — the shared substrate every other
Kinnet package consumes. No crypto, no I/O: this package decides what a record's fields are and
whether a candidate value is shape-valid, and nothing about whether it is signed correctly or
who to trust. @kinnet/crypto signs and verifies against these shapes; @kinnet/trust and
@kinnet/verify resolve identity and authority from them.
Install
npm install @kinnet/protocolor from a checkout of the repository:
pnpm install && pnpm buildParse and validate a record
Every record kind is a Zod schema. safeParse returns a typed result instead of throwing, so a
caller reading untrusted bytes decides what "invalid" means for its own surface:
import { participantProfileSchema, type ParticipantProfile } from "@kinnet/protocol";
const candidate = {
id: "pk_zQmYwAPJzv5CZsnAzt8auVZRnHEKzKgUEdy3W35nUSpS6kq",
type: "agent",
displayName: "Ordering Agent",
capabilities: ["orders/create"],
verifiedDomains: [],
updatedAt: "2026-08-17T00:00:00.000Z",
signature:
"z5rnVTAbdGrtjnu47AiPSJ3rr9iDskEANf6PLXXbEN6tfkP43izwMaLyBii1ZyqGYGdLVTXEBssdja39ZAqoXdjFx"
};
const result = participantProfileSchema.safeParse(candidate);
if (result.success) {
const profile: ParticipantProfile = result.data;
console.log(profile.displayName);
} else {
console.error(result.error.issues);
}This schema only checks shape — field types, formats, the cross-field rules the record's spec
states. It says nothing about whether signature actually verifies against the claimed id's
current key; that is @kinnet/crypto's verifyRecord against a key state resolved by replaying
the id's key log.
The signed records are participantProfileSchema, participantNodeSchema, keyEventSchema /
keyEventLogSchema, claimSchema, relationshipSchema, grantSchema, revocationSchema,
messageEnvelopeSchema, conversationSchema, and conversationUpdateSchema — each exports its
inferred TypeScript type alongside it (ParticipantProfile, Grant, KeyEvent, …).
The strictness rule
Every signed record schema is a Zod strictObject: a value carrying a key the schema does not
define is rejected, not silently stripped. That matters because these records are digest- or
signature-identified — a permissive parser that drops an unknown key would let one delivered byte
string decode into two different logical records, one with the key and one without, both
claiming the same signature:
import { participantProfileSchema } from "@kinnet/protocol";
const withStrayKey = {
id: "pk_zQmYwAPJzv5CZsnAzt8auVZRnHEKzKgUEdy3W35nUSpS6kq",
type: "agent",
displayName: "Ordering Agent",
capabilities: [],
verifiedDomains: [],
updatedAt: "2026-08-17T00:00:00.000Z",
signature:
"z5rnVTAbdGrtjnu47AiPSJ3rr9iDskEANf6PLXXbEN6tfkP43izwMaLyBii1ZyqGYGdLVTXEBssdja39ZAqoXdjFx",
extra: "not part of the schema"
};
participantProfileSchema.safeParse(withStrayKey).success; // false — "Unrecognized key: extra"Claim and Relationship are strict for the same reason and against each other specifically:
open schemas that each strip what they don't define would let one object parse as both kinds
under one signature. Two record kinds must never both accept the same bytes.
The record anchor
Revocation, Grant, Conversation and ConversationUpdate are verified against a
participant's key state, and each names the state it was signed under: anchor, the digest of
one event in the issuer's key log (spec 016). A verifier resolves that one state and tries no
other, which is what makes an edited signature set unmoveable onto a state that would accept it.
Where the field is required is a property of the issuer, and the schemas enforce it: a
Revocation always carries one; a Grant carries one exactly when issuerId is a participant
id and never when it is a bare KeyRef; a Conversation or ConversationUpdate carries one in
owner mode and not in delegated mode, where the chain's leaf key is the only candidate. In a
(record, chain) unit the two must agree — anchor present if and only if chain is absent —
so the mode is decided by the unit rather than inferred from a failed verification.
Encoding helpers
Bytes that will become a record — an HTTP body, a stored blob, anything read from outside the
process — need the same care before they reach a schema. decodeUtf8Strict refuses malformed
UTF-8 instead of silently substituting U+FFFD (the default TextDecoder behavior, which makes
two different byte strings decode to the same text), and parseJsonStrict refuses a JSON object
that repeats a key at any depth, since different parsers resolve a duplicate key differently:
import { decodeUtf8Strict, parseJsonStrict } from "@kinnet/protocol";
const octets: Uint8Array =
/* the exact bytes that were signed or delivered */ new TextEncoder().encode('{"want":"quote"}');
const record: unknown = parseJsonStrict(decodeUtf8Strict(octets));Use this pair — never JSON.parse(new TextDecoder().decode(octets)) — anywhere a signature or a
digest covers the delivered octets and the parsed value is about to be checked against a schema
above. @kinnet/verify's discovery client and @kinnet/crypto's grant-chain header decoder both
route through it.
This module also exports keyRefSchema, signatureSchema, multihashSchema, and
participantIdSchema for validating identifiers and encoded values on their own, and the ability
vocabulary (abilitySchema, isE2eeAbility) used by grant chains.
Conformance vectors
Where a record's bytes are digested or its kind must be distinguishable from every other kind, the schemas are backed by committed fixtures a third party can check without this package:
test/fixtures/record-kind-vectors.json— one shape-valid instance of every record and payload kind; each must validate under its own schema and be rejected by every other one (record kinds are non-confusable).test/fixtures/signed-conversation.json,test/fixtures/signed-conversation-e2ee.json— a replayable key log paired with a signedConversationrecord, machine lane and E2EE lane.test/fixtures/conversation-update-vectors.json— accept/reject cases forconversationUpdateSchema's well-formedness rules (spec 014).test/fixtures/conversation-unit-vectors.json— the(record, chain)payload wrapper accept/ reject cases, including spec 016's anchor/chain agreement, and the digest-identity property that the chain travels alongside the record without changing its id.test/fixtures/commit-validity-vectors.json— spec 014's membership-change commit-validity rules (apply / wait / invalid) over full evidence sets.
Specs
- 000 — Protocol scope & evolution
- 001 — Canonical serialization (JCS)
- 002 — Participant ID derivation
- 003 — Key-history log (KERI-lite)
- 008 — Revocation
- 009 — Grant (UCAN-aligned)
- 010 — Message envelopes & inbox
- 012 — Conversations
- 014 — Two-lane conversations (E2EE)
- 015 — Canonical signature sets
- 016 — Record anchoring
- 017 — Participant profile & node
- 018 — Claims & relationships
The full index is at
packages/protocol/spec.
Status
Pre-1.0, pre-wire-freeze: 0.x releases are for early adopters, and record shapes may still
change between them. The wire freezes at 1.0, when the maintainers declare it — not before.
Track the spec, not any one version of this package — the protocol is meant to be implemented
independently, and the conformance vectors are the compatibility contract.
License
Apache-2.0
