# @estiva-app/interop

Every entry answers the **manifest** question explicitly, including when the
answer is nothing (ADR 0002 §4b). A change to what a manifest may declare, or to
how a declaration is read, is a MAJOR — in `0.x`, a MINOR — even when no
TypeScript signature moved. A consumer upgrading must be able to tell whether
manifests already published still mean what they meant.

## 0.65.1 — 2026-10-23

**A Folder's listing reads a parent in another Folder as a link does**
(PEEKM-2, SPEC §6.5).

- `resolveFolderContents`: where a listed file's parent chain leaves the
  Folder, the rest is walked as `archivedWith` walks it: each ancestor with its
  root, so an archive signed in another Folder's `h` is ignored (SPEC §6.2), at
  any depth, the closest archived file named. Before, the parent outside was
  read by address only, one level.
- Such a parent's own app is read when the child's app does not draw its kind,
  once per kind and author through `cache`. Two children of one outside parent,
  drawn by different apps, each read it by their own app's rule.
- A level whose app cannot be found ends the walk, and its own archive no
  longer counts by SPEC's default rule (SPEC §6.5), in a listing and through
  a link alike. A bare file's built-in app always counts.
- Manifest: nothing new may be declared, and no declaration is read
  differently.

## 0.65.0 — 2026-10-23

**A file reached by address is archived with the closest archived file above
it, at any depth** (PEEKM-2, SPEC §6.5; record
*an-archived-file-is-read-only-in-every-app*).

- `archivedWith` now walks the whole parent chain, one read per level, and
  `archived.with` names the closest archived ancestor: a page under a page
  under an archived page is archived with it, and with two archived above it
  the nearer one is named. Before, only the direct parent was read.
- Each ancestor is read with its root, so an archive signed in another Folder's
  `h` is ignored at every level (SPEC §6.2), and its own parent is read by its
  own kind (an issue's moved `project`). An ancestor this reader cannot see
  ends the walk, as not archived.
- `archivedAncestorOf` is exported: the same rule for an app that folds its own
  files (Ship).
- A Folder's archive is not part of this rule yet (parked with
  Folders-as-labels, Miky, 2026-10-23).
- Manifest: nothing new may be declared, and no declaration is read
  differently.

## 0.64.0 — 2026-10-21

**Every resolved file says when it last changed** (5ebf2756).

- `ForeignObject.changedAt`, in seconds: the newest of the root's `created_at`
  and each folded field's winning change. Set on a file read by address, on
  each child of a `list` slot and on a Folder's files; a regular event's is its
  own `created_at`; absent on an unreachable stub. For a card that lists a
  project's most recently changed issues rather than all of them. Comments are
  not changes to the file and do not move it.
- Manifest: nothing new may be declared, and no declaration is read
  differently.

## 0.63.0 — 2026-10-21

**An issue read under its project carries its actions** (LTX-2; record
*a-document-app-writes-what-a-files-app-shares*, SPEC §7.3).

- A child reached through a `list` slot that has an address of its own — a
  Ship issue under its project — now carries the actions its own app declares
  for its kind, each with what its field holds, as it does when resolved on its
  own (borrowed creations, `declaredBy`, still come only with a file resolved
  on its own). It carried none, so a consumer asking "does this file's app
  share its text?" got no for every issue in a project's list. A child with no
  address (a `kind:9` message), and an event read by its id through
  `resolveForeignEvent`, still carry none. Nor does a child listed by an app
  that is not its kind's owner for its author (resolved per author, through
  the cache): an outside app listing Ship's issues cannot offer its own text
  actions on them.
- Manifest: nothing new may be declared. How a declaration is read changes: an
  action now reaches an addressable child it `appliesTo`, which it never did
  before. A consumer that drew every child's actions now draws them for issues
  in a project's list too.

## 0.62.0 — 2026-10-20

**A file's address on any Estiva app's host is that file**
(LTX-6; record *a-files-path-is-the-same-on-every-apps-host*, SPEC §7.7).

- `matchObjectUrl` matches a link's path against every shape passed, whatever
  origin the shape is written on, when the link's origin (scheme, host and
  port) is one some shape is written on. `leaf.estiva.app/issue/<slug>-<d>`
  now names Ship's issue. A link on no declared origin still names nothing. A
  fragment route (`/#/…`), and a shape that names no kind, match only on their
  own origin.
- A match on the link's own origin wins, as before. On either side, every
  shape reading the same identity adds its kind, whatever order the manifests
  came in (on the own origin the first shape listed used to win a tie): **new
  `MatchedObjectUrl.kinds`**, present (and `kind` absent) when they name more
  than one. A reader resolves under all of them and keeps the one-author rule,
  so a second claim to a word turns the link plain. A consumer that ignores
  `kinds` asks by `#d` alone, which the one-author rule still guards.
- `urlPatternsOf` drops a `urls` shape whose origin is not one of the
  manifest's `web` templates, and a manifest with no `web` declares none. That
  is consistency, not authentication.

Manifests: how a published `urls` declaration is read changed as above. Every
production manifest's shapes sit on its own `web` origin (checked 2026-10-20),
so none loses a shape.

## 0.61.0 — 2026-10-19

**A topic has no text, and a Ship project has a Folder to write into**
(LTX-4; record *a-document-app-writes-what-a-files-app-shares*).

- The bare file's built-in projection (SPEC §6.7) drops its `body` slot and its
  `describe` action. A topic resolves with no body, whatever its `content` or
  change stream holds, and `buildActionEvent` refuses `describe` on it. None of
  the 32 topics on the relay ever had a text (2026-10-19).
- `ForeignObject.folder` reads `h`, then `buzz-channel` (as `folderOf` does,
  without its `d` fallback for a Folder's own `39000`), so a
  Ship project placed the way Ship places it has the Folder a change or a
  comment goes into. It read `h` only, which left about half of Ship's
  projects with nowhere to write from another app.
- **New: `ForeignObject.scopedTo`**, the root's `h` alone: the channel that
  gates who can read the root itself, absent for a globally stored record.
  `MovableFile` now carries `scopedTo` instead of `folder`, and a move into a
  private Folder reads it, so a `buzz-channel` project is still refused there.
  A consumer building a `MovableFile` by hand renames the field; one passing a
  `ForeignObject` changes nothing. A file with no `scopedTo` is refused a
  private target, as one with no `folder` was.

Manifests: the built-in bare-file manifest changed as above (SPEC §6.7). What a
published manifest may declare, and how a declaration is read, did not.

## 0.60.0 — 2026-10-19

**A reply to a comment on part of a file copies that comment's place**
(COMM-5). `replyTo.place` takes the comment's `block` and `quote` tags, and
the comment action writes them verbatim after the reply's `p`, before its `h`,
as `@estiva-app/conversation` 0.14's `buildReply` does; any other tag in it is
dropped. Without `place` the reply is written as before. So Peek's reply to a
Leaf page's comment keeps its thread's place when the comment is deleted
(record *a-reply-on-a-page-keeps-its-threads-place*).

Manifests: none. Nothing a manifest declares, or how a declaration is read,
changed; the comment action is NIP-22's, not the manifest's. SPEC §13.6 changed:
a reply copies its top-level comment's `block` and `quote` instead of carrying
none.

## 0.59.0 — 2026-10-16

**`[` offers Leaf pages, and every row carries its file's summary** (SUM-7).

- `REFERENCE_FILE_TYPES` gains `30842` as `'document'`, so `fileCandidates`
  and `searchFileReferences` offer a Leaf page in every app, beside topics,
  projects and issues (Miky, 2026-10-16). The relay finds it by title already:
  it indexes every object kind's `title` (checked on production, 2026-10-16).
- Each file row carries `summary`, its `subtitle` slot (SPEC §6.12), one line
  of plain text; none when the file has no summary or it was cleared.

Manifests: none. Nothing a manifest declares, or how a declaration is read,
changed. Needs @estiva-app/conversation 0.12.0 for the types.

## 0.58.0 — 2026-10-15

**Manifests: no manifest is read for a Folder (`kind:39000`) or a chat message
(`kind:9`) any more; both are built in** (SPEC §3.2, §7.8; records
*folders-belong-to-the-protocol* and *a-chat-message-belongs-to-the-protocol*).
How published declarations are read changes for two kinds:

- **A Folder resolves through a built-in manifest, answered before NIP-89**, as
  the bare file does: title the `name`, subtitle the `about` (cut at 120, as
  Peek's claim cut it), noun `folder`, opened by the `naddr` template of the
  app declaring the `conversation` aspect and named after it. A `k 39000` claim
  — Peek's, until it republishes — is not asked about, and its projection,
  `list` slot and actions are ignored. Its address is `FOLDER_ACTIONS_ADDRESS`.
- **A Folder's card lists its files, a chip nothing.** Peek's claim listed
  fifty `kind:9` messages, which every Folder chip paid for; a Folder's
  contents are what its `30890` names. Pass `{ folderFiles: true }` to
  `resolveForeignObject` or `resolveForeignObjects` when you draw a Folder as
  a card or row: its `children` are the files its relay-signed `30890` lists
  that this reader can open, with nothing counting the rest. Without it (a
  chip) nothing more is read and `children` is undefined; it is undefined too
  for a Folder with no `30890` (a DM) and when the listing cannot be read, so
  no card says "nothing in it" wrongly. The resolve waits for the files, so
  read a card's in a call of its own. `resolveFolderContents` takes
  `listedOnly` for the same rule. `listsChildren` is false on a Folder: there is no declared
  list to expand. Every card that drew Peek's messages under a Folder needs
  the option, or it draws no list. It costs a Folder page's read (Estiva HQ:
  7.6 MB, ~2 s warm), so read it on open and focus, not on a refresh tick.
- **`start-a-conversation` is a Folder built-in** beside `create-file`, with
  `declaredBy` naming `spec:3.2`: a `kind:9` carrying only `h`, its body in
  `content`. Offer it only on a Folder with a relay-signed `30890`, and only if
  you may sign `kind:9`. A manifest's own creation of a `kind:9` on a Folder
  (Peek's) is not offered beside it. Act with it as with `create-file`,
  `resolveActingManifest(folder, FOLDER_ACTIONS_ADDRESS)`: the Folder's owner
  is the specification's and declares no actions, so a consumer that started
  a conversation as the owner's action (no `declaredBy`, or Peek's address)
  now finds none.
- **A chat message resolves through the built-in manifest**, as a comment does:
  the `message` widget, the author as title, the text as body, noun `message`,
  opened by the conversation app's `nevent` template. A `k 9` claim is not read.
- `resolveActingManifest(folder, FOLDER_ACTIONS_ADDRESS)` answers the built-in
  creator before the owner, whose address is the same section.
- A refused handler sweep costs a Folder its link, never its name.
- `KIND_MESSAGE` (9) is exported.

Upgrade every consumer before Peek's manifest drops its `k 39000`, `k 9` and
their projections: a runtime before this one finds a Folder and a message by
those claims, and draws nothing once they go.

## 0.57.0 — 2026-10-15

**Manifests: every file's `subtitle` is its summary, whatever its manifest
declares** (SPEC §6.12, §7.2; Miky 2026-10-15). How a published declaration is
read changes for one slot:

- **On every addressable kind but NIP-29's `39000`–`39009`, `subtitle` is
  `{ tag: 'summary', fold: 'summary' }`**: the root `summary` tag, overridden by
  a `kind:1851` with `field` `summary`, last write wins. A manifest need not
  declare it; one declaring another source for it on a file kind is ignored.
  No published Estiva manifest declares one on a file kind, so Ship, Leaf and
  Peek lose nothing; an outside app that aimed it at its description now shows
  its summary there, or nothing.
- **A Folder's `subtitle` is its `about`** (`{ tag: 'about' }`, §3.2), so a
  `summary` change aimed at a `39000` is never drawn. Peek's declaration is the
  same source and keeps its `truncate`.
- **`subtitle` is one line of plain text**: line breaks fold to spaces, and a
  value blank once trimmed resolves to no slot, so a summary cleared with `''`
  or `'  '` draws nothing and its seed does not show through. A summary is cut
  at 280 characters (a same-source declaration may cut it shorter): the relay
  does not hold a writer to about 240, and a consumer still clamps it to its
  room.
- **A `summary` change nobody can check is not read**: one aimed at a root
  that names no Folder, or folded without the root. A summary is new, so §6.2's
  two exceptions for older fields do not apply to it.
- **The bare file declares `summarize`**: `emits { kind: 1851, field:
  'summary' }`, `input { type: 'string' }`, no `format`. A consumer finds it by
  `field`, not by id.
- **A Folder's built-in `create-file` takes an optional `summary`**, written to
  the root tag of that name.
- New export: `SUMMARY_FIELD`.

## 0.56.0 — 2026-10-14

**Manifests: a `children` list may declare `anyParent: true`** (SPEC §7.2, for
the document, `kind:30842`; Miky 2026-10-14). The child names its own parent with
its `via` tag, of any kind, as a bare file does (§6.7 Nesting), so an owned kind
can sit under a topic, a project or itself. With it, `movedBy` names the field
without limiting the parent to the declaring kind. Published manifests mean
what they meant: without the key the declared kind is still the only parent.
The built-in bare-file projection now declares it, which changes no answer.

## 0.55.0 — 2026-10-14

**Manifests: the bare file's text is a change anyone in its Folder may make,
and every fold ignores a change signed in another Folder** (Leaf 1, SPEC §6.2
and §6.7). Published manifests mean what they meant; what changes is the
built-in bare-file manifest and how every declaration's changes are read.

- **The bare file's `body` is the `description` fold, seeded from `content`**
  — `{ fold: 'description', field: 'content' }`, Ship's body slot without its
  `tag`. A new **`describe`** action sets it: `emits { kind: 1851, field:
  'description' }`, `input { type: 'string', format: 'estiva-blocks-1' }`, the
  shape of Ship's `describe-issue`. Pass `contentFormat: 'estiva-blocks-1'` to
  `buildActionEvent` and the change carries the tag before `ts`. The author
  saves this way too: a republished root loses to an older change. A consumer
  that draws a `format` action on the body slot as an editor (Peek's
  `ForeignObjectWidget`) now offers one on every bare file it draws whole, so
  **Peek must keep it read-only when it upgrades** (Miky, 2026-10-13).
- **A change counts only in its target's Folder** (SPEC §6.2, every kind).
  Its first `h` with a value — the one the relay checks membership against —
  must be a Folder the target's newest root carries: `h`, or `buzz-channel`,
  or for a `kind:39000` the Folder itself. Any other, or none, is ignored by
  `resolveForeignObject(s)`, `projectEvents`, the Folder listing and its list
  of Folders, a card's children and their counts, the parent fold and every
  archive. A root that names no Folder takes every change, as before. The relay checks only the change's
  own `h`, so before this a member of any Folder could rename, move, archive or
  rewrite a file in a Folder they cannot read. Where only an address is held
  (an archived parent read by address), nothing is compared, as before.
  Compared on production 2026-10-14 against 0.54.0 across 48 Folders and 62
  listed files: no listing, title, status, parent or archive changed.
- **A FOL-20 placement from another Folder no longer lists a record.** A
  placement is a change, so it follows the rule: it counts when the record
  names that Folder (Ship's projects carry their team Folder in
  `buzz-channel`), and is ignored otherwise.

## 0.54.0 — 2026-10-12

**Manifests: a Folder offers actions no manifest declares, and the bare file's
`delete` is `listed`** (INT-12, SPEC §3.2). Published manifests mean what they
meant; what changes is what a Folder and a bare file offer beside them.

- **A Folder's built-in `create-file`** puts a bare file in it: `kind:30840`
  with the Folder in `h` (§6.7, never `buzz-channel`), then the `kind:1852`
  `add` when the Folder has state, because it is `listed`. It is on every
  `kind:39000` with no `kind:31990` declaring it, carries `declaredBy.address`
  `FOLDER_ACTIONS_ADDRESS` (`spec:3.2`, exported), and
  `resolveActingManifest` hands its manifest back by that address. A refused
  manifest sweep does not cost it. A Folder's actions are now the owner's, then
  this one, then other apps' creations; an app declaring a `30840` on a Folder
  is dropped in its favour rather than drawn as a second row. A consumer offers
  it only if it may sign `30840` and `1852` (§3.3) — **Ship and Leaf, which may
  not sign `30840`, must filter `create-file` out** when they upgrade.
- **The bare file's `delete` is `listed`**, so a consumer following it removes
  the file from every Folder whose state lists it, as Ship's `delete-project`
  does. `buildActionEvent` (one event) now refuses it; build it with
  `buildActionEvents` and `listedIn`, as Peek's delete already does.

## 0.53.0 — 2026-10-09

**Manifests: nothing changes.** No declaration is read differently.

- A comment `buildActionEvent(s)` writes for a manifest's `comment` action now
  names, in `p`, each person its body mentions by `nostr:npub…` (SPEC §13.1),
  once each and after the object's or parent's `p` — what `buildComment`'s
  callers already add with `mentionTagsFor`. Before, only the object's author
  was tagged, so a mention written through the runtime (the agent's) drew a
  chip and notified nobody (§11.8 reads `#p`). A change's value earns none.
  At most `MAX_MENTIONED_PEOPLE` `p` in all, the author's included.
- **A caller that appended `mentionTagsFor(body)` itself must stop** when it
  upgrades, or every mention is tagged twice: Peek's `commentAsViewer`
  transform does today.

## 0.52.0 — 2026-10-07

**Manifest: a `urls` pattern may declare a `<block>` in its fragment** (COM-2,
SPEC §7.7). Manifests already published mean what they meant: a pattern with
no `<block>` ignores the fragment exactly as before, and a consumer on an older
version reads a pattern with one as the same shape without the block.

- `matchObjectUrl` returns `block` when the pattern declares
  `#…<block>…` and the URL fills it; the block is optional, so the pattern
  still claims the URL without it.
- New: `blockUrlOf(patterns, { kind, d, title, block })` builds the link that
  opens one block, from the same shape — not from `web`, whose NIP-89 template
  has only `<bech32>`.

## 0.51.0 — 2026-10-06

**Manifests: nothing changes.** No declaration is read differently.

- `conversationsOf` takes `{ replies: true }` as a fourth argument: each file
  also gets a filter by `#A`, and the replies under the comments the read found
  come back beside them (`root !== id`), so a list can judge a thread reply by
  its thread (SPEC §11.3, CON-34). A NIP-22 reply names the file only in its
  uppercase `A`, so the `#a` read never brought one, and a file whose only new
  message was a thread reply never lit its row. The root decides (C11): a root
  past the `#a` limit is asked by id, in one more read only when one is
  missing, and a reply under a thread rooted elsewhere is left out. Without the
  option nothing changes, and the count stays comments only.

## 0.50.0 — 2026-10-05

**Manifests: one declaration is read in a new way.** On an issue (`30851`),
a `meta` slot whose field is `ref` is now read as the issue's ref (SPEC §6.1),
which Ship's manifest already declares. That lets `[` find an issue by its ref
(PEE-21, CON-33). A manifest without such a slot works as before.

- `searchFileReferences` counts a `kind:1851` hit on `ref` as well as on
  `title`, but a ref change only when it is aimed at an issue (SPEC §5.2). It
  keeps a hit when the current title or current ref holds what was typed, so
  an old ref finds nothing. A ref is matched through conversation's
  `refMatch`: from its start, and only once a digit or hyphen is typed (Miky,
  2026-10-05).
- `fileCandidates` sets an issue's `search` to its current ref, and captions
  it with the ref and then the project: "CON-33 · Conversation standard"
  (Miky, 2026-10-05). Before this, the caption was the project alone.
- Needs conversation ≥ 0.6.1.

## 0.49.0 — 2026-10-05

**Manifests: nothing changes.** This release only adds a planner (SHI-37). It
moves a file out of every Folder that lists it, which Ship, Peek and the agent
each used to put together themselves around `planMoveFile`.

- **Added: `planMoveFromFolders(pubkey, ms, { file, from, to, listings })`.**
  It plans one `kind:1852 add` in the target naming everything each source lists
  beneath the file, so the add lands whole or not at all, then one `remove` per
  source naming what that source lists. When no source lists anything, the file
  is placed instead, and a private target is refused unless the file's `h` is
  that Folder. It returns `{ ok, add?, removes: { folder, event }[], moved }`, or
  `planMoveFile`'s refusals.
- **Added: `FolderRef.addresses`** (`FolderSummary.addresses`). From it the
  planner knows that a source lists the file even when the file's row did not
  resolve.
- **Docs:** read a move's listings with `resolveFolderContents(…, { includeArchived: true })`.
  The defaults hide archived files, so an archived file beneath the moved one
  stayed behind.

## 0.48.0 — 2026-10-05

**Manifests: how `input.type: "pubkey"` is read changes** (SPEC §7.3, SHA-28).
It names a person, so the events built from it now carry `["p", <key>]`, which
is what makes that person a member of the file (§11.8). Ship's published `assign`
and `set-lead` declare it already: built here, they now carry the `p` that
Ship's own writer always added. No signature moved; needs protocol 0.24.0 and
conversation 0.5.0, unchanged.

- **Changed: a change whose input is `pubkey`** adds `["p", value]` after `ts`.
  `''` (clearing it) adds none.
- **Changed: a creation** adds one `["p", key]` per distinct non-empty property
  of `type: "pubkey"`, beside the property's own tag.
- **New refusal:** a `pubkey` value that is neither `''` nor 64 lowercase hex,
  such as an npub or upper-case hex, returns a string naming the action instead
  of being written without the `p`.

## 0.47.0 — 2026-10-03

**Manifests: nothing changes.** Every published manifest means what it meant.
**Folder writes and reads change to SPEC §3.3 / §3.2** (MAN-17), and three
planner signatures moved. Needs protocol 0.24.0 and conversation 0.5.0,
unchanged.

- **New: `planDeleteFolder(pubkey, ms, { folder })`** = `[kind:9008]`. The
  relay refuses it while the Folder holds a file, naming the count; show that.
- **Changed: `planMoveFile` moves a subtree, and refuses a private move.** It
  takes `{ file, from, to, listing }` instead of `{ address, … }`: `file` is a
  `MovableFile` (any `ForeignObject`), `listing` the source's
  `FolderContents.files` — required, `null` only for a file nothing can sit
  beneath. The plan is one `kind:1852 add` in the target naming
  the file and every file listed beneath it, then one `remove` naming the same
  set in the source (Miky, 2026-10-03), and returns `moved`. New refusal
  `target-is-private`: the target's `kind:39000` is private and a moved file's
  `h` is another channel or absent. New export `listedBeneath(listing, file)`.
- **Changed: no `kind:1852` carries a `name`.** `planCreateFolder` sends a bare
  `add`; `planRenameFolder` is the `kind:9002` alone and no longer takes
  `hasState`.
- **Changed: `planArchiveFolder` always publishes into the Folder's own `h`**;
  `into` is gone.
- **Changed: a Folder's title is its `kind:39000` name** in `listFolders` and
  `resolveFolderContents`; a `name` on the state is no longer read.
- **Changed: a Folder's own archive counts only from an owner or admin** on its
  relay-signed `kind:39001`, published in its own `h`. `FolderSummary` gains
  `admins` (offer Archive only to them) and `private`. `listFolders` and
  `resolveFolderContents` each ask for the `kind:39001` in the request they
  already make.
- The bare file's action descriptions say "Folder", not "team".

## 0.46.0 — 2026-10-03

**Manifests: nothing changes.** Every published manifest means what it meant,
and every existing export behaves as before. New exports for `[` (PEE-21).
Needs protocol 0.24.0, unchanged, and **conversation 0.5.0**.

- **`searchFileReferences(text, query, options?)`**: the files whose
  *current* title holds `text`, as conversation's search tier. Asks the relay
  for roots (30850, 30851, 30840) and title changes (1851) as two filters of
  32 each, with buzz's `search_mode: "prefix"` so a half-typed word matches;
  each hit is only an address, read back through `resolveForeignRoots` and
  kept only when what it is called now matches. A relay that indexes none of
  these kinds answers nothing, and so does this. A failed read rejects.
- **`resolveForeignRoots(references, query, cache?)`**: objects from their
  roots and changes alone — `projectEvents` over the same root and change
  filters `resolveForeignObjects` sends, without comments, children or
  people. An address whose root the reader is not handed is absent.
- **`fileCandidates(objects, tier, query, options?)`**: resolved files as
  `[` rows — type word, current title, an issue captioned with its project's
  title (read in one batch for the projects not already in the set). Marks a
  file `archived` when it or its project is. Leaves out unreachable and
  untitled objects and kinds `[` does not offer.
- **`REFERENCE_FILE_TYPES`**: 30850 project, 30851 issue, 30840 topic.
- **`isClosedStatus(slot)`**: a `status` slot that is done or dropped, by its
  words — for a consumer holding the object without its manifest.

## 0.45.0 — 2026-10-02

**Manifests: nothing changes.** Every published manifest means what it meant,
and every existing export behaves as before. One new export (PER-21). Needs
protocol 0.24.0 and conversation 0.1.0, unchanged.

- **`projectEvents(events, manifestFor)`**: every object in a set of events
  the caller already holds, projected with no requests. `manifestFor` answers
  the manifest per root author (`kind:pubkey`), as `resolveManifest` does, so
  an object is still drawn by the app its own author recommended. Each root of a kind the
  manifest projects becomes the object `resolveForeignObject` builds for it:
  the same fold, archive, slots, meta, parent and actions. It has no
  `comments`, `children`, `people` or borrowed actions, because each of those
  needs a read. Two versions of one root collapse by NIP-01's rule.
  It is for a reader that wants a whole app's workspace. estiva-agent re-reads
  Ship's on every command, which costs ~100 POSTs through
  `resolveForeignObjects` and a few paged queries this way.

## 0.44.0 — 2026-10-02

**Manifests: one new optional field, `fileNoun`, read only beside `aspect`; and
the bare file reads its opener's `name`** (PRO-24). A manifest already
published means what it meant for every kind it owns. What changes is how the
manifest a bare file *opens in* is read. Needs protocol 0.24.0 and conversation
0.1.0, unchanged.

- **A bare file is named by the app that opens it.** Its `appName` was always
  "File", while its `openUrl` came from the conversation app's manifest. So
  Ship labelled a Peek topic "File", and its hand-off had to name the app
  itself for the top card (ship#226). It now
  takes `appName` from that same manifest ("Peek"), and stays "File" only when
  no app can open it. A comment (`kind:1111`) takes the opener's name the same
  way.
- **`fileNoun`: what a generic app calls a file nobody owns.** Lower case and
  singular, next to `aspect`; Peek's will be "topic". It resolves onto the bare
  file's `ForeignObject.noun`, and is absent when the opener declares none or
  declares something that is not a non-empty string. A comment does not take
  it. This is the opener's word, not the protocol's (Miky, 2026-10-02): another
  company's conversation app may call these threads.
- **A comment's noun is "comment"**, declared on the built-in `kind:1111`
  projection: NIP-22's word and SPEC §6.4's, not any one app's (Miky,
  2026-10-02).
- **A noun is a short word or it is nothing.** Every `noun`, an owner's
  included, and a bare file's `fileNoun` and opener `name` must be at most 32
  letters, digits, spaces, hyphens or apostrophes. Anything else is read as
  "this app does not say". A consumer drops the noun into its own copy, and the
  opener is whichever manifest declares the aspect newest. The name and noun
  are as unverified as the link already was (PRO-25 tracks a trusted opener).
- Nothing else of the opener applies. The bare file's address is still
  `spec:6.7`, and its slots, fold rule and actions are still this runtime's,
  so a `projections["30840"]` in the opener's manifest is ignored, as any
  claimant's is.

## 0.43.0 — 2026-09-30

**Manifests: nothing new to declare, and nothing read differently** (FOL-50). A
manifest already published means what it meant. Needs protocol 0.24.0 and
conversation 0.1.0, unchanged.

- **`resolveParents(references, query, cache?)`: the parent of each file in a
  set, and nothing else.** The roots, grouped by author and kind, and the
  changes only of a kind a list declares `movedBy` for, one filter per file
  with the object reader's limit so an old move is never paged out behind
  newer edits — one POST per ~120 files once the manifests are cached.
  `resolveForeignObjects` answers the same question on the way to a whole
  object and reads every comment and child besides: for the 110 issues Peek's
  Folder dots name on production that was 20 POSTs and 1 MB, against 1 POST and
  ~500 KB here (the roots carry the descriptions, and a relay cannot be asked
  for one field of a change).
- Folded by `parentRefOf`, the rule behind `ForeignObject.parentRef`, and
  answered only for a kind its app draws — checked against
  `resolveForeignObjects` on those 110 issues with no disagreement. A parent's
  address, `null` at the top, no key when nothing can be told (not an address,
  no manifest, a root the reader cannot see).

## 0.42.0 — 2026-09-30

**Manifests: nothing new to declare; a `movedBy` move now writes one more tag
and a card reads it** (FOL-45). A manifest already published means what it
meant. Needs protocol 0.24.0 and conversation 0.1.0, unchanged.

- **A move names its new parent where a relay indexes it** (SPEC §7.2).
  `buildActionEvent` adds `["A", <new parent>]` after `ts` to a change setting
  the field a list declares as `movedBy` — Ship's issue `project`, the bare
  file's `parent` — when the value is an address. A move to the top adds
  nothing, and no other change carries it. Exported as `MOVED_TO_TAG`.
- **A card lists the children moved in.** Until now a `#via` read found only
  the children created under a parent, so an issue moved to another project was
  drawn under neither. The card's own read also asks
  `{ kinds: [changeKind], "#A": [parent] }`; the roots it names are read with
  the children's changes, in the same follow-up, so a card costs no more
  requests. A child found this way is kept only while its fold still names this
  parent: the tag is a hint, a later move away names another parent.
- **A move written before this** carries no `A`, so the child it moved stays
  invisible under its new parent until it is moved again. The Folder listing
  (`resolveFolderContents`) is unchanged, and still sees such a move when both
  are listed.

## 0.41.0 — 2026-09-30

**Manifests: nothing new to declare; how a comment is read changes in two
cases none of production's events is in** (CON-5). The conversation rules move
to `@estiva-app/conversation` 0.1.0, which is now a **peer dependency**
alongside protocol 0.24.0.

- **Removed, moved to `@estiva-app/conversation`:** `commentDecorationsOf`
  (now `decorationsOf`, whose `edit` is the package's `EditFold` and whose
  reactions follow SPEC §6.6 as decided 2026-09-29 — last `e`, empty is `+`,
  one per `(target, pubkey, emoji)` — with the event cut reported as
  `reactionEventsCut`), `editTargetOf`, `REACTION_HORIZON`, and the types
  `CommentDecoration`, `CommentDecorations`, `CommentReaction`,
  `CommentResolution`.
- **A reply is never a root** (§6.4). `comments` and `conversationsOf` no
  longer list an event carrying a reply `e` — a `kind:1111` whose `e` differs
  from its `E`, or a `kind:9` with any `e` — whatever `a` it carries. 0 on
  production (§6.9).
- **A `kind:9` is a comment only when the owner declares `9`** among its
  comment kinds (`emits.kind` or `emits.alsoRead`), which is what this package
  already queried by; the rule now lives in `isCommentOn`'s `declaredKinds`,
  so an app reading without interop applies the same one.
- **`ts` is trusted only when `floor(ts / 1000) == created_at`** in every
  change and comment fold (§6.2, clarified 2026-09-29). This package accepted
  ±1 s; all 3,470 events carrying `ts` on production agree exactly.

## 0.40.0 — 2026-09-30

**Manifests: nothing new to declare, and one declaration is read in one more
place — an `appliesTo` naming a kind another app owns** (MAN-8). A manifest
already published means what it meant to its own objects; an object of a kind
some *other* manifest declares a creation on now offers that creation too.
Needs protocol 0.24.0, unchanged.

- **An object offers the creations other apps declare on its kind.** Ship
  declares `add-project` on a Folder (`appliesTo: "39000"`), and a Folder is
  Peek's, so `resolveForeignObject` answered with Peek's
  `start-a-conversation` alone — measured on production. Now its `actions`
  are the owner's, then every borrowed creation, from
  `resolveForeignObject` and `resolveForeignObjects` alike.
- **Only a creation, of a kind the declaring app projects.** A field change,
  a comment or a deletion on another app's kind is the owner's to declare —
  the owner's `records` fold it — so none is borrowed. One manifest per
  created kind (newest wins, so a stale copy is not a second row), and none
  for a kind the owner already creates there. Not on an unreachable object,
  and not on a card's children.
- **`ResolvedAction.declaredBy`** — `{ address, appName? }` — names the
  manifest a borrowed action came from. Absent on the owner's actions.
- **`resolveActingManifest(pointer, declaredBy, query, cache?)`** is the
  manifest to build with. Without `declaredBy` it is `resolveManifest`; with
  it, that manifest only if resolving the object would have offered its
  action, and holding only those creations — otherwise null. Pass what it
  returns to `buildActionEvents`, which finds the action by id in the manifest
  it is handed; the owner's has no `add-project`.
- **Cost: one more read on a cold resolve**, a sweep of every `kind:31990`
  (the one `resolveAspectApp` makes), sent beside the object's read so it adds
  a request and no wait. Memoised per kind in a `ProjectionCache`, so a warm
  refresh is still one request. `ProjectionCache`'s `@internal` methods now
  carry a list for that entry as well as one manifest. **A refused sweep
  costs the borrowed actions, not the object**, and is not remembered;
  `resolveActingManifest` rejects instead, since a write should not proceed
  without its manifest.

## 0.39.0 — 2026-09-30

**Manifests: one new, optional declaration — a projection's `noun`** (PEE-41).
A manifest already published means what it meant; one without a `noun` resolves
exactly as before. Needs protocol 0.24.0, unchanged.

- **`ForeignObject.noun`** is what the owning app calls one of these, lower case
  and singular: `projections: { "30851": { "noun": "issue", … } }`. Peek's file
  menu said "Delete topic" on a Ship issue, because the only way to know better
  was to check the kind number — the check a manifest exists to replace. Set on
  every object the projection draws, an unreachable one included. Trimmed, and
  left unset when the declaration is not a non-empty string.
- **The bare file declares none.** Its manifest is this package's, and "File"
  is already its `appName`; a consumer says its own word for a file nobody
  owns, which in Peek is "topic".

## 0.38.0 — 2026-09-30

**Manifests: nothing new to declare, and one declaration is read in one more
place — a `list`'s `movedBy`, now on a card's children as well as in the Folder
listing** (MAN-7). A manifest already published means what it meant; a card
drawn from one that declares `movedBy` or `records` changes what it lists.
Needs protocol 0.24.0, unchanged.

- **A card's children are folded.** `resolveForeignObject(s)` built a `list`
  slot's children from their roots alone, so a Ship issue moved to another
  project was still drawn on the old project's card, and every folded slot on
  a child (an issue's status) showed what its root said at creation. Each
  addressable child now gets its own fold, and a child is left out when the
  Folder listing leaves it out: its folded `movedBy` names another parent, or
  none (SPEC §7.2's MUST); or it is hidden by the app's `hiddenWhen`; or it is
  archived. Measured on production: all 36 issues moved away from a project
  were on its card under 0.37.1 and are on none under 0.38.0, and every other
  child is still there.
- **A child moved in is still not on its new parent's card.** Its new parent is
  in a `value`, which no relay indexes; SPEC §7.2 names the Folder listing as
  the read that sees it.
- **One more request, only when there is a fold to read** — the app declares
  `records` and the card lists an addressable child. The children's changes
  name addresses not known until the children are back, so they cannot ride in
  the object's request (SHI-13). A Ship project card with issues is two requests
  a refresh; a Peek Topic card, whose children are `kind:9` messages, stays
  one. `resolveForeignObjects` reads every card's children's changes in one
  more request for the whole set, not one per card.

## 0.37.1 — 2026-09-30

**Manifests: nothing new, and nothing read differently for a file itself**
(FOL-46, the two review findings 0.37.0 merged without). Needs protocol 0.24.0.

- **A parent's or a child's archive is read by the owning app's own `records`
  rule** when that app draws its kind — an issue's project is Ship's, as the
  issue is — and by SPEC's `kind:1851` shape otherwise. 0.37.0 read them by
  SPEC's tags alone, so an app folding other tag names was archived on its own
  page while its children were not archived with it, and `archiveImpact`
  counted its already-archived children as live. The files' own archives were
  already read by their app's rule.
- **`planArchiveFolder({ …, into? })`** publishes the change in the Folder that
  lists this one, so that Folder's readers read it — a private sub-Folder is
  then hidden from its team for every member of the team. Default: the Folder
  itself, as in 0.37.0.

## 0.37.0 — 2026-09-29

**Manifests: nothing new to declare, and one field is now read for every kind
— `archived`** (FOL-46). A manifest already published means what it meant: a
Ship project archived through its `hiddenWhen` is hidden exactly as before. What
moves is that an `archived = 'true'` change now hides *any* file — including one
whose app declares no `records` (a Folder) — and hides what is under it.
Needs protocol 0.24.0, unchanged.

- **Archive is one concept for every file.** New **`ARCHIVED_FIELD`**,
  **`type Archive`** (`by`, `at`, `resolution?`, `with?`) and
  **`isArchived(file)`**. `ForeignObject.archived` is set by
  `resolveForeignObject(s)` on an archived file, so a link opens it and can say
  so. Read by the file's own `records` rule when it declares one, else by
  SPEC's `kind:1851` `a`/`field`/`value`. Only `'true'` archives, as
  `hiddenWhen` already read it; an empty value restores.
- **The resolution is the archiving change's `content`.** New
  **`ActionEventArgs.note`** writes a change's `content`, and is refused on a
  comment, a deletion or a creation, which would drop it. Ship's pre-FOL-46
  placeholder note `archived` (twelve on production) is not a resolution.
- **The cascade is computed when reading, never written.** A file whose parent
  (`parentRef`, walked through everything the Folder lists) is archived is left
  out of `resolveFolderContents` with it — an issue with its project, a
  sub-topic with its topic. A parent in another Folder is read once, one level.
  **New option `resolveForeignObject(…, cache, { archivedWith: true })`** (and
  the same last argument on `resolveForeignObjects`, `type
  ArchiveReadOptions`) marks a file archived **with** its parent
  (`archived.with`), one level, in one more request for the whole set — off by
  default so a refresh tick stays one request. **New option
  `resolveFolderContents(…, cache, { includeArchived })`** returns them marked
  instead of leaving them out. The archives ride in the Folder read's request
  in groups of 25 addresses, so a busy Folder's other changes cannot push them
  off the shared filter's 500.
- **A Folder is archived the way any file is.** `FolderSummary.channel` is its
  `kind:39000` address and `FolderSummary.archived` its archive;
  `FolderContents` carries both for the Folder read. `listFolders` reads every
  Folder's archive **in the request it already makes for the record roots**
  (one more request only when no state lists a record), and marks a record's
  channel archived with its record when every record naming it is.
  **`topLevelFolders` leaves an archived Folder out**, and
  `resolveFolderContents` leaves out a nested one. New
  **`planArchiveFolder(pubkey, ms, { folder, channel, archived, resolution? })`**
  writes the same change `buildActionEvent` writes for any other file.
- **The bare file declares `archive`** (`emits: { kind: 1851, field:
  'archived' }`, vocabulary `archived`: `''` Active, `'true'` Archived — Ship's
  `ARCHIVED_STATES`), so a consumer draws Archive on a topic from the
  declaration, as it draws Rename.
- **New `archiveImpact(address, query, { folders?, cache? })`** — the counts
  behind the warning: `hides` by kind (children, and for a Folder what only it
  lists with their children, not counting what is archived already) and
  `staysVisible` (files another live Folder also lists).

## 0.36.0 — 2026-09-29

**Manifests: three new declarations, and every manifest already published
reads as it did** — none declares them (SPEC §7.2–7.3, MAN-1). One behaviour
moves for the bare file only, and it is the same answer: its `parent` is now
read from its declaration instead of a constant. Needs protocol 0.24.0.

- **`children.movedBy`** — the field whose change moves a child. The folded
  field wins over the `via` tag, an empty value — or one that is not an
  address of the declared parent kind — is a move to no parent, and it is
  ignored with `match: 'identifier'`. `ForeignObject.parentField` is the
  declared field (was: `parent` on a bare file, nothing else), and the new
  **`ForeignObject.parentKind`** is the kind a move may name — absent on a
  bare file, which may sit under any kind. `Nestable` gains `kind` and
  `parentKind`, and `moveTargetsOf` offers only that kind when it is set.
  The bare file declares `movedBy: 'parent'`; `BARE_FILE_PARENT_FIELD` is gone
  (it was never exported).
- **`emits.placement`** — on a creation, the tag the root names its Folder
  with: `h` (default) or `buzz-channel`. Anything else is refused.
- **`emits.listed`** — a creation is followed by `kind:1852 add` when the
  Folder has state, a deletion by a `remove` from each Folder with state that
  lists it. **New: `buildActionEvents(args & { folderHasState?, listedIn? })`**
  returns the events in publish order and refuses without the fact it needs;
  **`buildActionEvent` refuses a `listed` creation or deletion** rather than
  build the root alone. `ResolvedAction.listed` marks the actions that need
  it. `ActionEventArgs` is the args type, now named.
- **`format`** — on a creation's `content` property or a change's input,
  `estiva-blocks-1` says the value may be a block document.
  `ActionFormField.format` and `ResolvedAction.format` carry it when it is a
  format this version writes (`type ProseFormat`). **`buildActionEvent({
  contentFormat })`** tags the root or change with `content-format`, after
  parsing the value, and adds an `imeta` per attached file; without it the
  value is written untagged as marker text, as before. Refused on a comment, a
  deletion, or an action not declaring that format.
- **`actionProblems`** flags an unknown placement, a `listed` on an action
  that neither creates nor deletes, a format with nowhere to go and an
  unknown format.

## 0.35.0 — 2026-09-29

**Manifests: nothing.** No declaration is read differently. Additive: a reply
is built from NIP-22, as a top-level comment already was, and the manifest
still decides the kind, whether a comment exists, and what kinds are read.

- **`buildActionEvent({ replyTo: { id, kind, author } })`** (PRO-20) — a
  comment that answers another: `A`/`K`/`P` still name the file, `e`/`k`/`p`
  name the parent, and there is no lowercase `a` (SPEC §6.4). Refused on any
  action that is not a comment. Peek and the agent each amended the built
  event's tags to do this; now there is one.
- **`threadsOf(reference, rootIds, query, cache?, limit?)`** — `conversationsOf`'s
  counterpart by `#e`: the roots by id and their direct replies, every thread
  in one request, kinds off the owner's manifest. `limit` caps the replies
  across all threads, `CONVERSATION_LIMIT` by default. Returns the events, as
  `{ roots, replies }`. Peek's thread pane and the agent's `peek topic` read.

## 0.34.0 — 2026-09-29

**Manifests: nothing.** No declaration is read differently. A security fix to
how a `kind:40003` is read.

- **An edit lands only on the first `e` whose value is 64 hex** (PEE-38, SPEC
  §6.8), marker ignored — the event whose ownership the relay checked.
  `commentDecorationsOf` used to take the first *unmarked* `e` naming a target,
  so `['e', <own message>, '', 'mention'], ['e', <victim>]`, which the relay
  accepts on the writer's own message, was drawn as an edit of the victim's.
  Same with the marker left off when the writer's message was not among the
  targets. Both now apply nowhere, or to the writer's own message when it is.
- **`editTargetOf(event)`** — that rule, exported, so an app folding edits
  itself reads the same `e`.
- **An `e` of `constructor` or `__proto__` no longer throws.** The target
  lookup used `in` on a plain object, so a resolution carrying one found
  `Object.prototype` and `commentDecorationsOf` rejected for every reader of
  that conversation. It is now `Object.hasOwn`.

## 0.33.0 — 2026-09-29

**Manifests: nothing.** No declaration is read differently. Additive.

- **`topLevelFolders(folders)`** (FOL-5) — the folders a sidebar draws as
  sections: state, and no other folder lists it. Peek and Ship each held this
  line word for word; now there is one.
- **`FolderSummary.addresses`** — what a folder's state lists, unresolved and
  in the state's order, present only when there is anything. `listedIn` in the
  other direction, read off the states `listFolders` already holds, so it
  costs no request. Ship draws a team's projects from it: its own records the
  team's state lists, rather than each project's own `contentFolder`.

## 0.32.0 — 2026-09-24

**Manifests: nothing.** No declaration is read differently. What is new is
the write side of Folders, which until now each app wrote for itself.

- **Folder write planners** (FOL-4): `planCreateFolder`, `planRenameFolder`,
  `planPlaceFile`, `planUnlistFile` and `planMoveFile`. Each returns unsigned
  events in publish order and does no signing, no I/O and no clock or id
  generation; each app keeps its own signing and publishing (ADR 0002 §10 —
  Ship is the second app to write these).
  - create = `[9007, 1852 add + name]`, so a Folder has state from birth;
  - rename = `[9002, 1852 add + name]`, the command only when it has state;
  - place / unlist = `[1852 add]` / `[1852 remove]` only when it has state;
  - move = `[add to target, remove from source]`, add first.
- **`hasState` is always the caller's**, from a read. A `kind:1852` against a
  Folder with no `kind:30890` emits state listing only what the command named,
  and everything filed in it by `h` stops being listed (peek#192). So **a move
  into or out of a stateless Folder is refused** — `{ ok: false, reason:
  'source-has-no-state' | 'target-has-no-state' }` — where Peek's own move
  sent both commands regardless.
- **Peer dependency: `@estiva-app/protocol >= 0.23.0`**, for
  `buildFolderCommand`. Nothing else in this package needed it raised.

## 0.31.0 — 2026-09-23

**Manifests: nothing.** No declaration is read differently. What is new is a
way to read many objects at once.

- **`resolveForeignObjects(references, query, lookupPeople?, cache?)`** (PER-10).
  The objects `resolveForeignObject` returns, keyed by reference, for one POST
  (chunked at `MAX_FILTERS_PER_QUERY`) and one people lookup instead of a
  request and a lookup each. Each object's filters are exactly the single
  form's, so each keeps its own limits. Peek's Screener resolved every followed
  file on its own each minute: 144 of an idle Desk's 194 requests in five
  minutes. A reference that is not an address is `null`; a failed read rejects
  the whole call.
- **An object's events are deduplicated before it is assembled**, in both forms.
  The relay concatenates what each filter matched, so an event two of one
  object's filters matched — a `kind:9` carrying both a Topic's `h` and its `a`
  — came back twice and would be listed twice. Not seen on production; in a
  pooled answer it is the ordinary case (a comment that names two files).
- **A root is matched on its author as well as its kind and `d`.** The filter
  already asked for that author, so a single resolve is unchanged; in a pooled
  answer two apps' objects may share a kind and a `d`.

## 0.30.1 — 2026-09-23

**Manifests: nothing.** No declaration is read differently. What changed is
how many times the same one is fetched.

- **`createProjectionCache` shares a manifest read that is still running**
  (PER-8). The memo only helped a resolve that started after another had
  finished, so a consumer resolving a set at once — Peek's Screener,
  `Promise.all` over every followed file, every minute — sent the
  recommendation and manifest lookups once *per file* each time the entry was
  cold. Production: 133 of an idle tab's 385 requests in five minutes, against
  a meter that refused 74. A 20-resolve burst now costs 2 discovery requests
  instead of 40.
- A failed read is shared by the burst that caused it and **not** remembered:
  the next resolve asks afresh. `clear()` during a read keeps that read from
  writing its answer back.
- `ProjectionCache` gains an optional `@internal` `share`. A hand-written cache
  without it behaves exactly as before.

## 0.30.0 — 2026-09-23

**Nothing a manifest may declare changes, and nothing about how one is read.**
`commentDecorationsOf` reports more, and a caller that passes a body gets a
narrower `edit`.

- **An edit's attachments fold** (CON-5, RFC 0.4 §7.2.1 as amended
  2026-09-23). A `kind:40003` may carry `imeta`. `CommentDecoration.attachments`
  is the `imeta` set of the newest edit that carries at least one, in the body
  fold's order; a set replaces the target's own, and it is absent when no edit
  carries one, so the target's own attachments stand.
- **A target may carry its `body`.** When it does, `edit` is about the body:
  its `at` and `by` are the last edit whose content differs from the body it
  replaced, and it is absent when every edit only attached files. `edit.body`
  is still the newest edit's content. A target without `body` gets exactly
  0.29.0's answer.

## 0.29.0 — 2026-09-23

**The bare file's built-in manifest declares one more action, `move`.** No
manifest on the relay means anything different, and nothing is read
differently except one value that used to be ignored. One export is new.

- **`move`** (FOL-4) — a `kind:1851` change to the `parent` field SPEC §6.7
  already defined; the value is the new parent's address, or `''` for the top.
  `h` is the file's team and does not change. A consumer that draws every
  field-setting action as a property row will now meet it; keep it out with
  the new `ForeignObject.parentField`, as the title's rename is kept out.
- **A `parent` change with an empty value moves the file to the top.** 0.28
  read it as "no change" and fell back to the root `a` tag, so a file could be
  moved under another but never out again.
- **`nestingOf(files)`** — roots, children, breadcrumb, move targets, drawn
  from one team listing with no requests. A file on a cycle is drawn at the
  top; a parent outside the listing is not drawn.

## 0.28.1 — 2026-09-22

**Nothing a manifest may declare changes, and nothing about how one is read.**
`listFolders` fails where 0.28.0 did not.

- **A refused record read throws** (FOL-42). 0.28.0 caught a refusal of its
  second request and returned the listing without the channels it places.
  Measured on production the same day: a page load that hit it left a
  project's channel unplaced for the whole session and the team's dot dark,
  and a listing missing placements is indistinguishable from a correct one, so
  nothing could retry. It now rejects exactly as a refusal of the Folder read
  always has; keeping the last good listing and retrying is the caller's.

## 0.28.0 — 2026-09-22

**Nothing a manifest may declare changes, and nothing about how one is read.**
One result is wider: `listFolders` places more channels, and costs one more
request when any state lists a record.

- **A record's own channel is listed wherever the record is** (FOL-42). A
  state lists a Ship project by address, and the project's conversation lives
  in the channel its `buzz-channel` — or, on about half of production's
  projects, its `h` — names. No state lists that channel, so `listFolders`
  reported it placed nowhere, and Peek's team dot, which folds a channel's
  unread verdict into its `listedIn`, had nowhere to fold a comment on a
  project. `listFolders` now reads the records the states list — one request,
  grouped by kind and author — and places each one's `folderOf` channel in
  every container that lists the record. Not placed: the container itself (a
  file published into its Folder carries that Folder's `h`), and a channel
  with state of its own, which is a Folder that states place — so no section
  leaves anybody's sidebar. If the record read is refused the listing is what
  0.27.0 returned rather than an error. Measured on production: 21 channels
  placed, the six top-level Folders unchanged.

## 0.27.0 — 2026-09-21

**What a manifest may declare changes, in one place: a `urls` pattern may carry
its placeholder in the query string.** Every manifest already published means
what it meant — every declared shape has its placeholder in the path, and a
path-only shape is read exactly as before, query ignored. And one thing is
read differently: a `kind:1111` event now resolves.

- **`?thread=<id>` is a shape** (FOL-38). Peek opens a thread as
  `/topic/<slug>-<d>?thread=<id>`, and the link is about the comment, not the
  topic. `matchObjectUrl` reads a placeholder out of a pattern's query, and
  when it does, that is the identity and the path's is the container, returned
  as `within`. A pattern with no query placeholder still ignores the query — a
  tracker's `?utm_…` never broke a link and still does not — and when two
  patterns claim one URL, the one whose query the URL satisfies wins whatever
  order the manifest declared them in, so the topic shape and the thread shape
  are two `urls` tags.
- **A comment resolves through the built-in manifest** — the one that draws
  the bare file, and for the same reason: every app writes `kind:1111` and
  none claims it with a `k` tag, so `resolveForeignEvent` on a comment found
  no manifest and answered null. It now draws as a `message` — the author as
  the title, the text as the body — named `File`, and opens in the app that
  declares the `conversation` aspect, by that app's `nevent` template.
  `resolveAspectApp`'s memo is keyed by entity as well as aspect, so a file
  and a comment in it never share the wrong link.

## 0.26.0 — 2026-09-21

**Nothing a manifest may declare changes, and nothing about how one is read.**
What the comment kinds a manifest declares are *read for* changes: every
manifest already published means what it meant, and `resolveForeignObject`
costs exactly what it did, but two results are narrower.

- **A mention is not a comment** (CON-15). `resolveForeignObject`'s
  `comments`, `conversationsOf` and `conversationCountsOf` used to hand the
  raw `#a` union, and the `a` tag is the index of every reference — so a
  widget said "3" beside an issue with one comment and two mentions, the
  merged list SPEC §6.4 says an app MUST NOT present. They now keep only
  what §6.4 calls a comment, per kind: a `kind:1111` whose uppercase `A` is
  the address (one with no `A` reads its `a` as the root), and any other
  comment kind — a `kind:9` — whose `a` is the address and whose body does
  not name it by `naddr`. Excluded: a `1111` rooted on another file that
  names this one, and a topic message whose `a` indexes a `[`-menu
  reference. Kept: a pre-REW-10 `kind:9` comment, which is not replaceable.
  Peek's `isCommentOn` (CON-14) and Ship's `anchorIndex` (CON-13) already
  applied this to the events they read themselves; the widget count and the
  read-only `comments` list now agree with them. `commentDecorationsOf` is
  untouched — a reaction on a comment is about the comment. Not done here:
  returning the mentions beside the comments so a read-only viewer could
  draw *Mentioned in* without a second read.

## 0.25.0 — 2026-09-19

**What a manifest may declare changes, in one place: an action whose
`emits.kind` is `5` is a deletion.** Every manifest already published means
what it meant — none declares one — and every declaration is read as before,
except that such an action, which 0.24.0 skipped as neither change nor comment
nor creation, now resolves.

- **A deletion is a fourth action shape** (FOL-33). It resolves with
  `control: 'confirm'` — a new member of `ResolvedAction.control`, so a
  consumer that switches on it and falls through to a text input must not draw
  one for it — and `buildActionEvent` builds NIP-09's `kind:5` naming the
  object's address (`a`, plus `k`), which is the branch on which the relay
  checks the actor against the address's author. Offer it to everyone and
  report the relay's answer; a client cannot evaluate the agent-owner half of
  the rule (SPEC §6.5).
- **The bare file's built-in manifest declares `rename` and `delete`** beside
  `comment`. `rename` is a `kind:1851` change to `title`, which the title slot
  already folded — so anyone in the team may, and every reader sees it — rather
  than a re-publish of the `30840`, which only its author could sign and which
  from anyone else is a different address, not a refusal. A consumer that draws
  every field-setting action as an input now gets a "Rename" box under a bare
  file's card; its `field` is the one `slots.title.field` names, so a consumer
  that treats the title as a heading rather than a property row can tell.

## 0.24.0 — 2026-09-18

**Nothing a manifest may declare changes, and nothing about how one is read.**
One function and its types are added; every manifest already published means
what it meant, and `resolveForeignObject` costs exactly what it did.

- **A comment's edits, reactions and resolutions** (SHA-27, for FOL-31).
  `commentDecorationsOf(targets, query)` reads, by `#e` and in one request,
  what has been done to a set of comment ids: the newest `kind:40003` as
  `edit` (CON-8), every `kind:7` as `reactions` for the newest
  `REACTION_HORIZON` (100) targets with `reactionTargetsOmitted` reported
  (SPEC §6.6, CON-1), and every `kind:9101` `t=resolution` as `resolutions`
  oldest first (PEEK-128). A second round trip by construction — an edit
  names the comment, not the file — so it is a function a conversation view
  calls with the ids it has, and a widget that draws no comments never pays
  for it.

## 0.23.0 — 2026-09-14

**Nothing a manifest may declare changes, and nothing about how one is read.**
One field is added to a return type; every manifest already published means
what it meant.

- **`listFolders` says which folders another folder lists as files** (FOL-22).
  `FolderSummary.listedIn`, present only when non-empty: the ids of the folders
  whose `kind:30890` state carries an `a` naming this folder's `kind:39000`.
  Read off the states the call already fetches, so it costs no query. A
  channel is addressable (RFC 0.4 §5.2), and Peek's topics are placed in their
  teams exactly this way; a sidebar filters on it to draw the five teams and
  not, beside them, the twenty-five topics inside them.

## 0.22.0 — 2026-09-14

**Nothing a manifest may declare changes. How a `records` declaration is read
does**, in one place: a change event of the declared `changeKind` now also
says where a file is listed. Every manifest already published means what it
meant; a consumer upgrading sees more files in some folders, never fewer.

- **A folder lists the files placed in it, as well as the files whose `h` it
  is** (FOL-20). `resolveFolderContents`, for a folder with no state: a change
  carried in the folder (`h`), naming a target through the app's `targetTag`,
  **whose value is the folder's own id**, places the target here. That is the
  statement Ship announces into a Folder when a project is linked to it — the
  only thing an append-only record can say once its `h` is fixed — and it is
  what lets five team Folders list fourteen projects none of which was
  re-created. `resolveFolderProject` has read the same statement since PEEK-24;
  this is the listing's half.
- **Current, or absent.** A placement holds only while the target's own fold
  still agrees with it: the field the statement set, folded from every change
  against the address, must still equal this folder. A project linked to a
  second team drops out of the first, whose statement an append-only stream
  cannot retract. An ordinary edit that strayed into a Folder — the relay
  accepts those — places nothing, because its value is not the folder.
- **No extra round trip.** The change filter rides in the containment
  request, and the change kinds come off the handler sweep it already made.
  Ordering is newest-first by whichever event put the file here, so a
  placement sorts by when it was made.
- A folder *with* state is unchanged: its `a` list is the model.

## 0.21.0 — 2026-09-13

**Nothing a manifest may declare changes, and nothing about how one is read.**
Every manifest already published means what it meant.

- **`conversationsOf` — what each file's conversation holds, one round trip
  for all of them** (FOL-16). The read `conversationCountsOf` was already
  making, returned instead of reduced: per file, each message's `id`, `at`,
  `by` and thread `root`, oldest first, as `ConversationMessage[]`. A list can
  judge unread per file from exactly this — SPEC §11.1 keys a file's read state
  on its address, and the messages' own `a` tag names it — with no second read.
  Read state is not this package's business, so no marker is known here; the
  consumer brings the rule. `conversationCountsOf` is now its length, so the
  badge and the dot can never disagree.

## 0.20.0 — 2026-09-12

**One field a manifest may now declare, and one kind whose link now comes
from it.** Every manifest already published means what it meant: none declares
the field, and the only reader of it is the bare file's opener.

- **`aspect` — a generic app says which aspect of every file it renders**
  (RFC 0.5 §10.7, the one protocol change that section asks for). `conversation`
  or `document`; `ASPECTS` and `Aspect` are exported. A specialized app — one
  that owns kinds — leaves it out, and nothing about how its objects resolve
  has changed.
- **A bare file's `openUrl` is the conversation app's `web` template with the
  file's own `naddr`** (FOL-17). The bare file has no owner and so had no
  template to take, which left a topic under a Ship project drawn as a card
  that was not a link. `resolveManifest` for `kind:30840` now also finds the
  manifest declaring `aspect: "conversation"` and carries its template as
  `ResolvedManifest.webTemplate`; the projection is still the built-in one
  and still cannot be overridden by any `kind:31990`. Newest wins among
  several, as the `#k` fallback does; SPEC §6.7's author recommendation for
  `30840` remains unread until a second conversation app exists.
- **That costs one round trip where it cost none**: a sweep of every
  `kind:31990`, the same one a folder read by containment already makes.
  Memoised in `ProjectionCache` under one key for every bare file, and a batch
  read (`resolveFolderContents`, `conversationCountsOf`) makes it once for all
  the bare files it holds however many authors they have — cache or no cache.
  The projection itself still consults nothing: with no manifest on the relay
  a bare file resolves as before, and simply has nowhere to open.

## 0.19.0 — 2026-09-11

**One kind is now read without any manifest, and one declaration is now
ignored.** Everything else published means what it meant.

- **`kind:30840` — the bare file — resolves from a manifest built into this
  package** (SPEC §6.7, RFC 0.5 §10.7). Title from the `title` tag with a
  `title` fold over it, body from `content`, comments `kind:1111` at its
  address, changes `kind:1851` under the same rule Ship declares, widget
  `card`, and a `list` of bare files beneath it. `resolveManifest` answers
  before the cache and before the network — zero round trips — and
  `ResolvedManifest.address` is `spec:6.7` rather than an event address,
  deliberately not shaped like one. `KIND_BARE_FILE` and
  `BARE_FILE_MANIFEST_ADDRESS` are exported.
- **A `kind:31990` that lists `30840` is ignored, recommendation or not.** No
  app may own the bare file: NIP-89 ownership is keyed by kind, and an owner of
  this kind would own every subject nobody has built an app for. A test pins
  that a greedy manifest with a recommendation pointing at it changes nothing.
- **`parentRef` for a bare file is its own `a` tag, whatever the parent's
  kind**, with a `parent` change event winning over the root tag — the way an
  issue's `project` field works. Every other kind's `parentRef` still comes
  from the *parent's* declared `list` slot; both directions are needed, and
  this one is FOL-4's generic parent link brought forward because a topic under
  a project is the first thing FOL-3 does.
- **A folder read by containment asks for `30840` by name.** The kind list
  used to be derived from published manifests alone, which would have made a
  team's topics the one thing its own folder view did not show.

## Backfill — 0.17.0 and 0.18.0

Shipped without entries here. **0.17.0 (2026-09-10)**: `listsChildren` on a
`ForeignObject`, true only where the owning app both declares a child list and
draws the child kind; `MAX_LIST_DEPTH` stays 1. **0.18.0 (2026-09-10)**:
`parentRef` on a `ForeignObject`, the address in the child's `via` tag matched
on the parent kind's prefix, set whether or not the parent is in the same
listing (estiva-foundation#58, #59). No manifest already published was read
differently by either.

## 0.16.0 — 2026-09-08

**No manifest changes meaning, and nothing already published is read
differently.** Nothing new may be declared: this is a rule about an event the
*relay* signs, so no producer writes it, reads it or can opt out of it. A
manifest published yesterday means exactly what it meant.

`folderOf` resolves a NIP-29 `kind:39000` to its own `d`. Measured on
production 2026-09-08: **0 of 40 channel records carry `h` or `buzz-channel`,
and 40 of 40 carry `d`** — so every write aimed at a topic was refused for
having nowhere to go, and Peek could declare no action on one (INT-9).

**This is the case 0.13.0 added as vocabulary and 0.14.0 withdrew.** The
withdrawal was right and is not being reversed: `records.folder: "identifier"`
asked every future producer to read a field whose only instance RFC 0.5 §1
retires. What went unnoticed is that it took a working feature with it, four
days after PRO-18 concluded a conversation had become declarable — because
nothing had declared the field yet, so nothing broke visibly.

So the same fact arrives as protocol instead, which is the distinction 0.14.0
was actually about. A `kind:39000` is relay-signed group metadata whose `d` *is*
the channel id: buzz's `NOSTR.md` §"Group metadata" states it,
`channel_info_from_event` reads it, and the relay's `h_grammar` is the same
uuid. That is the same standing `h` and `buzz-channel` already have here.

Two things keep it from growing into the rule it replaced:

- **`kind:39000` and nothing else.** Not "any addressable kind" — that would
  hand every `30000`–`39999` record its own `d` as a Folder and land a
  consumer's write in a channel that may not exist. A container-shaped
  addressable kind that is not the relay's still answers `null`, and a test
  holds it.
- **It is last in the chain, so it decays into dead code.** FOL-3 gives a topic
  an `h`; from that day the first clause answers and this one is unreachable.
  A shape a model change retires should stop firing, not start lying — the
  test named for FOL-3 pins the order that makes it a no-op.

`KIND_CHANNEL_METADATA` is exported for the same reason `KIND_FOLDER_STATE` is:
a bare `39000` at a call site reads as an app's kind rather than the relay's.

**`buildActionEvent` no longer needs a fold rule to build a creation.** Its
first line refused any app declaring no `records`, which gated creations and
comments on a rule only a *change* uses — the change tags are the only place it
is read. PRO-12 settled this on the read side (*"SPEC mandates `records`; the
runtime correctly stopped requiring it"*) and the write side never followed.

Found against production, and findable nowhere else: every fixture in this
package's suite declares a fold rule. Peek declares none deliberately — a
topic's name is a tag the relay wrote and a message is immutable — so its
first action came back "That app does not say how its records are written",
which is true, irrelevant, and impossible to act on. A change still refuses,
and still names the rule it is missing.

**What this does not unblock.** A container-*creating* action — Ship's
`add-project`, Peek's `create-topic` — still cannot be declared. Those need a
Folder that does not exist yet, which is FOL-2, and PRO-18's steer stands: wait
for the folder model rather than invent a shape it contradicts.

## 0.15.0 — 2026-09-08

**No manifest changes meaning, and nothing already published is read
differently.** This adds a reader. Every declaration it consumes — `projections`
for which kinds are files, `records` for the fold and for `hiddenWhen`, `widget`
for the fallback chain — is one manifests already carry, read in a new place.

The folder read model RFC 0.4 §4 and §5 specify: given a folder id, list what is
in it **across apps**, resolving each file through the manifest of whoever owns
it. Both reference apps consume this, so it belongs here rather than in either.

- **`resolveFolderContents(folder, query, lookupPeople?, cache?)`** returns the
  folder's identity and its files as ordinary `ForeignObject`s — the same shape
  an `naddr` in a message resolves to, carrying the same slots, actions and link
  back into the owning app.
- **`listFolders(query)`** is the sidebar's half: every folder this identity can
  see, named. It asks by kind rather than by walking anything, which is §4.2's
  own post-mortem on REW-11 — discovering children only through their parent
  loses them when the parent goes.
- **`KIND_FOLDER_STATE`** is `30890`, allocated by RFC 0.4 §12.1. Relay-signed,
  addressable and **global**: §4.2 shows three of the four properties a folder
  needs are impossible if its state carries an `h`.

**A topic and a project are peers because §5.2 made them peers**, not because
anything here special-cases either. A channel turned out to be addressable — the
relay emits a `kind:39000` per channel whose `d` is the channel uuid — so one
tag type, `a`, names every file, and a folder listing a topic beside a project
needs no second mechanism.

**Two rules in here are product decisions rather than mechanism**, and both are
worth reading before changing:

- **A file the reader cannot see is absent, not "unavailable".** A deliberate
  divergence from upstream NIP-MP, whose fold requires the opposite for public
  repositories. The count is the disclosure: a folder rendering three rows and
  two placeholders has told an outsider how much they are missing. Nothing in
  the return value counts what was dropped, and a test asserts that.
- **A file is anything addressable.** A `kind:9` message carries no `d`, so it
  is conversation rather than contents. That is the whole discriminator, and it
  needs no kind numbers.

**A folder with no state event still lists**, by containment — `h`, plus
`buzz-channel` for a record published globally, which on production today is
nine of nineteen projects. Kept because both wire shapes coexist permanently: no
migration is available, and a reader that understood only folder state would
show every folder on production as empty. It is an approximation and says so
(`source: 'channel'`), because containment by `h` **cannot list a topic** — under
`h` the topic is the container rather than something inside it.

Measured against production reading three folders through one cache: 24, 25 and
9 files cost 5, 5 and 4 round trips warm. Flat in the number of files, linear in
the number of apps.

**0.13.0 and 0.14.0 have no entries here.** Both were published; neither
updated this file. Not reconstructed here, because their commit messages are
detailed and the person who made them should say what they meant.

## 0.12.0 — 2026-09-04

**No manifest changes meaning, and nothing is read differently.** This adds a
producer-side check; it declares nothing new and alters no resolution.

`ManifestAction.description` said *"nothing reads this yet, and that is
expected."* Something does now — Peek's launcher selects an action from a
conversation by matching on that prose, across every manifest it can resolve.
Which makes a description written for a screen not merely unused but an action a
caller never picks, with no error anywhere: `resolveManifest` accepts it, every
consumer renders it, and it is quietly never chosen.

- **`actionProblems`** returns the sentences a producer needs before signing —
  a description that restates its label, one short enough to be a caption, an
  absent `effect` (read as *unknown* in both directions), a misspelt one
  (dropped, so a typo is invisible). Empty when the action is fine.
- **`MIN_ACTION_DESCRIPTION`** is the threshold, exported because it is a proxy
  rather than a truth and a producer may reasonably want to see it.

The division of labour is `widgetChainProblem`'s, stated there: a consumer stays
safe against a declaration it did not expect, *and* the unusable declaration
should not be signed. **Advisory, never fatal** — nothing in resolution calls
it, and a test asserts that, because a consumer refusing to render an app over
its prose is the objection that rules out iframes wearing a different hat.

Lifted from Ship, which has enforced these three rules privately since PRO-5.
Nothing was wrong with the rules; they protected one manifest.

## 0.11.0 — 2026-09-04

**A second identity shape: an event id, for an object that has no `d`.** No
existing manifest changes meaning; a pattern declaring `<id>` instead of `<d>`
is new, and `MatchedObjectUrl` gains `by` so a consumer knows which query to
make.

RFC 0.5 §7.6 recorded a message as *addressed by `nevent`, outside this
grammar*. PEE-17 needed a link to one, and following that literally would have
put bech32 in a URL — which §7.3 argues against on two grounds that apply to
`nevent` exactly as they do to `naddr`: a slice of it is checksum bytes rather
than the identity, and relay hints are part of the encoding, so one object has
more than one spelling.

**A raw event id has neither problem**, and resolves with `{ids: ["<id>"]}` —
one query, no index, the same property §7.2 chose the bare uuid for. So the rule
generalises rather than gaining an exception: the identity goes in the path,
unencoded, and everything before it is decoration.

- **`eventIdFromRef`** reads a 64-character id off a ref's tail.
- **`MatchedObjectUrl.by`** is `'d'` or `'id'` — the difference between
  `{"#d": […]}` and `{ids: […]}`.
- **The pattern decides which**, by carrying `<id>` or `<d>`. Not sniffed from
  the value: a uuid and a 64-hex string are distinguishable today, and a
  consumer relying on that would be inferring an app's addressing model from a
  character class.

Exactly 64 hex is required. A shorter run is a truncated id, which resolves to
nothing or — worse — to something nobody intended.

## 0.10.1 — 2026-09-03

**`matchObjectUrl` matched no fragment route at all.** The parser's path capture
stopped at the `#`, so `https://ship.estiva.app/#/issue/<uuid>` reduced to no
segments and matched nothing — including patterns declaring exactly that shape.

That is not a corner. Ship served fragment routes until SHI-16 and declares them
still, so that links already sitting in other people's messages resolve rather
than rendering as plain text for ever. A consumer on 0.10.0 would have silently
failed every one of them.

Found by a production probe before any consumer was built on it, which is the
argument for probing the mechanism rather than the fixture.

- Path and fragment segments are concatenated rather than swapped, because an
  app may be served under a sub-path *and* use a fragment — Ship's own dev URL
  is `localhost:5190/ship/#/…`, where both halves carry meaning.
- A fragment route and a path route stay distinct shapes. Collapsing them would
  let a pattern for `/issue/<slug>-<d>` claim `/#/issue/<d>`, and an app that
  means different things by the two would resolve the wrong object.

## 0.10.0 — 2026-09-03

**RFC 0.5 §7's URL grammar, which was accepted with amendments today.** No
existing manifest changes meaning; this adds a shape an app MAY declare and the
matching a consumer needs to read it.

### Added

- **`slugify`, `objectRef`, `identifierFromRef`** — `<type>/<slug>-<d>`, built
  and read. **Moved from Peek** (`src/lib/objectUrl.ts`, PEE-14) rather than
  rewritten, with its cases, because an extraction that changes behaviour while
  every test still passes is the thing to guard against.

  Here rather than in each app because §7.1's argument is that the URL one app
  puts in the address bar must be resolvable by another — SPEC §10's test for
  something the protocol owns. Two implementations would disagree about what a
  slug may contain, and the disagreement shows up as a link that resolves only
  in the app that wrote it. Ship was about to grow the second copy (SHI-16).

- **`urlPatternsOf`, `matchObjectUrl`, `UrlPattern`, `MatchedObjectUrl`** —
  §7.5's inbound half: given a pasted link and the shapes apps publish, recover
  which object it names. A URL no pattern claims returns `null`, which is the
  honest outcome — it is a link, and nothing should claim otherwise.

### The amendment this implements

§7.2 said a consumer MUST ignore *everything* before the final uuid, which
contradicted the paragraph above it and, read literally, resolves
`evil.example.com/issue/<uuid>` as a Ship issue. **The host and the `<type>`
segment select the app and the kind; only the slug is decoration**, and
`matchObjectUrl` enforces that. It is the difference between a resolver and a
gadget for rendering an attacker's chosen object inside your app.

### One thing §7.5 leaves open, decided here

A bare pattern tells a consumer *which app*, not which kind: `/issue/` means
`30851` only to the app serving it. So `urls` MAY carry the kind as a third tag
element. When it does not, a consumer falls back to the kinds that manifest
declares it handles — sound because no `d` is reused under two kinds, measured
across all 280 production records at §7's acceptance.

## 0.9.1 — 2026-09-03

**No behaviour change. The README was wrong, and this is the release that fixes
the page people are told to start from** (PRO-9).

- **§2's example did not compile, and its copy-paste path published an error
  message.** It called `buildActionEvent({ object, actionId, value })`, which is
  not the signature: the function takes the manifest, the kind, the address, the
  object's author, the folder, the signer's pubkey and a clock, because this
  package reaches for none of them. And it returns `UnsignedActionEvent | string`
  where **the string is the refusal** — the example signed and published the
  result without checking, so a reader following it would have put the words
  "This app does not offer …" on the relay.

- **The content model was undocumented.** `ResolvedSlot.format` shipped in 0.7.0
  and a body cannot be rendered safely without it: §13 forbids reading either
  content model as the other, and the page said nothing about which one you
  have. Now a table of the three values, `toRenderTree`, and the rule that the
  format is decided by the declaration and never by looking at the body.

- **Object-creating actions were undocumented** — `control: 'form'`, `fields`,
  `createsUnder`, and passing an object plus a `newId`, all shipped in 0.6.0.
  With the producer's side too, since `required` is a list of names on `input`
  rather than a flag on each property, which is the sort of thing a page is for.

### Added

- **`test/readme.test.ts` — the README, executed.** Every assertion is a claim
  the page makes in prose, and it lives in `test/` so `npm run typecheck`
  compiles it against the real declarations. The published example, pasted in
  verbatim, fails that pass with `TS2353: 'object' does not exist in type`.

  The page's examples had been run once, in a scratch project, when the package
  was first published. That is a snapshot; this is a check.

## 0.9.0 — 2026-09-02

**Behaviour unchanged, and no export moved.** `contentFormatOf`,
`CONTENT_FORMAT_TAG`, `BLOCK_DOCUMENT_FORMAT` and `ContentFormat` are now
**re-exported from `@estiva-app/protocol`** rather than defined here. Same
names, same values, same results; the 147 tests are untouched and pass.

They were defined here because this layer needed them first — a slot has to
tell a consumer which model its value is in. Reading a tag off an event is a
question about the event rather than the slot, and two folds outside this layer
now need it: Ship's and the agent's, one fold in two repositories held to a
single recorded state. Making either depend on the projection layer to read a
tag is the wrong direction.

**The peer floor moves to `>=0.9.0`**, which is not cosmetic: with an older
protocol these re-exports do not exist and this package would fail at import.

`BODY_SLOT` stays here. A slot is this layer's subject.

## 0.8.0 — 2026-09-02

**Manifest behaviour: a `body` slot now always reports a format, whichever
source it came from.** 0.7.0 reported one only for `{field: "content"}` and for
a folded change, on the reasoning that *a tag is a scalar and never a body*.

**That was false, and false in the common case.** Ship writes a project's
description to a root `description` tag and leaves `content` empty on purpose,
so a reader holding only tags has it without fetching content (`events.ts`).
Found by resolving production rather than by re-reading the rule: 11 of 15
projects resolve a body and **2 of them reported `format: undefined`**, because
nobody had ever changed their description and it lives in the tag.

A consumer seeing `undefined` cannot tell which model it has, and §13 forbids
guessing — so those two were exactly the objects the format exists for.

The rule is now the slot, not the source:

| the value came from | format |
| --- | --- |
| a change event | what that change declared, else `marker` |
| a root tag, or the root's `content` | `contentFormatOf(root)` — both are the same event, and §13.4's tag describes that event's body wherever it keeps it |
| a `default` | `marker`; a literal in the manifest is plain text by construction |

So **`slots.body` always carries a format and a consumer never needs its own
`?? 'marker'`**, which is the whole reason the default lives in this package.

Unchanged: `{field: "content"}` reports a format whatever slot it was declared
into, which keeps the truncation guard reachable for the
`{"subtitle": {"field": "content", "truncate": 120}}` mis-declaration. And
everything that is not a body still reports nothing — `undefined` means *not a
body*, not *marker*. A `title` on a block-document event still reports nothing,
and a folded `lead` is a pubkey rather than prose.

## 0.7.0 — 2026-09-02

**Manifest behaviour: a `body` slot now reports which content model it is
written in, and a structured value is no longer truncated.** No manifest changes
shape. A manifest that already declares `{"body": {"field": "content"}}` starts
resolving with a `format`; one that declares `truncate` on a value whose event
says it is a block document stops having it applied.

PRO-2 held the `body` slot open because "structured content" had no specified
format. RIC-1 decided it (SPEC §13): messages are marker text, rich text fields
are JSON block documents, and the two share an inline vocabulary and nothing
else. §13 forbids reading either as the other, so a consumer needs to be told
which one it has — and that is what a `ResolvedSlot` now carries.

- **`ResolvedSlot.format`** — `'marker' | 'blocks' | 'unknown'`, and **absent
  when the value is not a body at all**. Absent is not the same as `'marker'`: a
  folded `status` arrives through the same `value` tag a folded description
  does, so nothing in the data distinguishes them and only the slot they were
  declared into does.

  `'unknown'` exists so a consumer can decline. A format specified after this
  runtime was written must not be parsed as either model, and §13.5 makes
  rendering it as plain text conformant.

- **`contentFormatOf(event)`, `CONTENT_FORMAT_TAG`, `BLOCK_DOCUMENT_FORMAT`,
  `BODY_SLOT`** are exported. The default lives here rather than in each
  consumer for the reason `CLOSED_WIDGETS` does: two copies of *absent means
  marker* disagree the first time a third format exists, and the disagreement
  shows up as one app rendering JSON at a person.

- **The format is read from the event the value came from**, never from the
  object's root — SPEC §13.4. A description created as marker text and later
  edited into blocks is a root with no tag and a change with one, and the fold
  takes the change's value, tag and all.

- **A `fold` may now be seeded from `content`, not only from a tag** —
  `{"fold": "description", "field": "content"}`. §7.2 rule 2 already allowed a
  fold seeded from a tag, for the reason that tag-alone goes stale and
  fold-alone loses the creation value. A body lives in `content`, so the same
  argument reaches there, and until now neither of Ship's descriptions could be
  declared at all: an issue's is `fields.description?.value ?? event.content`,
  and a project's puts a `description` tag between the two. The chain is fold,
  then tag, then content.

  Found by trying to declare the slot rather than by reading the spec. The two
  expressible declarations were both wrong in the way §7.2 rule 1 warns about:
  `{field: "content"}` renders the creation value for ever, and
  `{fold: "description"}` renders blank for every object nobody has edited —
  most of them — and blank reads as "that app is broken" (PEE-10).

- **`truncate` is refused on a structured value.** PRO-8 established that
  truncating structure produces output that is wrong and cannot tell that it is
  wrong; it was enforced by a comment in Ship's manifest and a type only Ship
  had. Any manifest could publish `{"field": "content", "truncate": 120}`, and
  the consumer would have sliced a JSON document. Marker text is still
  truncated — a cut `**bold` is visibly cut.

**Absence of a declaration stays a declaration, permanently.** 731 published
bodies carry no `content-format` tag and none of them can be given one: roots
are replaceable by their author alone, messages and changes not at all, and
REW-11 established that rewriting stamps a `created_at` the relay will not
backdate. Untagged means marker text for good, not during a window.

## 0.6.0 — 2026-09-01

**Manifest behaviour: an object-creating action is now offered instead of
skipped.** No manifest changes shape; one that already declares
`input.type: "object"` starts being rendered, which is the point (RFC 0.4
§13.4).

- **`resolveActions` no longer drops them.** It used to say why: *"an action
  that creates a whole new object needs a form and a parent, so it is skipped
  rather than drawn as a control that cannot work."* True until something drew
  one — and the consequence was that a manifest could describe the single most
  useful cross-app action and no app could offer it. Ship has declared
  `add-issue` with a real schema the whole time.

  They resolve with `control: 'form'`, `fields` (each with any vocabulary
  already looked up, the same service `options` performs for a `select`), and
  **`createsUnder`** — the other half of "needs a form *and a parent*". A
  consumer drawing only the properties would publish an orphan.

- **`buildActionEvent` takes `{ property: value }`** for those actions, and a
  `newId` for the object being created.

  `newId` is supplied rather than generated: ADR 0002 §10 constraint 2 — the
  runtime reaches for nothing and is handed everything — and it makes the built
  event a pure function of its inputs. It is **required** for a kind in NIP-01's
  parameterized-replaceable range, because an object published without a `d` has
  no address at all: nothing can reference it, comment on it, or act on it.

**The rule that makes construction possible, previously implicit: a property's
name is the tag its value is written to.** It held for Ship by a coincidence of
naming — `add-issue` declares `{ title }` and a Ship issue carries
`["title", …]` — and nothing said so, while RFC 0.4 §13.4 asserts the existing
declaration is already sufficient for a consumer to try. Stated now at the point
of use.

Its limit, recorded rather than designed around: **nothing can target an event's
`content`.** Ship's issue description lives there and is therefore not creatable
from another app, which is why `add-issue` declares only a title.

**Every refusal names what was allowed** — an undeclared field, a required one
left empty, a value outside a declared vocabulary, and a scalar handed to a form
or the reverse. That is a requirement rather than a nicety: the owning app
cannot enforce any of it, so a consumer that guesses is the one putting junk in
a shared record, and one told only "invalid" cannot do better next time.

Verified against production, through Ship's live manifest: resolved as a form,
built, published, **and Ship's own fold shows an ordinary issue** — right title,
default status, right parent. The fixture was deleted afterwards.

Additive, so a MINOR by the rule ADR 0002 §4b sets for `0.x`.

## 0.5.0 — 2026-09-01

**Manifest behaviour: two optional fields an action may now declare.** Nothing
already published means anything different, and a manifest that declares neither
resolves exactly as before.

- **`ManifestAction.description`** — prose aimed at a machine, distinct from
  `label`, which is a button caption (RFC 0.4 §13.4). "Change status" tells a
  person which control to press and tells a caller choosing *between* actions
  nothing.

- **`ManifestAction.effect`** — `safe` | `writes` | `destructive`, whether
  invoking without confirmation is acceptable. Exported as `ActionEffect` and
  `ACTION_EFFECTS` so no consumer has to spell the set out.

Both are carried onto `ResolvedAction`. **Nothing reads them yet, and that is
expected** — they are here because adding a field costs a line and adding one
after several apps have published manifests is a migration across every one of
them, and a manifest is republished by its owner alone. The same argument
`emits.alsoRead` makes one level down.

**An unrecognised `effect` is dropped rather than carried**, which is the only
part of this that is a decision rather than a declaration. Every way of reading
`"nuke"` is a claim nobody made; absent already means *unknown, be careful*, and
leaving an uninterpretable value in place would let a consumer's
`effect !== 'destructive'` answer **true** about an action whose own manifest was
trying to warn it. Forward compatibility falls out of the same rule: a consumer
that does not know a future `reversible` treats it as unknown rather than as
permission.

That is the one field `parseManifest` sanitises, and deliberately the only one.
A manifest is another app's declaration and this layer renders what it is given
— a field this version does not recognise is a newer app, not a broken one.
`effect` is the exception because misreading it is *unsafe* rather than merely
wrong.

Additive, so a MINOR by the rule ADR 0002 §4b sets for `0.x`.

## 0.4.0 — 2026-09-01

**Manifest behaviour: unchanged.** Nothing a manifest may declare moved. What
moves is a cache that had been living in one app.

- **`createPeopleCache()`** — the foreign-profile cache, with its batching and
  its two TTLs, lifted out of Peek.

  It was written there for PEE-3, and the shape is the interesting part.
  `PROFILE_HIT_TTL_MS` is ten minutes because names change rarely;
  `PROFILE_MISS_TTL_MS` is one, because **a miss is somebody who has not
  finished setting up their identity — precisely the person whose name is about
  to arrive.** Cached as long as a hit, they render as `nostr:<8 chars>` until a
  full reload, and a consumer that re-reads on a timer looks like its refresh is
  broken.

  Ship needed the same behaviour, and a second consumer is what moves something
  out of one app rather than duplicating it. Without it Ship re-read `kind:0`
  for every author of every child on every tick: a referenced Peek topic with 50
  messages cost **2 requests a tick where a Ship object cost 1** — the cross-app
  case PRO-7 exists for being the dearest rather than the cheapest.

  **Store and lookup have different lifetimes**, which is why this is a cache
  with `through(fromRelay)` rather than a wrapped function. A consumer builds
  its query per call — Peek's carries the viewer's token — while the profiles it
  finds are public and worth keeping across all of them. `viaRelay(query)` is
  the common case. Caller-owned, like `createProjectionCache`; Peek's original
  was a module-level `Map`, which worked but meant every test needed a reset
  hook to undo the previous one.

  Peek's seven cases travel with it, converted from vitest **by hand** — a regex
  pass over an earlier suite produced assertions with no `assert` in them, which
  read fine and test nothing — and five more pin the store's lifetime, which the
  original could not express.

Additive, so a MINOR by the rule ADR 0002 §4b sets for `0.x`.

## 0.3.0 — 2026-09-01

**Manifest behaviour: unchanged.** Nothing a manifest may declare moved, and
nothing already published means anything different. What changes is how many
round trips reading one costs.

- **`createProjectionCache()`**, passed as the last argument to
  `resolveForeignObject`, `resolveForeignEvent` and `resolveFolderProject`.
  Omitting it is exactly the old behaviour.

  A consumer that re-resolves on a timer paid the whole resolve every tick, and
  **two of the four round trips were NIP-89 discovery** — the author's
  `kind:31989` recommendation, then the `kind:31990` manifest. Those answer the
  same thing until an app republishes. Memoised per kind and author with a
  five-minute TTL; a negative answer is cached too, because "no app claims this
  kind" costs the same two round trips and is just as stable.

  **Owned by the caller.** A module-level cache would be invisible global state
  shared by every consumer in a process, impossible to scope to a screen and
  awkward to reset in a test.

  Deliberately only the manifest. The object, its changes, its comments and its
  children are what a refresh exists to notice — caching those is how a live
  widget becomes a screenshot, which is the defect PRO-10 was filed for.

- **A `list` slot no longer costs a round trip of its own.** The child filter is
  built from the manifest and the *pointer*, and an addressable event's `d` is
  `pointer.identifier` by definition — it is what the root filter matches on. So
  the second query never needed to wait for the first, and the children now ride
  in the same request.

  The two together: **10 requests a tick to 3** for three references on one Ship
  issue, measured against production — one per reference — rendering identically
  (title, kind, comment count, child count, widget). A comment posted between
  ticks appeared on the next one-request resolve, so the widget is still live.

- **Comments and children are now matched on their filter's own criteria**, not
  on kind. This is load-bearing rather than tidying: Peek's Topic declares
  `kind:9` messages as children *and* `kind:9` as its comment kind, so merging
  the two filters into one request puts both under one number and only the tag
  separates them. Matching on kind alone would have made every message in a
  Folder a comment on its own Topic. The predicate uses **any** matching tag,
  as a relay's `#a` does — an event may carry several, and reading only the
  first would silently drop a comment that references something else before its
  parent.

Additive, with a changed request pattern, so a MINOR: ADR 0002 §4b puts the
break on MINOR within `0.x`.

## 0.2.0 — 2026-09-01

**Manifest behaviour: unchanged for addressable objects.** What changes is that
objects which have *no address* can now be resolved at all.

- **`resolveForeignEvent(reference, query)`** — resolve one event by id, from an
  `nevent1…` or a bare 64-hex id.

  The counterpart to `resolveForeignObject`, and PRO-11's reason to exist: a
  `kind:9` message carries no `d`, so `(kind, pubkey, d)` cannot be built for it
  and every resolver keyed on an address is blind to it. Measured on production
  during PRO-6.

  It is deliberately thinner. A regular event is immutable and has no folded
  state, so there is no `records` rule to apply and no change events to fetch;
  it cannot be the target of an `a` tag, so it has no comments addressed to it
  and **no actions**. That last absence is the model being honest rather than a
  gap — a change names its target by address, and there is nothing here to name.

  Two round trips, and the order depends on the reference. A manifest is found
  by kind; an `nevent` *may* carry its kind, and a bare id — which is what a
  pasted `e` tag gives you — does not, so the event is read first to learn what
  it is. Both arrive in practice.

- **`<bech32>` in a `web` template is substituted with whichever form the object
  has.** NIP-89 says nothing about which NIP-19 entity a template is handed;
  Ship's declares `naddr` because every Ship object is addressable. Substituting
  the form the object *actually has* is what lets one template serve both, and
  what stops a message linking to nothing.

- **`peerDependencies` rise to `@estiva-app/protocol >=0.3.0`**, which is where
  `decodeNevent` lives. Unlike 0.1.0's `>=0.2.0`, this range is justified by
  something the package uses — that one was a fact about the workspace and this
  is a fact about the code.

## 0.1.2 — 2026-08-31

**Manifest behaviour: unchanged.** A build fix; 0.1.1 was tagged and never
published, so this is the first release carrying its peer-range change.

- **`prebuild` builds `@estiva-app/protocol` first.** This package resolves that
  one's *types* through its `dist/`, which does not exist in a fresh checkout
  until it has been built — so `npm run build -w packages/interop`, which is
  exactly what the release workflow runs, failed with four TS2307s that read as
  if this package were broken.

  **It is the first foundation package that depends on another one.** protocol,
  identity and hello have no workspace siblings, so every release before this
  was independent and the workflow never had to care.

  Building every package instead would not have fixed it: `npm run build
  --workspaces` runs alphabetically, and `interop` sorts before `protocol`.
  Measured rather than assumed — that was the first fix attempted and it failed
  the same way. The dependency belongs in the package that has it.

## 0.1.1 — 2026-08-31 (tagged, never published)

**Manifest behaviour: unchanged.** No resolution, declaration or export moved.

- **`peerDependencies` relaxed to `@estiva-app/protocol >=0.1.2`**, from
  `>=0.2.0`.

  The stricter range was not justified by anything this package uses. It was
  chosen because the foundation repo happened to sit at 0.2.0 when the package
  was written — a fact about the workspace, not about the dependency.

  Found the way these things are found: **the second consumer tried to install
  it.** Peek is on `protocol@0.1.2` and `npm install` refused with `ERESOLVE`,
  which would have forced an unrelated protocol upgrade to adopt this package.
  Verified rather than assumed before relaxing — all four symbols this package
  imports (`encodeNaddr`, `pointerToAddress`, `referenceToPointer`,
  `parseProfile`) are exported by 0.1.2, and 0.2.0's change was to `Relay`'s
  paging, which this package does not use because it takes a `QueryFn` instead
  of a client.

  This is SHA-7's lesson one level up. The package shipped with one consumer
  that happened to be on the newer protocol, so nothing exercised the floor of
  the range until somebody else installed it.

## 0.1.0 — 2026-08-31

First publish. Extracted from `peek-app/interop/`, which was extracted from
`peek-app/convex/nostr/projection.ts` (PRO-1) — a path that said Convex about a
file whose own header said it knew nothing about the app it rendered.

**Manifest behaviour: unchanged from what is live.** Peek and Ship have been
publishing and reading these manifests on production throughout; this packages
the reader without altering what it reads.

What it resolves:

- **`resolveManifest`** — find the app that handles a kind, preferring the
  object author's own `kind:31989` recommendation over a guess.
- **`resolveForeignObject`** — an `naddr` or `kind:pubkey:d` to slots, meta,
  actions, comments, children and people. Reports `unreachable` when the
  manifest resolved and the object did not, because the relay answers
  "forbidden" and "empty" identically.
- **`resolveFolderProject`** — the container a Folder holds, and its children.
- **`buildActionEvent`** — the unsigned event a declared action emits.
- **`pickWidget` / `widgetChainProblem` / `CLOSED_WIDGETS`** — the fallback
  chain, from both sides. A consumer walks it; a producer is stopped from
  publishing one that ends nowhere.

**Not here, and not by omission:** no fold, no rendering, no relay client. See
the README's last section for why each is excluded.

### Why it is 0.1.0 and not 1.0.0

Two consumers have exercised it — Peek since it was written, Ship since PRO-7 —
and the second one changed the API on contact: `ForeignObject.widget` was typed
`string` while the wire carried a chain, which no amount of use by the first
consumer had revealed. **A third consumer will do it again.** The version says
so.
