VeriFeed Working draft

Reference · Working Draft

Wire Format & Verification

The normative shape of an entry, a signed head and a page, and what a subscriber must check.

Wire versions feed-entry/0.1, feed-head/0.1, feed-page/0.1 and feed-page/0.2. Reviewable, not yet frozen. Normative requirements use RFC 2119 keywords (MUST, SHOULD, MAY); all other text is non-normative. Cryptography is inherited: Ed25519 [RFC 8032] over JCS-canonical bytes [RFC 8785], with did:key issuers.

1Scope and non-goals

A feed is an append-only sequence of entries published by one issuer, identified by that issuer's DID (feed_id). This document defines the entry, the signed head, the page a subscriber pulls or is pushed, and the verification a subscriber MUST perform.

Transport, storage, subscription registration, push retry and delivery semantics, and Merkle inclusion proofs are out of scope (§8, §9). Fork detection is partly in scope: §7 attributes a fork once two conflicting heads meet; the gossip transport that brings them together, and witness co-signing, are not. Withholding — an issuer freezing one subscriber's view instead of forking — is not detectable (§7). The payload is opaque and its meaning belongs to the consumer.

2Entry — feed-entry/0.1

FieldReqMeaning
version✓"feed-entry/0.1"
feed_id✓Issuer DID (did:key) — the signer
seq✓Monotonic non-negative integer; contiguous within a feed
issued_at✓RFC 3339 timestamp, caller-asserted
payload✓Opaque JSON object; MUST carry a non-empty string type
prev_hash✓Previous entry's entry_hash, or null for seq = 0
entry_hash✓"sha256:" + hex(sha256(JCS(core))), core = the six fields above
signature✓base64 Ed25519 over JCS(entry without signature)

prev_hash is always present, null only for the genesis entry, never an omitted key. payload is not interpreted by this layer; type lets a consumer dispatch. The feed/ type prefix is reserved (Appendix A).

2.1 Closed field set

A verifier MUST reject an entry whose key set is not exactly the eight fields above: no missing key, no extra key. An extra field would be covered by signature but not by entry_hash, admitting two entries that differ on the wire and agree on their content address. The same rule applies to the head (§3) and the page (§4). It forbids an additive optional field under an unchanged wire version string (§11).

3Signed head — feed-head/0.1

{version, feed_id, seq, entry_hash, generated_at, signature} — the issuer's signed statement of its current head. A subscriber pins the last head it accepted to detect a later rewind or fork.

4Page — feed-page/0.1 · feed-page/0.2

{version, feed_id, since, entries[], head} — entries with seq > since in ascending contiguous order, plus the issuer's signed head (null only for a feed with no entries). Pull (GET …/feed?since=<cursor>) and push (POST to a callback) deliver the same object.

4.1 Partial pages

A page MAY carry a prefix of the entries after since. A publisher serving a long backlog SHOULD chunk it. A partial page MUST declare feed-page/0.2; a page whose head equals its last entry MUST declare feed-page/0.1. The two versions differ in exactly one rule — whether the head may run ahead — and in nothing else. head is always the issuer's current head, so on a partial page it runs ahead of the page's last entry. A subscriber detects this by comparing head.seq with the last entry's seq; no extra field is required, and a full page is one where the two are equal. head.seq MUST NOT be less than the last entry's seq: a head behind the entries it accompanies is a rewind (§5, rule 5).

Partial pages make the resource-bound boundary coherent. Bounding a response body and rate-limiting an endpoint belong to the transport, which requires that the format permit a bounded response. A subscriber that has not reached head.seq re-requests with its new cursor.

Tail truncation. Within feed-page/0.2, dropping entries from the tail of a page is indistinguishable from a publisher sending fewer. Within feed-page/0.1 it is detected: that version's contract is that the head equals the last entry, so a truncated page contradicts its own version string. Completeness is unaffected: it is a property of the run between two anchors (§5.1). The cursor stops earlier, head.seq exceeds it, and the subscriber re-requests. A truncated tail looks like entries that have not arrived yet, not like entries that do not exist. Entries dropped between two the subscriber holds break the chain (§5, rule 3).

5Verification

A subscriber verifying a page holds two pieces of state:

  • expected_prev_hash — the entry_hash of its last accepted entry, null on a from-genesis sync;
  • expected_head — the {seq, entry_hash} of the last signed head it accepted, null if it has never accepted one.

It MUST reject the page unless all of the following hold:

  1. version MUST be "feed-page/0.1" or "feed-page/0.2" (§4.1). A verifier MUST reject any other string.
  2. Every entry: version correct, recomputed entry_hash equals entry_hash, signature verifies under feed_id, and feed_id equals the page's.
  3. The first entry's prev_hash MUST equal expected_prev_hash; each subsequent entry's prev_hash MUST equal the prior entry's entry_hash.
  4. Sequence numbers MUST be contiguous — each seq is the prior + 1.
  5. The signed head MUST verify and its feed_id MUST match. If the page carries entries, head.seq MUST NOT be less than the last entry's seq; where the two are equal, head.entry_hash MUST equal that entry's entry_hash. Where head.seq is greater, the page is a partial page (§4.1) and head.entry_hash commits to an entry the subscriber has not seen.
  6. Where expected_head is held, head MUST be present — a null head denotes a feed with no entries (§4), which a subscriber holding a pinned head knows to be false — and head.seq MUST NOT be less than expected_head.seq. Where the two seq are equal, head.entry_hash MUST equal expected_head.entry_hash.

On success the subscriber persists the last entry's {seq, entry_hash} as its next cursor and expected_prev_hash, and the page's head as its next expected_head. On a page with no entries, both carry forward unchanged.

Rule 6 is the entire rewind defence, and it does nothing unless the subscriber supplies the head it pinned. A subscriber that omits expected_head will accept a validly signed head at any position, including one behind the history it already holds. Supplying it is REQUIRED.

5.1 What a verified page establishes

Completeness is relative to the anchor held, and is claimed only as far as the last entry walked. A page whose head.seq exceeds its last entry's seq establishes completeness to that entry, not to the head. Sync from genesis for end-to-end completeness, or from a trusted signed head to start mid-stream.

A valid page establishes that the subscriber received an authentic, complete run of entries the issuer signed. It establishes nothing about whether the issuer's payloads are true, and nothing about the history the issuer showed any other subscriber. The first limit belongs to the consuming payload profile; the second is §7 and the part of it left open in §9.

Completeness-to-head is measured against the head the page presented, not the issuer's current head, and is not a freshness signal. An issuer replaying a stale head to a subscriber whose view it is freezing satisfies it on every pull, indistinguishably from a feed to which nothing new has been published.

5.2 Entry timestamps

An issuer SHOULD emit issued_at non-decreasing with seq. A verifier MUST NOT reject an entry or a page for violating it. The chain, not the clock, is the ordering authority, and issued_at is caller-asserted with no trusted source. A consumer MAY surface the anomaly to its own layer.

6Compaction — state checkpoints

A checkpoint is an ordinary entry whose payload.type is "feed/state-checkpoint", carrying {covers_through_seq, chain_tip, state_digest}. chain_tip MUST equal the entry's own prev_hash; in a linear chain that hash commits the whole prior history, so no Merkle root is used (§8).

A late-joining subscriber MAY adopt a signed checkpoint as its local genesis: verify the entry (§2), then use its entry_hash as expected_prev_hash and walk forward (§5) from seq + 1. Doing so trusts the issuer for all history at or before the checkpoint; no completeness claim is made for it. state_digest is opaque to this layer.

A checkpoint is an entry, not a signed head, so a late joiner has no expected_head to supply on its first verification, and is rewind-blind for that one page. The gap follows from joining mid-stream: a joiner that adopts a checkpoint has already accepted the issuer's word for everything behind it, so one unpinned page adds little. A joiner requiring more SHOULD obtain a signed head out of band.

7Fork detection — equivocation via gossip

Subscribers gossip the latest signed head each has seen. Two heads with the same feed_id and seq and conflicting entry_hash, both validly signed, establish that the issuer equivocated. A verifier MUST check both signatures before emitting a feed-equivocation/0.1 proof {feed_id, seq, head_a, head_b}; a proof built from an unverified head is invalid and MUST NOT be produced. Any party can re-check a proof from the issuer's own two signatures. The proof attributes the fork; remediation policy is out of scope.

Withholding. A fork is detectable only where two heads conflict at the same seq. An issuer freezing one subscriber's view at an earlier seq while serving others further on has not equivocated by this definition, and no proof exists to be produced: every head it signed is internally consistent. §9 records the intended remedy.

8Strict linearity (no Merkle)

The chain is strictly linear and walked sequentially via prev_hash (§5). Merkle trees and O(log n) inclusion or consistency proofs are out of scope by design, not deferred: (seq, entry_hash) fully locates a position in a linear history, sufficient for both compaction (§6) and equivocation (§7).

Establishing that a single historic entry is in the feed requires walking to it, at O(n), against O(log n) for a Merkle log. In exchange there is no tree to build, store, serve or get wrong, and a conformant implementation runs to a few hundred lines. At the scale of a certificate log this trade is wrong and Certificate Transparency [RFC 9162] remains the correct construction. At the scale of an announcement feed it is not.

9Not yet covered

  • Push transport — delivery, retry and subscription registration belong to the consumer.
  • Witness co-signing — §7 detects a fork once two conflicting heads meet; soliciting witness signatures on a head is future work. It is also the intended remedy for withholding: a head co-signed at a known position by parties the subscriber did not learn about from the issuer supplies an external reference for how far the feed has advanced, which self-signed history cannot.
  • Long-feed compaction cadence — how often to checkpoint, and how to prune without weakening the completeness claim for subscribers anchored mid-stream.

10Conformance

A conformant implementation passes the conformance vectors and the reference test suite. The suite is the only mechanical authority. The published JSON Schemas are the structural contract, and are exercised against the vectors.

11Versioning

Two version lines, and they are not the same thing.

Package version. SemVer, per the distribution. A breaking wire change bumps major; a backwards-compatible addition bumps minor; a clarification bumps patch.

Wire version strings (feed-entry/0.1, feed-head/0.1, feed-page/0.1). An object's string changes whenever either its field set or its verification contract changes, the addition of an optional field included. Verifiers enforce a closed field set (§2.1) and reject version strings they do not recognise, so an object carrying an unknown field is indistinguishable from a forgery and is rejected as one. There is no additive optional field under an unchanged wire string: adding a field mints a new wire version by construction.

A package minor bump may therefore introduce a new wire version string alongside the old one, and a verifier accepting both is what makes such a release backwards-compatible. Nothing in this document permits a verifier to ignore unknown fields.

feed-page/0.2 is the first application of this rule. Release 0.2.0 relaxed the head constraint (§4.1), which changed the page's verification contract, so the page string moved while feed-entry and feed-head — whose contracts did not change — stayed at 0.1.

Appendix A — Reserved payload types

Non-normative. The payload is opaque (§2) with one exception: the feed/ type prefix is reserved for conventions defined by this specification. A consuming profile MUST NOT mint its own feed/* types; use a profile-specific prefix (agent/capability-announced, registry/agent-updated).

TypeStatusMeaning
feed/state-checkpointNormative (§6)Compaction anchor
feed/key-rotationNon-normative (A.1)Successor-key endorsement

A.1 feed/key-rotation

did:key binds a feed to one keypair: feed_id is the public key, so a feed cannot change keys without becoming a different feed. Key custody is out of scope, and the absence of a convention leaves an issuer that must rotate with no way to say so in band.

Before retiring a key, an issuer appends an ordinary entry — signed by the old key, chained normally, verifying under §5 — whose payload is:

{
  "type": "feed/key-rotation",
  "successor_feed_id": "did:key:z6Mk...",
  "effective_from_seq": 128
}

A subscriber that has verified this entry under the old key MAY thereafter accept entries from successor_feed_id as the continuation of the same logical feed from effective_from_seq onward. The endorsement's authority is the old key's signature: the outgoing key vouches for its successor while it is still able to.

This is non-normative, and no verifier behaviour depends on it. Verification continues to require every entry's feed_id to equal the page's, so a rotation spans two feeds and stitching them is the consumer's decision. A compromised old key can endorse an attacker's successor: the key-custody boundary restated.