| 1 | # Klonkt Content Archive, format version 1
|
|---|
| 2 |
|
|---|
| 3 | A portable archive of **your own content**: posts, their media, and — as a
|
|---|
| 4 | read-only historical record — the replies other people wrote underneath them.
|
|---|
| 5 | It can be exported from one Klonkt and imported into another, and it stays
|
|---|
| 6 | readable when Klonkt is gone.
|
|---|
| 7 |
|
|---|
| 8 | This is the format specification. It is written before the exporter and the
|
|---|
| 9 | importer exist, on purpose: without a written format each of them writes down
|
|---|
| 10 | its own assumptions, and then there are two sources of truth instead of one.
|
|---|
| 11 |
|
|---|
| 12 | ## What this is not
|
|---|
| 13 |
|
|---|
| 14 | | | |
|
|---|
| 15 | |---|---|
|
|---|
| 16 | | **Storage backup** (`shaer-190t`) | A byte-level zip of the storage directory, including password hashes, sessions and the per-actor private RSA keys. A backup for Klonkt-to-Klonkt restore. Never hand it to anyone. |
|
|---|
| 17 | | **GDPR data export** (`shaer-0j2`) | Personal data for inspection. Different scope, different audience. |
|
|---|
| 18 | | **This format** | Your content, portable, importable, reproducible, and free of every credential. |
|
|---|
| 19 |
|
|---|
| 20 | They may share serialisation code. They do not share a purpose, and they must
|
|---|
| 21 | not quietly become the same file.
|
|---|
| 22 |
|
|---|
| 23 | ## Container layout
|
|---|
| 24 |
|
|---|
| 25 | ```
|
|---|
| 26 | manifest.json format version, origin, counts, checksums
|
|---|
| 27 | posts/<post-id>.json ONE post — canonical, the only thing import reads
|
|---|
| 28 | replies/<post-id>.json replies received under that post — read-only archive
|
|---|
| 29 | media/<sha256>.<ext> the bytes, content-addressed
|
|---|
| 30 | readable/<slug>.md rendered for humans — derived, NEVER read on import
|
|---|
| 31 | ```
|
|---|
| 32 |
|
|---|
| 33 | Delivered as a zip. `<post-id>` is Klonkt's own post id, which is also the tail
|
|---|
| 34 | of the ActivityPub object id (`<origin>/ap/notes/<post-id>`). Using it as the
|
|---|
| 35 | filename means the identity is visible in the tree and no character escaping is
|
|---|
| 36 | needed. The slug lives inside the file, where it can change without renaming
|
|---|
| 37 | anything.
|
|---|
| 38 |
|
|---|
| 39 | ## manifest.json
|
|---|
| 40 |
|
|---|
| 41 | ```json
|
|---|
| 42 | {
|
|---|
| 43 | "formatVersion": 1,
|
|---|
| 44 | "generator": "klonkt/1.6.0",
|
|---|
| 45 | "exportedAt": "2026-08-06T13:00:00Z",
|
|---|
| 46 | "origin": "https://boiert.eu",
|
|---|
| 47 | "actor": "https://boiert.eu/ap/users/boiert",
|
|---|
| 48 | "site": { "slug": "boiert", "title": "Boiert" },
|
|---|
| 49 | "counts": { "posts": 412, "replies": 1180, "media": 233, "mediaMissing": 7 },
|
|---|
| 50 | "files": { "posts/abc123.json": "<sha256>", "media/<sha256>.jpg": "<sha256>" }
|
|---|
| 51 | }
|
|---|
| 52 | ```
|
|---|
| 53 |
|
|---|
| 54 | `origin` is the field the importer cannot work without. It decides whether the
|
|---|
| 55 | ActivityPub ids may be kept (see [Import rules](#import-rules)). Everything else
|
|---|
| 56 | in the manifest is verification and reporting.
|
|---|
| 57 |
|
|---|
| 58 | `exportedAt` lives **only** here. It must not appear in any post file, or two
|
|---|
| 59 | exports of unchanged content would differ.
|
|---|
| 60 |
|
|---|
| 61 | ## posts/<post-id>.json
|
|---|
| 62 |
|
|---|
| 63 | An ActivityStreams 2.0 object carrying the post **as authored**, with Klonkt's
|
|---|
| 64 | own fields in the `shaer:` namespace.
|
|---|
| 65 |
|
|---|
| 66 | ### It is not what we federate, and that is deliberate
|
|---|
| 67 |
|
|---|
| 68 | `buildNote()` produces a *projection* for the fediverse, and that projection is
|
|---|
| 69 | lossy by design:
|
|---|
| 70 |
|
|---|
| 71 | - the title is baked into the content as a bold first line, because Mastodon
|
|---|
| 72 | ignores a Note's `name`;
|
|---|
| 73 | - a paid post federates a **teaser only**, never the body;
|
|---|
| 74 | - `<img>` is stripped from the content and moved into `attachment`, because
|
|---|
| 75 | Mastodon drops inline images;
|
|---|
| 76 | - when a post carries an external embed the image attachments are suppressed, so
|
|---|
| 77 | the link card wins.
|
|---|
| 78 |
|
|---|
| 79 | Exporting that projection would mean archiving a paid post as its teaser. So the
|
|---|
| 80 | archive carries the authoring truth instead, and the differences are:
|
|---|
| 81 |
|
|---|
| 82 | | | federated (`buildNote`) | archive |
|
|---|
| 83 | |---|---|---|
|
|---|
| 84 | | type | always `Note` | `Article` when the post has a title, else `Note` |
|
|---|
| 85 | | title | inside `content` | `name` |
|
|---|
| 86 | | paid post body | teaser | full content |
|
|---|
| 87 | | inline images | stripped from `content` | left in `content`, and also listed in `attachment` |
|
|---|
| 88 |
|
|---|
| 89 | ### Fields
|
|---|
| 90 |
|
|---|
| 91 | | AS2 / `shaer:` | source | notes |
|
|---|
| 92 | |---|---|---|
|
|---|
| 93 | | `id` | `<origin>/ap/notes/<posts.id>` | the federation identity; see import rules |
|
|---|
| 94 | | `type` | `posts.title` present? | `Article` / `Note` |
|
|---|
| 95 | | `attributedTo` | site actor | |
|
|---|
| 96 | | `name` | `posts.title` | plain text, not HTML |
|
|---|
| 97 | | `content` | `posts.content` | authored HTML, shorthand intact |
|
|---|
| 98 | | `contentMap` | `posts.language` | omitted when the post has no language |
|
|---|
| 99 | | `summary` | `posts.content_warning` | AS2 summary is the content warning |
|
|---|
| 100 | | `sensitive` | `posts.nsfw` | |
|
|---|
| 101 | | `published` | `posts.published_at` ?? `created_at` | ISO 8601, UTC |
|
|---|
| 102 | | `updated` | `posts.updated_at` | omitted when equal to `published` |
|
|---|
| 103 | | `url` | `<origin>/<slug>` | the human permalink |
|
|---|
| 104 | | `attachment` | cover, inline `<img>`, `c2s_attachments` **and their `poster`**, hosted audio tracks | see [Media](#media) |
|
|---|
| 105 | | `tag` | `posts.tags`, mentions, custom emoji | `Hashtag`, `Mention`, `toot:Emoji` |
|
|---|
| 106 | | `oneOf` / `anyOf` / `endTime` | `posts.poll_json` | a poll exports as a `Question` |
|
|---|
| 107 | | `quoteUrl`, `shaer:quoteActor` | `posts.quote_uri`, `quote_actor` | FEP-044f |
|
|---|
| 108 | | `shaer:slug` | `posts.slug` | |
|
|---|
| 109 | | `shaer:status` | `posts.status` | `draft` posts are yours too and are exported |
|
|---|
| 110 | | `shaer:excerpt` | `posts.excerpt` | |
|
|---|
| 111 | | `shaer:type` | `posts.type` | |
|
|---|
| 112 | | `shaer:pinned`, `shaer:noindex`, `shaer:fanOnly` | idem | |
|
|---|
| 113 | | `shaer:paid`, `shaer:paidMinCents` | idem | **see the warning below** |
|
|---|
| 114 | | `shaer:apVisibility` | `posts.ap_visibility` | |
|
|---|
| 115 | | `shaer:publishAt` | `posts.publish_at` | a scheduled post keeps its schedule |
|
|---|
| 116 | | `shaer:coverAlt` | `posts.cover_alt` | |
|
|---|
| 117 | | `shaer:viewCount` | `posts.view_count` | optional; a restore without it loses history |
|
|---|
| 118 | | `shaer:audio` | `audio_tracks` rows referenced from the content | title, artist, duration, credit, license, external links, `shaer:ref` (the `[[track:id]]` it belongs to) and `shaer:media` (which attachment holds its file) |
|
|---|
| 119 |
|
|---|
| 120 | **Deliberately not exported:** `yjs_binary` (collaborative editor state — large,
|
|---|
| 121 | regenerable, and meaningless elsewhere), `content_rendered` (derived from
|
|---|
| 122 | `content`; exporting it would create a second truth that can drift),
|
|---|
| 123 | `origin_server`, and every id that belongs to a foreign server.
|
|---|
| 124 |
|
|---|
| 125 | > **Warning — a paid post exports in the clear.** The archive contains the full
|
|---|
| 126 | > body of paywalled posts, because an archive that silently drops your own
|
|---|
| 127 | > content is worse than useless. An export of a site with paid posts must be
|
|---|
| 128 | > handled like the content itself.
|
|---|
| 129 |
|
|---|
| 130 | ### Klonkt shorthand inside `content`
|
|---|
| 131 |
|
|---|
| 132 | `content` may contain Klonkt shorthand that other software will not understand:
|
|---|
| 133 |
|
|---|
| 134 | ```
|
|---|
| 135 | [[track:<id>]] a hosted audio track → see shaer:audio
|
|---|
| 136 | [[album:<id>]] an album of tracks
|
|---|
| 137 | [[playlist:<id>]] a playlist
|
|---|
| 138 | [[embed:<url>]] an external player (Spotify, YouTube, SoundCloud, …)
|
|---|
| 139 | ```
|
|---|
| 140 |
|
|---|
| 141 | They are left in place, because resolving them at export would destroy the
|
|---|
| 142 | authored source. A foreign consumer should either strip them or resolve them
|
|---|
| 143 | using `shaer:audio`. `readable/<slug>.md` has them rendered.
|
|---|
| 144 |
|
|---|
| 145 | ## Media
|
|---|
| 146 |
|
|---|
| 147 | Media are content-addressed by the SHA-256 of the bytes, so two exports of the
|
|---|
| 148 | same content produce the same filenames and duplicates collapse on their own.
|
|---|
| 149 |
|
|---|
| 150 | An attachment has three possible states, and the format must be able to say
|
|---|
| 151 | which one applies. Two are not enough: at Boiert we will know that a picture
|
|---|
| 152 | existed and where it lived, without having the bytes. An empty attachment list
|
|---|
| 153 | would be a lie, and a reference to a missing file would be a broken archive.
|
|---|
| 154 |
|
|---|
| 155 | ```json
|
|---|
| 156 | {
|
|---|
| 157 | "type": "Image",
|
|---|
| 158 | "mediaType": "image/jpeg",
|
|---|
| 159 | "name": "alt text",
|
|---|
| 160 | "url": "media/9f86d0….jpg",
|
|---|
| 161 | "shaer:availability": "included",
|
|---|
| 162 | "shaer:originalUrl": "https://boiert.eu/media/2026/cover.jpg",
|
|---|
| 163 | "shaer:sha256": "9f86d0…"
|
|---|
| 164 | }
|
|---|
| 165 | ```
|
|---|
| 166 |
|
|---|
| 167 | | `shaer:availability` | meaning | `url` |
|
|---|
| 168 | |---|---|---|
|
|---|
| 169 | | `included` | the bytes are in `media/` | container-relative path |
|
|---|
| 170 | | `missing` | it existed, we know where, we do not have it | the original absolute URL |
|
|---|
| 171 |
|
|---|
| 172 | `url` for an included attachment is a **container-relative path**, not a URL. An
|
|---|
| 173 | importer must rewrite it. This is the one place where the archive deviates from
|
|---|
| 174 | strict AS2, and it is the price of carrying bytes at all.
|
|---|
| 175 |
|
|---|
| 176 | An importer must **count and report** every `missing` attachment. It must never
|
|---|
| 177 | silently drop one: a post whose picture has vanished should say so.
|
|---|
| 178 |
|
|---|
| 179 | ## replies/<post-id>.json
|
|---|
| 180 |
|
|---|
| 181 | An AS2 `OrderedCollection` of the replies received under that post, as received.
|
|---|
| 182 |
|
|---|
| 183 | ```json
|
|---|
| 184 | {
|
|---|
| 185 | "type": "OrderedCollection",
|
|---|
| 186 | "shaer:archive": true,
|
|---|
| 187 | "shaer:inReplyTo": "https://boiert.eu/ap/notes/abc123",
|
|---|
| 188 | "orderedItems": [ { "id": "https://mstdn.social/users/x/statuses/1", "…": "…" } ]
|
|---|
| 189 | }
|
|---|
| 190 | ```
|
|---|
| 191 |
|
|---|
| 192 | This is **other people's content**. Three rules follow, and none is optional:
|
|---|
| 193 |
|
|---|
| 194 | 1. It is never re-delivered, produces no notifications and no push. It is
|
|---|
| 195 | history, not traffic.
|
|---|
| 196 | 2. Their ids belong to their servers. We carry them as they are and never claim
|
|---|
| 197 | them.
|
|---|
| 198 | 3. Because of this section an export is not purely "your data". That is exactly
|
|---|
| 199 | why this format and the GDPR export (`shaer-0j2`) have different scopes and
|
|---|
| 200 | must not silently become the same file.
|
|---|
| 201 |
|
|---|
| 202 | `shaer:archive: true` is what an importer keys on. An importer that does not
|
|---|
| 203 | understand this section must skip it, never read it as own content.
|
|---|
| 204 |
|
|---|
| 205 | ## readable/<slug>.md
|
|---|
| 206 |
|
|---|
| 207 | Markdown with YAML front matter, rendered from the canonical file: title, date,
|
|---|
| 208 | content with the shorthand resolved, images as links into `media/`.
|
|---|
| 209 |
|
|---|
| 210 | It exists so the archive survives Klonkt itself. It is **derived** and travels
|
|---|
| 211 | in one direction only — the importer never reads it. This is not tidiness: an
|
|---|
| 212 | archive whose Markdown and JSON are both read is an archive with two sources of
|
|---|
| 213 | truth, and they will drift.
|
|---|
| 214 |
|
|---|
| 215 | ## Versioning
|
|---|
| 216 |
|
|---|
| 217 | `formatVersion` is an integer in `manifest.json`.
|
|---|
| 218 |
|
|---|
| 219 | - Adding a property does **not** bump it. Readers must ignore properties they do
|
|---|
| 220 | not know.
|
|---|
| 221 | - Changing or removing the meaning of a property bumps it.
|
|---|
| 222 | - An importer that meets a `formatVersion` **higher than it knows must refuse the
|
|---|
| 223 | whole archive** and say so. It must never read it partially: a half-understood
|
|---|
| 224 | restore is worse than no restore, because it looks like it worked.
|
|---|
| 225 |
|
|---|
| 226 | ## Import rules
|
|---|
| 227 |
|
|---|
| 228 | **Identity.** Compare `manifest.origin` with the importing site's own origin.
|
|---|
| 229 |
|
|---|
| 230 | - Equal → **keep the ActivityPub ids**. This is what makes a restore a restore:
|
|---|
| 231 | the boosts, likes and replies that already point at those ids find their post
|
|---|
| 232 | again.
|
|---|
| 233 | - Different → **mint new ids**, and keep the original `url` as a historical
|
|---|
| 234 | reference. Keeping foreign ids would mean publishing objects under an id you
|
|---|
| 235 | do not control; remote servers resolve such an id at its original host, and it
|
|---|
| 236 | is a forgery surface besides.
|
|---|
| 237 |
|
|---|
| 238 | The importer determines this itself. It is not a question for the user, because
|
|---|
| 239 | a wrong answer cannot be taken back.
|
|---|
| 240 |
|
|---|
| 241 | **Consequences of keeping ids.** The post id is part of the identity, so the
|
|---|
| 242 | importer must be able to *set* the primary key rather than let it be generated.
|
|---|
| 243 | That is a different write path from creating a post, and it must not be reachable
|
|---|
| 244 | outside the import.
|
|---|
| 245 |
|
|---|
| 246 | **No `Update` broadcast.** If the restored content differs from what other
|
|---|
| 247 | servers cached long ago, their copy stays stale. Sending `Update` activities
|
|---|
| 248 | would fix that, but it is a broadcast to the whole fediverse and must never be a
|
|---|
| 249 | side effect of an import. Separate, deliberate action.
|
|---|
| 250 |
|
|---|
| 251 | **Dry run.** The importer offers a mode that reports what would happen —
|
|---|
| 252 | including the missing-media count — before anything is written.
|
|---|
| 253 |
|
|---|
| 254 | **Idempotent.** Importing the same archive twice produces no duplicates. On a
|
|---|
| 255 | collision with an existing id the importer follows one documented rule (skip,
|
|---|
| 256 | overwrite, or refuse and report); it never guesses per post.
|
|---|
| 257 |
|
|---|
| 258 | ## Reproducibility
|
|---|
| 259 |
|
|---|
| 260 | Two exports of unchanged content must be **byte-identical except for
|
|---|
| 261 | `manifest.exportedAt`**, which is by definition the moment of export. Everything
|
|---|
| 262 | else — every post file, every media file, and the `files` checksum map — must
|
|---|
| 263 | match. That is what makes an archive verifiable and a diff meaningful: if the
|
|---|
| 264 | checksum map is equal, nothing changed.
|
|---|
| 265 |
|
|---|
| 266 | - JSON: keys sorted, two-space indent, LF, trailing newline.
|
|---|
| 267 | - Posts ordered by `published`, then by `id` for ties.
|
|---|
| 268 | - No timestamps inside post files; `exportedAt` lives only in the manifest.
|
|---|
| 269 | - Zip entries in sorted path order, with a fixed modification time.
|
|---|
| 270 | - Media names derive from the bytes, so they never depend on export order.
|
|---|
| 271 |
|
|---|
| 272 | ## Worked example: the hardest post
|
|---|
| 273 |
|
|---|
| 274 | The format is tested against the most demanding post Klonkt can produce, not the
|
|---|
| 275 | easiest: a **poll** with **attachments**, a **quote card**, **custom emoji**, a
|
|---|
| 276 | **content warning**, and **paid** access.
|
|---|
| 277 |
|
|---|
| 278 | - poll → `type: "Question"` with `oneOf`/`anyOf` and `endTime`; the votes are
|
|---|
| 279 | federation state, not content, and are not exported;
|
|---|
| 280 | - attachments → `attachment[]`, each with its own availability;
|
|---|
| 281 | - quote → `quoteUrl` + `shaer:quoteActor` (FEP-044f);
|
|---|
| 282 | - custom emoji → `tag[]` entries of `toot:Emoji`, whose icons are media like any
|
|---|
| 283 | other and are content-addressed the same way;
|
|---|
| 284 | - content warning → `summary` + `sensitive: true`;
|
|---|
| 285 | - paid → full `content` plus `shaer:paid`, with the warning above.
|
|---|
| 286 |
|
|---|
| 287 | A post that survives this round trip unchanged is the acceptance test for
|
|---|
| 288 | slices 2 and 3.
|
|---|