# RSAS 0.1 — Really Simple Agent Syndication

**Status:** stable draft. **Version:** `0.1`. **Publisher of this document:** In The Loop.
**License:** Apache-2.0.

RSAS is a JSON syndication format for machine consumers. It exists because the agents
that read feeds want three things RSS and Atom do not offer: a **stable identifier** per
item, **complete delivery** without a polling race, and **provenance you can check
offline**.

An RSAS feed is a set of static JSON files:

```
/rsas/feed.json              the head — signed, small, regenerated every run
/rsas/archive/00000.json     append-only archive pages, oldest first
/rsas/archive/00001.json     …frozen pages never change again, byte for byte
/.well-known/rsas.json       discovery: where the feed is, which keys sign it
```

There is no API, no write path, no negotiation, and no state on the server. A consumer
fetches files and verifies signatures.

### What a record asserts

An RSAS publisher **wraps, never absorbs**. A record is the publisher's signed
attestation that *"feed X emitted item Y, and I observed it no later than T"*. It carries
the item's title, a short plain-text summary, and the canonical link to the origin. It
never republishes origin content, and the signature makes no claim about whether the
origin's content is true, correct, or still present.

---

## 1. Conventions

The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in
RFC 2119.

- All JSON is UTF-8, without a byte order mark.
- All timestamps are RFC 3339 in UTC with second precision and a literal `Z`:
  `2026-08-22T00:00:12Z`. Fractional seconds and numeric offsets MUST NOT be used.
- **All numbers in RSAS are integers.** Floating point values MUST NOT appear anywhere
  in the format. (This keeps canonicalization — and therefore signatures — free of
  number-formatting ambiguity.)
- All strings are Unicode NFC. Publishers normalize at construction time; verifiers do
  not normalize before checking a signature.
- Links *within* a feed (`current_archive`, `prev_archive`) are **root-relative paths**
  and MUST be resolved against the URL the containing document was fetched from. Only
  the head and the discovery document carry absolute URLs. A publisher that moves to a
  new hostname therefore never has to rewrite — and thereby invalidate — archived bytes.

---

## 2. Canonicalization and signing

Every signed object carries exactly one `sig` member.

1. Take the object and **remove its top-level `sig` member**.
2. Serialize the result with **RFC 8785, the JSON Canonicalization Scheme (JCS)**:
   object members sorted by the UTF-16 code units of their names, no insignificant
   whitespace, minimal string escaping, integers printed in their shortest form.
3. The signature is **Ed25519** (RFC 8032) over the UTF-8 bytes of that string.
4. `sig` is `"ed25519:" + kid + ":" + base64url(signature)`, base64url per RFC 4648 §5,
   **without padding**. The signature is 64 bytes, so the encoded form is 86 characters.

`kid` names the key that signed the object, so a publisher can rotate keys without a
format break. RSAS 0.1 defines one key id: `k1`.

Because canonicalization is JCS and numbers are integers, any implementation in any
language reproduces the signed bytes exactly. A verifier that cannot reproduce the bytes
MUST treat the object as invalid rather than guess.

### 2.1 Key pinning — read this before you trust anything

**A feed cannot authenticate itself with a key it carries.** The `keys[]` array in
`/.well-known/rsas.json` is a convenience for key discovery, not a root of trust.

A verifier MUST obtain the publisher's public key **out of band** — from this spec
document, from the publisher's website, or by pinning on first use — and MUST check that
the in-feed copy matches the pinned key. If they differ, verification fails; the feed does
not get to explain the difference.

**In The Loop's publishing key**, for pinning:

```
kid        k1
public_key ed25519:iqD8t1k2eG0ONxtnGuLXxPK7JZKUhEzjxVk-leauzZg
```

### 2.2 Key rotation

Rotation is additive: the publisher generates a new key, publishes it in `keys[]` with a
`not_before`, and signs new records with the new `kid`. Records signed by the retired key
remain verifiable forever, because every signature names its own key. RSAS 0.1 publishers
are not required to implement rotation, only to carry `kid`.

---

## 3. Records

A record is either an **entry** or a **tombstone**. The two shapes are a tagged union on
`kind`; a consumer MUST handle exactly these two and MUST ignore nothing else — an
unknown `kind` is a hard error, not a skip, because a record it cannot interpret may be
retracting something it is showing.

### 3.1 Entry

```json
{
  "kind": "entry",
  "id": "itl:9f2c4a7b1e8d3f60a5b2c9d4e7f01a3b",
  "source_id": "src-brooker",
  "origin_feed": "https://brooker.co.za/blog/rss.xml",
  "origin_id": "https://brooker.co.za/blog/2026/08/01/example.html",
  "url": "https://brooker.co.za/blog/2026/08/01/example.html",
  "title": "An example post",
  "summary_text": "Plain text, at most 500 characters. May be empty.",
  "topics": ["distributed-systems"],
  "published": "2026-08-01T14:03:00Z",
  "observed_at": "2026-08-01T18:00:07Z",
  "sig": "ed25519:k1:…"
}
```

| Member | Required | Meaning |
|---|---|---|
| `kind` | yes | `"entry"` |
| `id` | yes | The record identifier — see §3.3 |
| `source_id` | yes | The publisher's immutable identifier for the source feed |
| `origin_feed` | yes | The feed URL at observation time. Display metadata; **not** an input to `id` |
| `origin_id` | yes | The origin's own identifier for the item, **verbatim** (§3.3) |
| `url` | yes | Canonical link to the item at its origin |
| `title` | yes | Plain text, ≤ 300 characters |
| `summary_text` | yes | Plain text, ≤ 500 characters, may be empty |
| `topics` | yes | 1–3 tags from the publisher's taxonomy (§5) |
| `published` | no | The origin's publication time. **Omitted** when the origin gave no parseable date — never guessed |
| `observed_at` | yes | The publisher attests it observed this item **no later than** this instant |
| `sig` | yes | §2 |

**Entries are immutable.** The same `id` MUST appear at most once in the entire archive
chain, and its bytes MUST never change. An entry is signed once, when first observed, and
is never re-signed. If the origin edits an item after publication, RSAS 0.1 does not
reflect the edit — the attestation is about what was observed, not about what the origin
currently says.

`title` and `summary_text` are derived from the origin by stripping markup to plain text,
collapsing whitespace, NFC-normalizing, and — if still over the limit — truncating at a
word boundary and appending `…` (U+2026). Truncation never splits a Unicode code point.

### 3.2 Tombstone

A tombstone is an **editorial retraction**, issued by a human. It is always a *new*
record appended to the current archive page; the page holding the retracted entry is
never touched.

```json
{
  "kind": "tombstone",
  "supersedes": "itl:9f2c4a7b1e8d3f60a5b2c9d4e7f01a3b",
  "source_id": "src-brooker",
  "origin_feed": "https://brooker.co.za/blog/rss.xml",
  "retracted_at": "2026-08-14T09:12:00Z",
  "reason": "Short, public-safe reason.",
  "sig": "ed25519:k1:…"
}
```

- A tombstone MUST supersede an entry that exists in the chain, and there MUST be at most
  one tombstone per `id`.
- Consumers MUST apply a tombstone over any cached copy of the superseded entry, however
  old that cache is.
- **A tombstone is never inferred from feed contents.** An item aging out of an origin's
  RSS window is aging, not retraction, and MUST NOT produce a tombstone.
- Every tombstone ever issued also appears in the head's `retractions` array, so a
  consumer that caches frozen pages forever still learns about retractions from a single
  head fetch.

### 3.3 Identifier derivation

```
id = "itl:" + lowercase_hex( first 16 bytes of SHA-256( source_id + "\n" + origin_id ) )
```

encoded UTF-8, where `\n` is a single U+000A. The prefix identifies the publisher
namespace; `itl:` is In The Loop's.

`origin_id` is taken from the origin document, byte-exact, by the first rule that
produces a non-empty value:

1. RSS `<guid>` text — XML entity- and CDATA-decoded, otherwise **verbatim**: no
   trimming, no case folding, no Unicode normalization.
2. Atom `<id>` — same treatment.
3. JSON Feed `id` (stringified if the document used a number).
4. The entry's link, in absolute form.
5. Last resort: `"sha256:" + hex(SHA-256(T + "\n" + P))` where `T` is the
   stripped, NFC-normalized, **pre-truncation** title and `P` is the origin's raw date
   string, or the empty string if it had none.

If rule 5 has no title to work with either, the item has no stable identity and the
publisher MUST skip it rather than mint one.

Two consequences worth stating plainly. `source_id` is immutable *by policy at the
publisher* — correcting a feed's URL does not change any identifier, because the URL is
not an input. And a *deliberately* opaque `origin_id` (`<guid isPermaLink="false">`) is
honoured as-is, which is what makes ids stable across an origin's own URL changes.

---

## 4. Documents

### 4.1 Feed head — `/rsas/feed.json`

Signed. Regenerated and re-signed on every publication run, including runs that add
nothing.

```json
{
  "rsas_version": "0.1",
  "title": "ITL RSAS Index",
  "publisher": "In The Loop",
  "home_url": "https://<base>/",
  "feed_url": "https://<base>/rsas/feed.json",
  "well_known_url": "https://<base>/.well-known/rsas.json",
  "cadence_seconds": 21600,
  "generated_at": "2026-08-22T00:00:12Z",
  "current_archive": "/rsas/archive/00007.json",
  "current_archive_item_count": 37,
  "total_records": 743,
  "items_truncated": true,
  "retractions": [],
  "items": []
}
```

- `items` — the newest **at most 100** records, sorted by timestamp descending, then
  identifier ascending. The head is a convenience window, not a delivery guarantee.
  Because a tombstone has neither an `observed_at` nor an `id`, the sort keys are stated
  per kind: an entry sorts on `observed_at` and `id`, a tombstone on `retracted_at` and
  `supersedes`.
- `items_truncated` — whether records exist that are not in `items`.
- `total_records` — records across the whole chain, entries and tombstones together.
- `current_archive_item_count` — lets a consumer notice it has a stale cached copy of the
  current page.
- `retractions` — every tombstone ever issued, in full.
- `cadence_seconds` — how often the publisher intends to run. Consumers calibrate polling
  to this; see §6.
- The signature covers the head *including* `items`, so truncation or tampering of the
  window is detectable.
- On a run that finds nothing new, only `generated_at` and `sig` change.

### 4.2 Archive pages — `/rsas/archive/NNNNN.json`

The canonical, complete, append-only log. Page numbers start at `0` and are zero-padded
to five digits in the filename.

```json
{
  "rsas_version": "0.1",
  "page": 7,
  "frozen": false,
  "prev_archive": "/rsas/archive/00006.json",
  "items": []
}
```

- `items` are in **append order, oldest first**. Every entry in one publication run
  shares a single `observed_at`, so entry `observed_at` values never decrease along the
  chain — a consumer walking backwards may stop as soon as it reaches an instant it has
  already processed. A tombstone's `retracted_at` is editorial and carries no such
  guarantee: it may name a moment earlier than the entry it follows.
- A page accumulates records until it holds exactly 100. It is then written once with
  `"frozen": true` and **MUST never change again, byte for byte, with no exceptions.**
  Consumers MAY cache a frozen page forever.
- **Freezing page N atomically creates page N+1** — empty, `"frozen": false`, with
  `prev_archive` pointing at page N — in the same publication step, so `current_archive`
  can never name a file that does not exist. An empty just-created page is valid.
- `prev_archive` is absent on page 0, and only on page 0. That is how a walk terminates.
- The current (unfrozen) page is rewritten as records are appended to it.

### 4.3 Discovery — `/.well-known/rsas.json`

Signed.

```json
{
  "rsas_version": "0.1",
  "feed_url": "https://<base>/rsas/feed.json",
  "publisher": "In The Loop",
  "contact": "https://<base>/",
  "spec_url": "https://<base>/spec/RSAS-0.1.md",
  "keys": [
    {
      "kid": "k1",
      "public_key": "ed25519:iqD8t1k2eG0ONxtnGuLXxPK7JZKUhEzjxVk-leauzZg",
      "not_before": "2026-08-22T00:00:00Z"
    }
  ]
}
```

Remember §2.1: `keys[]` is discovery, not trust. Pin out of band.

---

## 5. Topics

A record's `topics` are the *publisher's* editorial tags for the source, not tags the
origin supplied. They are lowercase kebab-case. The publisher declares its taxonomy
alongside its registry; for In The Loop's feed the v1 taxonomy is closed and is:

`durable-execution` · `ai-agents` · `distributed-systems` · `dev-tooling` ·
`research` · `industry-news`

Consumers SHOULD treat topics as a filter hint and MUST NOT assume the set is closed
forever — a publisher may extend its taxonomy without a format version bump, which is why
the record schema constrains the *shape* of a tag rather than its value.

---

## 6. Consuming an RSAS feed

### 6.1 Complete delivery

The head window is not a delivery guarantee. **The archive walk is the only complete
path:**

```
head = GET /rsas/feed.json                     verify signature
page = GET head.current_archive                verify every record
while page.prev_archive:
    page = GET page.prev_archive               verify every record
                                               stop early at a page you already hold
                                               frozen ⇒ safe to cache forever
```

A consumer that has walked once needs only the head plus the current page on later polls,
until `current_archive` names a page it has not seen — then it walks back until it
reaches a frozen page it already holds.

### 6.2 Verification

A consumer SHOULD, in order:

1. Check the head's signature against the **pinned** key (§2.1).
2. Check `generated_at` for staleness — older than `2 × cadence_seconds` means the
   publisher is failing, even though every signature is still perfectly valid. Signed and
   stale is the failure mode this format is designed to make visible; do not confuse a
   valid signature with a live feed.
3. Check every record's own signature. Records are self-contained: a record copied out of
   the feed and stored elsewhere remains verifiable.
4. Recompute each entry's `id` from `source_id` and `origin_id` (§3.3) and check it
   matches. A publisher cannot silently re-point an identifier.
5. Enforce uniqueness: each entry `id` exactly once across the chain, at most one
   tombstone per `id`.
6. Apply tombstones — from the current chain *and* from the head's `retractions` array.

### 6.3 Polling etiquette

- Poll no more often than `cadence_seconds`. The feed changes only when a run publishes.
- Send `If-None-Match` / `If-Modified-Since`; expect `304`.
- Frozen archive pages are served `Cache-Control: immutable`. Fetch each exactly once,
  ever.
- Identify yourself in `User-Agent` with a contact URL.

### 6.4 Errors a consumer should expect

| Situation | What it means | What to do |
|---|---|---|
| Head signature fails | Tampering, wrong pinned key, or a canonicalization bug in your verifier | Fail closed; do not fall back to the in-feed key |
| Head is stale | The publisher's pipeline is failing | Serve cached data, surface the staleness |
| A record's `id` does not recompute | Publisher bug or tampering | Reject that record, keep the rest |
| `current_archive_item_count` disagrees with the page you fetched | CDN cache skew between two URLs | Re-fetch the page; it converges |
| An entry you hold is superseded | Editorial retraction | Stop showing it, keep the tombstone |

---

## 7. JSON Schema

Normative for both record kinds and for all three documents. Shipped beside this file as
`rsas.schema.json`; the copy below is identical.

<!-- BEGIN rsas.schema.json -->
```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://rsas.spec/rsas-0.1.schema.json",
  "title": "RSAS 0.1",
  "$defs": {
    "record_id": {
      "type": "string",
      "pattern": "^itl:[0-9a-f]{32}$"
    },
    "source_id": {
      "type": "string",
      "pattern": "^src-[a-z0-9]+(-[a-z0-9]+)*$"
    },
    "timestamp": {
      "type": "string",
      "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"
    },
    "sig": {
      "type": "string",
      "pattern": "^ed25519:[a-z0-9]{1,16}:[A-Za-z0-9_-]{86}$"
    },
    "topic": {
      "type": "string",
      "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
    },
    "https_url": {
      "type": "string",
      "pattern": "^https://"
    },
    "archive_path": {
      "type": "string",
      "pattern": "^/rsas/archive/[0-9]{5}\\.json$"
    },
    "entry": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "kind",
        "id",
        "source_id",
        "origin_feed",
        "origin_id",
        "url",
        "title",
        "summary_text",
        "topics",
        "observed_at",
        "sig"
      ],
      "properties": {
        "kind": { "const": "entry" },
        "id": { "$ref": "#/$defs/record_id" },
        "source_id": { "$ref": "#/$defs/source_id" },
        "origin_feed": { "$ref": "#/$defs/https_url" },
        "origin_id": { "type": "string", "minLength": 1 },
        "url": { "type": "string", "pattern": "^https?://" },
        "title": { "type": "string", "maxLength": 300 },
        "summary_text": { "type": "string", "maxLength": 500 },
        "topics": {
          "type": "array",
          "minItems": 1,
          "maxItems": 3,
          "uniqueItems": true,
          "items": { "$ref": "#/$defs/topic" }
        },
        "published": { "$ref": "#/$defs/timestamp" },
        "observed_at": { "$ref": "#/$defs/timestamp" },
        "sig": { "$ref": "#/$defs/sig" }
      }
    },
    "tombstone": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "kind",
        "supersedes",
        "source_id",
        "origin_feed",
        "retracted_at",
        "reason",
        "sig"
      ],
      "properties": {
        "kind": { "const": "tombstone" },
        "supersedes": { "$ref": "#/$defs/record_id" },
        "source_id": { "$ref": "#/$defs/source_id" },
        "origin_feed": { "$ref": "#/$defs/https_url" },
        "retracted_at": { "$ref": "#/$defs/timestamp" },
        "reason": { "type": "string", "minLength": 1, "maxLength": 280 },
        "sig": { "$ref": "#/$defs/sig" }
      }
    },
    "record": {
      "oneOf": [{ "$ref": "#/$defs/entry" }, { "$ref": "#/$defs/tombstone" }]
    },
    "archive_page": {
      "type": "object",
      "additionalProperties": false,
      "required": ["rsas_version", "page", "frozen", "items"],
      "properties": {
        "rsas_version": { "const": "0.1" },
        "page": { "type": "integer", "minimum": 0 },
        "frozen": { "type": "boolean" },
        "prev_archive": { "$ref": "#/$defs/archive_path" },
        "items": {
          "type": "array",
          "maxItems": 100,
          "items": { "$ref": "#/$defs/record" }
        }
      }
    },
    "feed_head": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "rsas_version",
        "title",
        "publisher",
        "home_url",
        "feed_url",
        "well_known_url",
        "cadence_seconds",
        "generated_at",
        "current_archive",
        "current_archive_item_count",
        "total_records",
        "items_truncated",
        "retractions",
        "items",
        "sig"
      ],
      "properties": {
        "rsas_version": { "const": "0.1" },
        "title": { "type": "string", "minLength": 1 },
        "publisher": { "type": "string", "minLength": 1 },
        "home_url": { "$ref": "#/$defs/https_url" },
        "feed_url": { "$ref": "#/$defs/https_url" },
        "well_known_url": { "$ref": "#/$defs/https_url" },
        "cadence_seconds": { "type": "integer", "minimum": 1 },
        "generated_at": { "$ref": "#/$defs/timestamp" },
        "current_archive": { "$ref": "#/$defs/archive_path" },
        "current_archive_item_count": { "type": "integer", "minimum": 0 },
        "total_records": { "type": "integer", "minimum": 0 },
        "items_truncated": { "type": "boolean" },
        "retractions": {
          "type": "array",
          "items": { "$ref": "#/$defs/tombstone" }
        },
        "items": {
          "type": "array",
          "maxItems": 100,
          "items": { "$ref": "#/$defs/record" }
        },
        "sig": { "$ref": "#/$defs/sig" }
      }
    },
    "discovery": {
      "type": "object",
      "additionalProperties": false,
      "required": ["rsas_version", "feed_url", "publisher", "contact", "spec_url", "keys", "sig"],
      "properties": {
        "rsas_version": { "const": "0.1" },
        "feed_url": { "$ref": "#/$defs/https_url" },
        "publisher": { "type": "string", "minLength": 1 },
        "contact": { "$ref": "#/$defs/https_url" },
        "spec_url": { "$ref": "#/$defs/https_url" },
        "keys": {
          "type": "array",
          "minItems": 1,
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["kid", "public_key", "not_before"],
            "properties": {
              "kid": { "type": "string", "pattern": "^[a-z0-9]{1,16}$" },
              "public_key": {
                "type": "string",
                "pattern": "^ed25519:[A-Za-z0-9_-]{43}$"
              },
              "not_before": { "$ref": "#/$defs/timestamp" }
            }
          }
        },
        "sig": { "$ref": "#/$defs/sig" }
      }
    }
  },
  "$ref": "#/$defs/feed_head"
}
```
<!-- END rsas.schema.json -->

---

## 8. Versioning

`rsas_version` is `"0.1"`. A change that would make an existing correct consumer wrong —
a new required member, a changed identifier derivation, a different canonicalization —
requires a new version and a new document. Additive members that consumers may ignore do
not; note that because signatures cover the whole object, a consumer MUST NOT strip
unknown members before verifying.

Known 0.1 limitations, listed so nobody has to rediscover them: origin edits are not
reflected (§3.1); there is no per-item cadence; there is no push, no query, and no
partial-content fetch; and a consumer that wants everything must walk the archive.
