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
| Field | Req | Meaning |
|---|---|---|
| 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— theentry_hashof its last accepted entry,nullon a from-genesis sync;expected_head— the{seq, entry_hash}of the last signed head it accepted,nullif it has never accepted one.
It MUST reject the page unless all of the following hold:
versionMUST be"feed-page/0.1"or"feed-page/0.2"(§4.1). A verifier MUST reject any other string.- Every entry:
versioncorrect, recomputedentry_hashequalsentry_hash, signature verifies underfeed_id, andfeed_idequals the page's. - The first entry's
prev_hashMUST equalexpected_prev_hash; each subsequent entry'sprev_hashMUST equal the prior entry'sentry_hash. - Sequence numbers MUST be contiguous — each
seqis the prior+ 1. - The signed head MUST verify and its
feed_idMUST match. If the page carries entries,head.seqMUST NOT be less than the last entry'sseq; where the two are equal,head.entry_hashMUST equal that entry'sentry_hash. Wherehead.seqis greater, the page is a partial page (§4.1) andhead.entry_hashcommits to an entry the subscriber has not seen. - Where
expected_headis held,headMUST be present — anullhead denotes a feed with no entries (§4), which a subscriber holding a pinned head knows to be false — andhead.seqMUST NOT be less thanexpected_head.seq. Where the twoseqare equal,head.entry_hashMUST equalexpected_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).
| Type | Status | Meaning |
|---|---|---|
| feed/state-checkpoint | Normative (§6) | Compaction anchor |
| feed/key-rotation | Non-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.