N4-B — the portable evidence bundle, and the stateless icnctl relying party
Implements: N4A_DEVICE_AUTHORITY_EVALUATION.md §9.2 items 2 and 3 — the container for
(facts, act) and the verb that consumes it ·
Parent ladder: icn#2694 §7 ·
icn#2599 (N4) ·
Companion to: IDENTITY_SEMANTICS.md (the canonical contract), HOME_RUNTIME_IDENTITY_PROFILE.md
(who consumes this and why), N4C_DEVICE_ENROLLMENT_REQUEST.md (how a device comes to hold facts) ·
Code: icn/crates/icn-identity/src/evidence_bundle.rs, icn/bins/icnctl/src/device_authority.rs ·
Tests: icn/crates/icn-identity/tests/evidence_bundle.rs,
icn/bins/icnctl/tests/device_authority_bundle_test.rs ·
Reference: icn/crates/icn-identity/tests/reference/evidence_bundle_reference.py
This document is the contract for the N4-B portable evidence bundle. It is written so that an implementation in another language can reproduce the canonical bytes in §8 from this text, the N1 canonical-encoding contract it cites, and the N4-A act contract — and so that a stateless consumer can state exactly what it has checked at each of the three steps between receiving bytes and accepting a device-signed act.
Truth status. Normative for the bundle bytes and for the verb's contract. LIB-TESTED for the container (25 tests observed 2026-10-27, plus an independent Python reference that reproduces the vector and applies its own strict decoder) and CLI-REACHABLE for the verb (5 end-to-end tests of the built binary). Not PRODUCTION: no runtime, route, daemon or deployment calls either. The bundle changes nothing in N1 or N4-A. It is not a network protocol, not N3 replication, not a persistence format or durability guarantee, not a session format, not an enrollment or recovery protocol, not a Home or deployment format, and not an
icnd, gateway or SDK surface. It confers no institutional recognition, membership, standing or session authority. It makes no claim that any real person's device is enrolled. Fixture identities only in every test; no real person's identity, keys or records are involved.
One format. This is the single canonical N4-B container, the result of converging two independently developed implementations of the same boundary on 2026-10-04 (§11). The superseded
DeviceAuthorityBundleV1(icn.n4.device-authority-bundle) no longer exists in the tree and has no compatibility decoder.
1. The question this answers
How does a holder of N1 facts hand a stateless verifier exactly the evidence that the existing N1 + N4-A semantics need to evaluate one signed device act, so that the verifier rebuilds the same
AuthorityStoreand reaches the same verdict — withouticnd, a network, a directory or a clock?
The answer is a strict, deterministic byte container. It is framing over canonical facts. It adds no fact kind, no selector, no ordering rule the fold could read, and no verdict.
1.1 The architectural invariant
The container transports evidence. It does not interpret authority. The canonical N1 admission
path (admissible) and derivation path (derive_prefix, consumed by verify_device_act) remain
the only authority semantics. The flow is, and may only be:
portable bytes
↓ EvidenceBundle::decode -- strict, fail-closed, structural (§5)
N1 canonical bodies + witnesses
↓ EvidenceBundle::admit -- every (body, witness) through AuthorityStore::ingest (§6)
AuthorityStore
↓ verify_device_act(act, S, store, E) -- the existing N4-A verifier (§7)
VerifiedDeviceAct | refusal
No step may be skipped or merged. In particular, decoding a bundle cannot cause a fact to bypass
canonical admission: AuthorityStore has no ingest path other than ingest, which calls
admissible, and the bundle type has no method that builds a store any other way.
2. Three claims, kept separate
A consumer that accepts an act after processing a bundle has made three independent checks. They prove different things and none implies the next.
| Step | Succeeds when | Proves | Does not prove |
|---|---|---|---|
| decode (§5) | the bytes are one canonical bundle | the framing is well-formed; each carried body is a strict canonical N1 body; each stated event_id is that body's digest; each body belongs to the bundle's Subject at position ≤ E; each body has ≥ 1 witness; the act is a strict canonical device act naming the bundle's Subject and E |
that any witness signature verifies; that any fact was ever authorized; that the prefix is complete or unforked |
| admit (§6) | every (body, witness) passes N1 admission |
some key signed each body — the inline signer — exactly as a live replica would conclude | that the signer had authority; that the set is complete; anything about the act |
| verify (§7) | the existing N4-A verifier returns evidence | under the facts the bundler supplied, folded through E, the chain was live and gap-free through E, the device held a covering grant, was not the establishment authority, and signed this exact act |
that the bundler supplied all facts that exist for S through E (see §3.3); that the device is authorized now (that is N4-E, N4E_CURRENT_ADMISSION.md); anything N4-A §2 lists as outside its claim |
A bundle that decodes and admits but whose act is refused is a correct bundle. Refusal is a verdict, and transporting the evidence for a refusal is the container doing its job.
3. Scope: which facts a bundle carries
A bundle is scoped to one Subject S and one evaluation position E, both named by the enclosed act
(N4-A §6.1: the act binds the position its signer claims; the relying party must agree, §7).
3.1 The rule
A bundle for
(S, E)carries every retained N1 bodybwithsubject(b) = Sandposition(b) ≤ E, together with every retained witness(event_id(b), σ)over each such body, and nothing else.
3.2 Why exactly that set
This is not a heuristic. N4-A §5 defines
derive_prefix(S, store, E) := derive(S, { b ∈ Bodies_store(S) : position(b) ≤ E })
so the fold's only input is that body set. A verifier that admits exactly these facts folds
exactly what the bundler would have folded. Each consequence below was checked against N1's actual
candidate rule (candidate_is_authorized: subject, position, prev_digest, authorization by the
state as of the parent) rather than assumed:
- Facts above
Eare never required to prove authority atE. The fold filters them out before it runs, the candidate set at positionpis drawn only from bodies at exactlyp, and no body aboveEcan change what is concluded at or below it. A later revocation therefore need not — and (§3.4) cannot — be carried into a historical proof. This is N4-A's non-retroactive-revocation invariant, honoured by construction. - Facts at or below
Ethat the fold would never select are carried. Unauthorized spam, orphaned branches and superseded candidates are all inBodies(S). Deciding that one of them is unselectable would require the bundler to run the fold, which is interpretation. So the bundler does not decide; it carries. - A fork at or below
Eis carried as a fork. Both candidates satisfy the rule; neither is chosen. The verifier reportsAuthorityHalted { disputed_at }exactly as it would on the original store. - A gap at or below
Eis carried as a gap. There is nothing to carry at the missing position; bodies above it that name an unretained parent are carried (they are≤ E) and remain unselectable; the verifier reportsPrefixIncomplete { frontier, required: E }. - An unknown Subject is carried as an empty fact list. A bundler holding no inception for
Sproduces a bundle with zero facts. It decodes, admits to an empty store, and the verifier refusesSubjectUnknown. The decoder does not convert that into an error of its own. - Every witness over a carried body is carried. Several witnesses over one body are a re-signing, not duplicity (N1 §9.2.1); dropping to one would mean choosing a signature.
What a bundle discloses. Every retained body for S through E goes to the relying party,
including the grants to the Subject's other devices in this context. It stays within one context
(one SubjectId), but a consumer that hands bundles around should know it reveals the Subject's
device set up to E, not only the one device that acted.
3.3 What the bundler's selection does and does not prove
A bundle proves what the verifier's third claim says: a verdict under the facts supplied. It
does not prove the bundler supplied every fact that exists. A bundler can omit a fact at or below
E (for example one candidate of a fork, or the revocation at E itself) and the verifier cannot
detect the omission from the bundle alone. Withholding the only fact at E leaves the prefix
incomplete and the verifier fails closed (PrefixIncomplete). Withholding one candidate of a fork
at or below E is not detectable: the remaining chain is clean, and the verifier returns a
verdict where the full fact set would have halted the Subject. A bundle therefore proves a verdict under the facts it
carries, never that no conflicting fact exists. That is N1's O-N7 (late fork discovery) and
the N3 replication question (#2598), neither of which a transport format can close. What the
container does guarantee is that the bundler cannot add a fact outside scope (§3.4), cannot
substitute a body for the one its witnesses are over (§5), and cannot make the verifier evaluate at
a position other than the one the act binds (§5, act agreement).
3.4 Strictness at the boundary
The decoder refuses a body whose subject is not S (FactSubjectMismatch) or whose position is
above E (FactOutsideScope). A bundle that carries one is not a prefix bundle for (S, E). This
is what makes "the same semantic evidence yields the same bytes" a property of the format rather
than a convention of the encoder, and it is what prevents a revocation after E from being smuggled
into a proof about E in either direction.
4. Canonical bytes
4.1 Primitives
Reused from N1 (authority_log/encoding.rs), not redefined:
LP(x) := u32be(len(x)) || x -- length-prefixed byte string
b32(x) := 32 raw bytes, no prefix
b64(x) := 64 raw bytes, no prefix
u8 / u16be / u32be / u64be -- big-endian, fixed width
H(x) := SHA-256(x)
4.2 Constants (frozen for v1)
| Name | Value |
|---|---|
BUNDLE_DOMAIN |
ASCII icn.n4.evidence-bundle (22 bytes) |
BUNDLE_VERSION |
1 (u16be) |
| position bound | 1 ..= MAX_POSITION (N1's 1_048_576), applied to E |
BUNDLE_DOMAIN is length-prefixed and pairwise distinct from every N1 separator
(icn.authority-log, .sig, .commit, .kdf), the N4-A act separator (icn.n4.device-act), the
N4-C request separator (icn.n4.enrollment-request), the N4-D binding separator
(icn.n4.channel-binding) and every GEN separator. Bundle bytes can never be presented as, or
mistaken for, any of those, and none of those can be presented as a bundle.
4.3 Layout
evidence_bundle_v1 :=
LP(BUNDLE_DOMAIN)
|| u16be(BUNDLE_VERSION)
|| b32(subject) -- S; MUST equal act.subject
|| u64be(evaluation_position) -- E; MUST equal act.evaluation_position
|| u32be(fact_count) -- may be 0
|| fact * fact_count -- strictly ascending by event_id
|| LP(device_act_v1) -- the act's canonical bytes, verbatim (N4-A §6)
|| b64(act_signature) -- Ed25519 over device_act_v1, verbatim
fact :=
b32(event_id) -- H(canonical_body), stated, not inferred
|| LP(canonical_body) -- the N1 canonical body bytes, verbatim (N1 §1)
|| u32be(witness_count) -- MUST be ≥ 1
|| b64(signature) * witness_count -- strictly ascending bytewise; no duplicates
bundle_id := H(evidence_bundle_v1)
4.4 Relationship to the N1-D durable record
N1-D (#2800) persists one durable fact as key = event_id (32) || signature (64),
value = canonical_bytes(body). A fact above is that record grouped by body: one body, its
stated digest, and every signature over it. The grouping is the only difference, and it exists so
that one body has one encoding in the container (a flat record per witness would repeat the body
bytes and open the question of what to do if two records for one event_id carried different body
bytes). The digest is carried explicitly for the same reason N1-D carries it in the key: a
substituted or corrupted body is caught as a disagreement between what is stated and what is
carried, rather than silently re-identified.
Nothing of a storage backend is serialized. The record layout is a concept shared with N1-D; the bundle is transport framing over canonical facts and would be unchanged if N1-D never existed.
4.5 Ordering, and why it carries no meaning
Facts are ordered by event_id; witnesses by signature bytes. These are the only canonical
identifiers the durable layer has, and both are digests or signatures — values that bear no relation
to log position, arrival order or preference. In the §8 vector the inception body (position 0) sorts
third. A decoder rebuilds set-valued state (BTreeMap<EventId, _>, BTreeSet<WitnessSignature>);
the fold orders bodies by position itself. Container order is never authority order. Ingest
order, backend iteration order, map iteration order, filesystem order and process restarts cannot
change the bytes, because the bytes are a function of the fact set only.
No clock, no randomness, no "latest", no lexical branch selection, no repair-on-read.
4.6 Bounds
No new limit is introduced. Every count and length is bounded by the input itself through N1's
Reader: a declared element count that the remaining bytes could not hold is refused
(CountOverflow) before any allocation, and a declared length that overruns the input is refused
(UnexpectedEnd). The act payload is bounded by N4-A's MAX_DEVICE_ACT_PAYLOAD (65 536 bytes) inside
DeviceActV1::decode, and E by N1's MAX_POSITION. Memory consumed by a decode is therefore
proportional to the bytes the caller chose to read. The number of facts a legitimate Subject can
have through E is bounded by the chain, but N1 deliberately leaves adversarial storage pressure
open (O-N8); a cap invented here would be a storage-policy decision this layer does not own. (The
superseded implementation carried such a cap, MAX_BUNDLE_FACTS; §11 records why it was removed.)
5. Strict decode — EvidenceBundle::decode
Fail-closed on every defect. In order of detection:
| # | Check | Refusal |
|---|---|---|
| 1 | LP(BUNDLE_DOMAIN) present and exact |
Framing(BadDomain) |
| 2 | version = BUNDLE_VERSION |
Framing(UnsupportedVersion) |
| 3 | 1 ≤ E ≤ MAX_POSITION |
PositionOutOfRange(E) |
| 4 | fact_count fits the remaining input |
Framing(CountOverflow) |
| 5 | each fact: stated event_id strictly greater than the previous |
FactOrder |
| 6 | each fact: LP(canonical_body) fits; body is a strict N1 body (N1's own decoder, including its trailing-byte check) |
Framing(UnexpectedEnd) · Body { source: CodecError } |
| 7 | each fact: H(canonical_body) = event_id |
EventIdMismatch { stated, computed } |
| 8 | each fact: subject(body) = S |
FactSubjectMismatch |
| 9 | each fact: position(body) ≤ E |
FactOutsideScope { position, through: E } |
| 10 | each fact: witness_count ≥ 1 and fits the input |
NoWitness · Framing(CountOverflow) |
| 11 | each witness strictly greater than the previous | WitnessOrder |
| 12 | LP(device_act_v1) fits; act is a strict N4-A act (N4-A's own decoder: domain, version, principal tag, position bound, payload bound, trailing bytes) |
Act(DeviceActError) |
| 13 | 64 signature bytes present; input fully consumed | Framing(UnexpectedEnd) · Framing(TrailingBytes) |
| 14 | act.subject = S |
ActSubjectMismatch |
| 15 | act.evaluation_position = E |
ActPositionMismatch { act, bundle } |
Any ambiguity that would give one logical bundle two byte identities — a reordered fact, a duplicate fact, a reordered or duplicate witness, trailing bytes — is a refusal, not a normalization. Every 32-byte and 64-byte field is typed by position in the layout and, for principals inside bodies and the act, by N1's principal tag; no blob is accepted "because it is the right length".
Decode performs no signature verification and no derivation.
5.1 Inspection — EvidenceBundle::summarize
A decoded bundle can be described: for each carried fact in canonical order, its index, N1 kind
tag, Subject, position, stated event_id and witness count. summarize admits nothing and
evaluates nothing. A bundle whose act would be refused summarizes exactly as one whose act would be
accepted. It exists for the inspect verb (§9) and for logging; its output is never a verdict.
6. Re-admission — EvidenceBundle::admit
For every carried fact and every witness over it, in canonical order:
AuthorityStore::ingest(SignedAuthorityEvent { body, signature })
This is N1's gate, unchanged: admissible(body, signature) — canonical round-trip, position bound,
verify_strict under the body's inline signer over LP(SIGNATURE_DOMAIN) || canonical_body. A
witness that does not verify is refused as Inadmissible { event_id, source: AdmissionError }.
admit fails closed on the first refusal. A partially admitted store is a different Subject
history than the one bundled, and returning one would let a single bad witness silently change a
derived authority state. There is no "skip the bad one" path.
Because AuthorityBody's equality, ordering and hashing are defined over canonical_bytes(), the
store rebuilt by admit is equal to the store the bundle was assembled from, restricted to the
scope — not merely equivalent. The tests assert ==.
7. Verification
Not defined here. The consumer calls the existing N4-A verifier:
verify_device_act(bundle.act(), subject, &bundle.admit()?, position)
where subject and position are the relying party's own. Every rule of N4-A §7 applies
unchanged, including that E is the position the relying party evaluates at. The bundle's header
(S, E) was required to agree with the act at decode (§5, rows 14–15); the relying party's (S, E)
is then checked by N4-A itself: a disagreement is ActSubjectMismatch or
EvaluationPositionMismatch, a refusal and never a fallback. For a stateless consumer that chooses
to rely on the bundler's selection (§3.3), its own (S, E) is the bundle's. A consumer that
holds its own facts should ingest the bundle's facts into its own store (the join is set union;
order is irrelevant) and evaluate at its own E — and if the question is "is this act valid
now?", that E is the one N4-E computes from its own retained clean tip (N4E_CURRENT_ADMISSION.md),
never one the device chose.
7.1 The thin composition — verify_evidence_bundle
verify_evidence_bundle(bundle, subject, position)
:= verify_device_act(bundle.act(), subject, &bundle.admit()?, position)
verify_evidence_bundle_bytes(bytes, subject, position)
:= verify_evidence_bundle(&EvidenceBundle::decode(bytes)?, subject, position)
These are API convenience and nothing more. They make no decision of their own: no scope check
beyond what decode already enforced, no selection, no fallback, no position taken from the bytes.
EvidenceVerifyError keeps the two sources apart — Bundle(BundleError) for a decode or admission
refusal, Act(DeviceActVerifyError) for the N4-A verdict — so a consumer can say which of the
three claims in §2 failed. The tests pin that the wrapper's result equals the direct call's, verdict
for verdict.
8. Test vector
Inputs, stated in full. These are the authority_log_support::subject(0x31, 4) fixture and two
stranger device keys, written out as raw data so no test helper is needed to reproduce them.
| Input | Value |
|---|---|
| Subject root secret | [0x31; 32] |
| Subject context nonce | [0x94; 32] (0x31 ^ 0xa5) |
| establishment plan | 4 generations, all Rotate |
| device A seed | [0x41; 32] |
| device B seed | [0x42; 32] |
| facts | inception @0; Authorize(A, {Sign}, no span) @1 by A_0; Authorize(B, {Sign}, no span) @2; Revoke(A) @3 — each with the one RFC 8032 witness by A_0 |
| act | DeviceActV1 { subject: S, device: B, capability: Sign, evaluation_position: 3, payload: ASCII "fixture:open-workspace" }, signed by B |
Derived (hex):
| Value | Bytes |
|---|---|
A_0 public key |
216bcf74f3b911f6fe9065b158c006b7524b4597ddf343364a0bc94859fb948a |
| device A public key | db995fe25169d141cab9bbba92baa01f9f2e1ece7df4cb2ac05190f37fcc1f9d |
| device B public key | 2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12 |
SubjectId = H(inception) |
a0068f2e8e052f31533edaac720f0911ef32aa24a4b056106b7bbb7002b4c04d |
fact order (ascending event_id) |
1bd0cea4… (authorize A, pos 1) · 9791575d… (authorize B, pos 2) · a0068f2e… (inception, pos 0) · b22bf1bc… (revoke A, pos 3) |
device_act_v1 (123 B) |
0000001169636e2e6e342e6465766963652d6163740001a0068f2e…db12 0000000000000003 01 00000016 666978747572653a6f70656e2d776f726b7370616365 |
evidence_bundle_v1 |
1 335 bytes; pinned in full as EXPECT_BUNDLE_BYTES_HEX in tests/evidence_bundle.rs |
bundle_id |
009fb4ce160121684041689c82bc4d01563dc6e852d5c9390c8aaa31a658deaf |
Every one of these values is reproduced by an independent reference written from this document,
the N1 contract and the N4-A contract (Python, sharing no code, library or constant with
icn-identity), which derives the N1 bodies, the witnesses, the act, the container and its id from
the seeds above, applies its own strict decoder to its own bytes, and then reads the EXPECT_*
literals out of the Rust test purely as a comparison target:
python3 icn/crates/icn-identity/tests/reference/evidence_bundle_reference.py
It exits non-zero on any disagreement, and additionally demonstrates from the prose that a trailing
byte, a truncation, a reordered fact, a stated digest that is not the body's, a fact above E and
an act whose position disagrees with the bundle are each refused.
8.1 Behaviour pinned by the test suite
- byte-exact round trip;
decode(encode(b)) == b;admitrebuilds a store equal to the source; - identical bytes and
bundle_idunder all4!ingest orders of the four facts and under every split-and-join of each order; - two valid witnesses over one body survive as two witnesses of one body; the rebuilt store has 4 bodies and 5 witnesses; witness arrival order does not change the bytes; the verifier is unmoved;
- a stated
event_idthat is not the body digest, a body swapped under another fact's digest, and a body with one flipped nonce byte are each refusedEventIdMismatch; - a body with a wrong version, an unknown kind, an internal trailing byte, or act bytes in a body
slot is refused with N1's own
CodecError; - an N1 body in the act slot, an act at position 0, a
SubjectIdin the act's device slot and a truncated act are refused with N4-A's ownDeviceActError; - every strict prefix of the bytes is refused; one trailing byte is
TrailingBytes(1); - a
u32::MAXfact count, au32::MAXwitness count and an overrunning body length are refused before allocation;E = 0andE = MAX_POSITION + 1are refused; a 65 537-byte act payload is refused by N4-A's bound; - reordered facts, a duplicate fact, descending witnesses, a duplicate witness and an empty witness list are each refused;
- a fact for another Subject, a header Subject disagreeing with the act, and a header position disagreeing with the act are each refused;
- a flipped witness bit and a stranger's signature over the right body decode cleanly and are refused
at admission with
AdmissionError::SignatureInvalid; - the verifier reaches the same result after transport as on the original store for: B accepted
at 3; A refused
DeviceNotAuthorizedat 3; an unknown Subject refusedSubjectUnknown; a fork at 2 refusedAuthorityHalted { disputed_at: 2 }with both candidates carried; a gap at 2 refusedPrefixIncomplete { frontier: 2, required: 3 }with the orphan carried; - A at
E = 2verifies from a 3-fact bundle that does not contain the position-3 revoke; splicing the revoke in is refusedFactOutsideScope; A atE = 3is refused because that bundle carries it; - for every byte index and two bit masks, the mutated bytes have a different
bundle_idand either fail to decode, fail to admit, or are refused by the verifier — no single-byte mutation yields evidence; - bundle bytes are not an N1 body and not an act, and neither is a bundle; the six domain separators are pairwise distinct;
evidence_bundle.rsreferences no clock, no randomness, no hash map, and calls none ofsupersede,resolve,derive,derive_prefix.
8.2 Behaviour ported from the superseded suite
Pinned by DeviceAuthorityBundleV1's tests and not by the donor's; restated over this container:
- a bundler that drops the revoke at
E = 3and still claimsE = 3yieldsPrefixIncomplete { frontier: 3, required: 3 }, never a still-authorized A (withholding cannot extend authority); verify_evidence_bundleandverify_evidence_bundle_bytesreturn exactly the direct verifier's evidence or refusal, and surface an inadmissible witness asBundle(Inadmissible)before any verdict;- a relying party that supplies another Subject or another position is refused by N4-A
(
ActSubjectMismatch,EvaluationPositionMismatch), not by the container and not by a fallback; BUNDLE_DOMAINis distinct from the N4-C, N4-D and GEN separators;summarizelists the four facts in canonical order with the right kinds, positions and witness counts for a bundle whose act would be refused.
9. icnctl device-authority
Stateless. Reads the bundle file and nothing else: no data directory is opened, no daemon or network is contacted, no N2-A gate applies (nothing persistent is touched). stdout is one JSON document; the exit code is the verdict class. The verb is a mechanical consumer of §5–§7:
bytes -> EvidenceBundle::decode -> admit (every pair through AuthorityStore::ingest)
-> verify_device_act at the --subject and --position the caller states
-> one JSON document; exit code = verdict class
icnctl device-authority verify --bundle FILE --subject HEX64 --position N
icnctl device-authority inspect --bundle FILE
| exit | verdict |
when |
|---|---|---|
| 0 | authorized |
the act is covered; the document carries the N4-A evidence |
| 1 | refused |
all facts admissible and N4-A refused (class = one of the N4-A refusal names: MalformedAct, ActSubjectMismatch, EvaluationPositionMismatch, BadSignature, PositionOutOfRange, SubjectUnknown, AuthorityHalted, PrefixIncomplete, DeviceIsEstablishmentAuthority, DeviceNotAuthorized, GrantNotInForce, CapabilityNotGranted), or one fact was inadmissible (class = InadmissibleFact, with the fact's event_id) |
| 2 | malformed |
not a strict canonical bundle, unreadable file, or an unusable --subject |
authorized document:
{
"verdict": "authorized",
"subject": "<hex32>", "device": "<hex32>", "device_did": "did:icn:…",
"capability": "Sign", "evaluation_position": 3,
"grant": { "capabilities": ["Sign", "Present"], "validity": null, "granted_at": 2 },
"generation": 0,
"act_id": "<hex32>", "bundle_id": "<hex32>", "fact_count": 4
}
Every field comes from the VerifiedDeviceAct that verify_evidence_bundle returned; act_id
is that verified act's own id, over the exact bytes whose signature verified, not an id computed
apart from verification. authorized means "authorized under the facts this bundle carries, at
the position the relying party named": it is not current authorization, completeness of the
Subject's history, or finality (N4-A §2; §3.3 above). The verb is CLI-REACHABLE library code;
nothing in production calls it. device_did is the key-derived spelling of the device Principal
(IS §3): it names a device, never a Subject or a member, and MUST NOT be fed into a legacy
did:icn: member or Subject path.
inspect prints the bundle's own scope (subject, evaluation_position), each fact (index,
kind_tag, subject, position, event_id, witness_count) and the act (subject, device,
device_did, capability, evaluation_position, payload_len, act_id) and decides
nothing: a bundle whose act would be refused inspects with exit 0.
The verb MUST NOT, and does not: choose E; fetch missing facts; query icnd; repair malformed
evidence; normalize a fork; invent freshness or currentness; fall back on any failure.
--subject and --position are mandatory and are never read from the bundle. A caller that takes
them from the bundle has made itself the signer's relying party, which is the one thing N4
invariant 6 forbids. (A caller that wants "valid now" computes its position with N4-E over its own
facts and passes that.)
10. What a downstream consumer MAY rely on / MUST NOT do
Everything in N4A_DEVICE_AUTHORITY_EVALUATION.md §9.3, plus:
MAY: carry a bundle over any transport; store a bundle as the record of what was evaluated,
where the consumer has its own basis to retain it — a valid bundle is not permission to keep it,
and retention is a separate decision (see ADR-0089, accepted 2026-10-27 for fixture Subjects
only, not implemented);
treat bundle_id as the identity of what was carried (it is witness-dependent, unlike act_id);
run icnctl device-authority verify as its relying party and gate on exit code and class;
re-verify at a later position once it has retained more facts, by assembling a new bundle.
MUST NOT: take subject or position from the bundle; cache a verdict across positions;
treat inspect output as a verdict; treat exit 1 with InadmissibleFact as "some facts were
fine"; reimplement the framing, the admission rules or the fold in another language (the Python
file is an audit, not a client); put anything in a bundle that is not an N1 fact or the act;
reintroduce a second container format or a compatibility decoder for the superseded one.
11. Convergence record (2026-10-04)
Two implementations of this boundary were developed independently from the same base
(eee1dca63): DeviceAuthorityBundleV1 (icn.n4.device-authority-bundle, this branch at
b5df9d93b, with the icnctl verb) and EvidenceBundle (icn.n4.evidence-bundle,
task/n4a-portable-evidence-bundle at 8962f17a5/ae0a0e1ee, with the independent reference).
The requirement was one canonical byte representation. They were compared directly against
IDENTITY_SEMANTICS.md, N4-A, the N1 encoding/admission/store/fold and #2599/#2694, and
EvidenceBundle was selected. The differences, and why each resolved the way it did:
| Dimension | DeviceAuthorityBundleV1 (removed) |
EvidenceBundle (canonical) |
Why it mattered |
|---|---|---|---|
| Evidence scope | any (body, witness) pair for any Subject at any position; pruning left to the verifier |
exactly { b ∈ Bodies(S) : position(b) ≤ E } with every witness; FactSubjectMismatch / FactOutsideScope at decode |
§3.2: the scope is the derive_prefix input set; enforcing it makes "same evidence, same bytes" a property of the format and keeps a fact above E out of a proof about E |
| Header | none | b32(S) ‖ u64be(E), agreeing with the act |
the bundle names what it is evidence for; a consumer can state its scope before admitting anything |
| Body/witness representation | flat record per witness: event_id ‖ σ ‖ LP(body); a re-signed body repeated its bytes |
one body once with all its witnesses, ≥ 1 |
§4.4: one encoding per body; no two records for one digest carrying different bytes |
| Ordering / duplicates | strict by (event_id, σ); duplicates refused |
strict by event_id, witnesses strict bytewise; duplicates refused |
equivalent strictness; the grouped form is the one the header and scope rule need |
event_id stated and checked |
yes | yes | equivalent |
| Bound | MAX_BUNDLE_FACTS = 2^16, invented |
none beyond Reader::count, N4-A's payload bound and N1's MAX_POSITION |
§4.6: traces to no governing contract; a cap is a storage-policy decision this layer does not own |
| Verification wrapper | verify_bundle = store_from_bundle → verify_device_act |
verify_evidence_bundle = admit → verify_device_act (ported) |
pure composition in both; kept as a thin API, documented as such, not as a layer |
| Reference | zero-fact framing vector + the act vector | full 4-fact vector derived from seeds, plus an independent strict decoder | the stronger cross-implementation evidence |
| CLI | `icnctl device-authority verify | inspect` | none |
Disposition: device_authority_bundle.rs, its test file, its Python reference, the domain
icn.n4.device-authority-bundle, MAX_BUNDLE_FACTS, store_from_bundle, verify_bundle and
BundleVerifyError are removed. verify_bundle's composition survives as verify_evidence_bundle;
its summarize survives on EvidenceBundle; its tests that the donor suite did not already cover
are ported (§8.2); its icnctl verb is retargeted. N4-C, the two-device lifecycle acceptance and
N4-D assemble their bundles from a store instead of from a fact list; nothing they assert changed.
N4-E never depended on the container. No compatibility decoding was added: neither format had
deployed or persisted bytes. The standalone portable-evidence branch is superseded by this one.
12. What changed in N1 and N4-A
Nothing semantic. The only edit outside the module is two pub(crate) helpers, Writer::b64
and Reader::b64, in authority_log/encoding.rs (identical on both branches), so a 64-byte
signature is framed with N1's own reader and writer rather than a second implementation of
fixed-width reads. No wire format, no constant, no admission rule, no fold rule and no verifier
rule changed. DeviceActV1 still has no serde and no wire encoding beyond its canonical bytes; the
bundle carries those bytes verbatim.
13. What remains open
- Where a client gets the facts. This document assumes the bundler holds them. The N1-D fact
store (#2800) is how a replica persists them; replication between replicas is N3 (#2598); how a
device first receives its Subject's facts is part of the N4 enrollment ceremony (#2599,
N4C_DEVICE_ENROLLMENT_REQUEST.mdstep 4, unbuilt). - Which position a relying party should pin for a deferred (class 1) decision: G1-A, #2694 §6. For a class-2 act admitted now, N4-E computes it from the relying party's own facts.
- Late fork discovery and omitted facts (§3.3): O-N7 and N3, not this container.
- A gateway route as a hosted relying party (N4-A §9.2 item 4) — deliberately unbuilt.
14. Deliberate scope boundaries
Not in this slice, by design: icnd wiring; a gateway route; SDK bindings; persistence or any
coupling to N1-D's trait (N1-D is a sibling whose record concept is shared, §4.4); N3 replication;
recovery; rotation or replacement lifecycle; GEN subject-context work; Home enrollment transport; Pi
or network-ops changes; governance voting; institution or member recognition; any real identity
material.
The format has one version. A future version is a new BUNDLE_VERSION with its own decoder branch;
v1 bytes never change meaning.