# fed-sx Milestone 2 — Multi-actor + Federation

Real federation between two fed-sx instances. Per-actor state, signed
inbox delivery, Follow lifecycle, audience-resolving outbound queue, and
the rich verbs (Note, Announce, Endorse) needed for federated propagation.
Reference: `plans/fed-sx-design.md` (especially §9 identity, §13 federation,
§16 HTTP endpoints). Builds on Milestone 1 (see `plans/fed-sx-milestone-1.md`).

## Goal

Two cooperating fed-sx instances `A` and `B`, each hosting one or more
actors, can:

1. **Discover** each other's actors via webfinger + actor docs.
2. **Follow** across instances (`Follow` → `Accept` → state).
3. **Publish** a `Note` on `B` and have it land in every follower's
   `actor-state` projection on `A` via signed inbox delivery.
4. **Announce** a peer's activity, propagating it to followers of the
   announcer.
5. **Rotate keys** on either side without breaking historical sig
   verification (per §9.6).

Acceptance: the §11 smoke test (`smoke_federate.sh`) drives all of the
above against two locally-running kernel instances on distinct ports, no
human-in-the-loop, and exits 0.

## Non-goals (what milestone 2 deliberately does NOT do)

- **Real WAN federation.** Both instances run on `localhost:PortA` and
  `localhost:PortB`. Cross-instance HTTP is unencrypted plaintext.
  TLS, NAT traversal, and signed HTTP-message headers (per RFC 9421)
  are v3.
- **ActivityPub Mastodon interop.** No HTTP-signatures-2018 compat layer,
  no Linked-Data-Signatures, no JSON-LD canonicalisation. Cross-fed-sx
  only.
- **IPFS / S3 storage backends.** Still local files only.
- **Browser client + operator dashboard.** Curl-shaped API only.
- **Capability tokens / delegation.** Multi-actor means multi-user, not
  multi-device for a single actor. Capability tokens (per §9.5) defer.
- **Cross-host conformance.** Only OCaml/Erlang-on-SX host runs fed-sx
  in v2.
- **Performance work.** Functional correctness first.
- **Spam/abuse infrastructure.** Per §13.6 the layers are designed; v2
  implements signature verification + replay defense; reputation,
  rate-limiting, instance allowlists / blocklists are v3.
- **Operator quarantine UX.** Logs only.

## Architecture summary

```
                   Instance A                     Instance B
                   (port 9999)                    (port 9998)

  Outbox     ┌─────────────────┐               ┌─────────────────┐
  ────────▶ │ HTTP server      │               │ HTTP server      │
            │  POST /activity  │               │  POST /activity  │
            │  POST /inbox     │               │  POST /inbox     │
            │  GET  /actors/.. │               │  GET  /actors/.. │
            │  GET  /.well-    │               │  GET  /.well-    │
            │       known/*    │               │       known/*    │
            └────────┬─────────┘               └────────┬─────────┘
                     │                                  │
            ┌────────▼─────────┐               ┌────────▼─────────┐
            │ nx_kernel        │ ◀ HTTPS ▶    │ nx_kernel        │
            │ multi-actor      │   deliveries  │ multi-actor      │
            │  bucket map      │   (signed)    │  bucket map      │
            │   ActorA -> {…}  │               │   ActorB -> {…}  │
            │   ActorC -> {…}  │               │                  │
            └────────┬─────────┘               └────────┬─────────┘
                     │                                  │
            ┌────────▼─────────┐               ┌────────▼─────────┐
            │ Delivery queue   │               │ Delivery queue   │
            │ (one worker per  │               │ (one worker per  │
            │  peer instance)  │               │  peer instance)  │
            └──────────────────┘               └──────────────────┘
                     │
                     │ HTTP POST /inbox to peer
                     ▼
              (peer instance)
```

The federation transport is plain HTTP POST of canonical-bytes-signed
activities to each follower's actor inbox. Delivery is push (§13.1); pull
+ relay deferred to v3.

## Build order

Twelve steps in dependency order.

| Step | Title                                              | Depends on            |
|------|----------------------------------------------------|-----------------------|
| **1** | Per-actor state buckets in nx_kernel              | M1 closeout           |
| **2** | Actor lifecycle activities (Person/Service/Group) | Step 1                |
| **3** | Key rotation via Update + actor-state projection  | Steps 2, M1 §9.6      |
| **4** | Multi-actor HTTP routing (per-actor outbox/inbox) | Steps 1, M1 8b-start  |
| **5** | POST /inbox: peer signature verify + ingestion    | Steps 3, 4            |
| **6** | Follow lifecycle (Follow / Accept / Reject / Undo) | Step 5                |
| **7** | Audience-resolving delivery set computation       | Step 6                |
| **8** | Outbound delivery queue + retry/backoff           | Step 7                |
| **9** | Backfill modes on Follow accept                   | Steps 6, 8            |
| **10** | Discovery: webfinger + actor doc fetch           | Step 4                |
| **11** | Rich verbs as runtime artifacts (Note, Announce, Endorse) | Step 8        |
| **12** | Two-instance smoke test (`smoke_federate.sh`)    | Steps 1-11            |

Steps 1-3 are the multi-actor foundation. Steps 4-10 are the federation
core. Steps 11-12 close the proof points.

---

## Step 1 — Per-actor state buckets

Today `nx_kernel` holds one actor's state at the top of its property list.
Make it bucketed by ActorId so a single kernel can host any number of
actors.

**Deliverables:**

- [x] **1a** — Pure-functional bucket APIs. State shape becomes
  `[{actors, [{ActorId, ActorBucket}, ...]}, {next_actor_seq, N}]`
  with `ActorBucket = [{key_spec, KS}, {actor_state, AS}, {log, L},
  {projections, [Name]}, {next_published, N}]`. New exports: `new/0`,
  `add_actor/4`, `has_actor/2`, `actors/1`, `actor_count/1`,
  `next_actor_seq/1`, `actor_bucket/2`, `publish/3`, per-actor
  accessors (`actor_log_state/2`, `actor_log_tip/2`, `actor_key_spec/2`,
  `actor_state/2`, `actor_projections/2`, `actor_next_published/2`),
  `with_actor_projections/3`. Legacy single-actor accessors
  (`actor_id/1`, `key_spec/1`, `actor_state/1`, `log_state/1`,
  `log_tip/1`, `projections/1`, `next_published/1`,
  `with_projections/2`, legacy `publish/2`) continue to read from the
  first bucket — every M1 test passes via `bootstrap:start/3` →
  `new/3` → first-bucket lookup. `lists:keymember`/`keyfind` not in
  the substrate; local `has_keyed`/`find_keyed`/`set_keyed`/
  `set_bucket` helpers handle the keyed-list ops.
  `next/tests/nx_kernel_multi.sh` 17/17.
- [x] **1b** — Multi-actor gen_server. `start_link/3` still seeds
  bucket 0; new exports `add_actor/3`, `publish_to/2(ActorId,
  Request)`, `log_tip_for/1`, `actors/0`, `state_for/1`,
  `bucket_for/1`, `with_projections_for/2` delegate to the pure-
  functional bucket APIs via fresh `handle_call` branches. Existing
  `publish/1`/`log_tip/0`/`with_projections/1` route through bucket
  0 unchanged. Per-actor mailbox concurrency (one gen_server per
  bucket so distinct-actor publishes don't serialise) is forward-
  looking — deferred to Step 4 (multi-actor HTTP routing) where it
  actually pays off. `nx_kernel_multi.sh` extended with 9 gen_server
  cases (26 total), every M1 nx_kernel-adjacent + http suite still
  green (134 / 134 across 12 suites).

**Acceptance:** `bash next/tests/nx_kernel_multi.sh` passes 12+ cases.

---

## Step 2 — Actor lifecycle activities

Per design §9.1, an actor is a Person, Service, or Group object,
created by `Create{Person{...}}`. The kernel needs to fold this into
an actor-state projection that downstream code can read for keys,
publicKey rotation history, profile fields, follower counts, etc.

**Deliverables:**

- [x] **2a** — Genesis additions: `DefineObject{Person}` /
  `DefineObject{Service}` / `DefineObject{Group}` — three new SX
  files in `next/genesis/object-types/` plus manifest entries (now
  13 object-types total, 34 total genesis entries). Each defines
  `:name`, `:doc`, `:schema (fn (obj) (string? (-> obj :name)))`.
  `next/tests/genesis_parse.sh` extended +7 cases (head form +
  :name + manifest membership), now 57/57. Bootstrap suite
  count assertions bumped (`bootstrap_read.sh` 15/15,
  `bootstrap_load.sh` 15/15, `bootstrap_populate.sh` 14/14,
  `bootstrap_start.sh` 10/10). `bootstrap_build.sh` 12/12 picks
  up the new bundle CID dynamically.
- [x] **2b** — Actor-state projection fold (Erlang-fun stand-in,
  mirrors Step 5d-pure's `define_registry`). `next/kernel/actor_state.erl`
  with state shape `[{ActorId, Profile}, ...]` where `Profile` is a
  proplist with `:type / :name / :preferredUsername / :summary /
  :icon / :public_keys / :moved_to / :created`. Maps `#{}` aren't
  registered in the substrate, so the profile is a property list
  (same shape choice as the kernel's bucket / registry state).
  Folding rules:
  - `Create{Person|Service|Group}` (from a known `:actor`):
    captures profile fields + `:created` (=`:published` seq).
    Duplicate Creates are no-overwrite.
  - `Update{Person|Service|Group, patch}`: merges `:patch` into the
    profile last-write-wins per key.
  - `Move`: records `:moved_to` on the profile.
  Other activity types and non-actor object Creates pass through.
  `fold_fn/0` plugs into `projection:start_link/3`. Local
  `find_keyed/has_keyed/set_keyed` helpers (same gap as 1a — no
  `lists:keyfind`/`keymember` in the substrate). 19 cases in
  `actor_state_pure.sh`.
- [x] **2c** — `nx_kernel:bootstrap_actor/4(ActorId, Profile,
  KeySpec, State)` — adds an actor bucket and publishes
  `Create{Person|Service|Group}` as the bucket's first activity in
  one call. Profile carries `:type` (defaults to `person`), `:name`,
  `:preferredUsername`, `:summary`, `:icon`, `:public_keys`; the
  function builds the Create's `:object` from the profile and the
  kernel-side AS from `:public_keys`. gen_server variant
  `bootstrap_actor/3` for live-kernel use; integration test in
  `actor_lifecycle.sh` ties 2a artefacts, 2b projection, and 2c
  bootstrap together end-to-end (pure + gen_server + projection
  capture for all three actor types). 15/15.

**Acceptance:** `bash next/tests/actor_lifecycle.sh` passes 10+ cases.

---

## Step 3 — Key rotation via Update + actor-state

Per §9.2: rotation is itself an activity. The actor-state projection
keeps the full key history (with `created` / `superseded_at`) so
`envelope:verify_signature/2` continues to find historical keys when
verifying activities published before the rotation.

**Deliverables:**

- [x] **3** — `actor_state.erl` `fold_update` now routes patches
  through `apply_patch/3`, which special-cases two rotation patch
  entries:
  - `{add_publicKey, KeyProplist}` appends the key to `:public_keys`,
    defaulting `:created` to the activity's `:published` if unset.
  - `{supersede, OldKeyId}` marks the matching key with
    `:superseded_at` = activity's `:published` (idempotent: existing
    `:superseded_at` is preserved; unknown ids are no-ops).
  Other patch entries fall through to last-write-wins per key
  (preserving Step 2b semantics; verified by extra
  `actor_state_pure.sh` cases).
  New exports `key_history/1` (full list incl. superseded entries),
  `active_keys_at/2` (subset active at time T, mirroring envelope's
  `is_active_at` semantics — envelope keeps its predicate private,
  so a local copy lives here), and `find_key_by_id/2`.
  Rotation-purpose schema gating per §9.6 ("rotation activity must
  itself be signed by an active key with `rotate-key` purpose") is
  deferred to Step 5 (peer-side `stage_signature` will plumb the
  purpose check through pipeline). 16 cases in `key_rotation.sh`
  cover rotation arithmetic, `key_history` preservation, and live
  `envelope:verify_signature/2` round-trips for pre / post / mid
  rotation activities — including the negative case (post-rotation
  K1-signed activity returns `{error, no_active_key}`).

**Acceptance:** `bash next/tests/key_rotation.sh` passes 12+ cases.

---

## Step 4 — Multi-actor HTTP routing

Per-actor URLs per design §16.1:

```
GET  /actors/<id>                  # actor doc
GET  /actors/<id>/outbox           # OrderedCollection
GET  /actors/<id>/outbox?page=N    # page
POST /actors/<id>/inbox            # peer delivery to this actor
GET  /actors/<id>/followers        # follower list
GET  /actors/<id>/following        # following list
POST /activity                     # authenticated publisher API (existing)
```

`POST /activity` still picks the publishing actor from the bearer
token; the token now maps to an `:actor_id` rather than a fixed `alice`.

**Deliverables:**

- [x] **4a** — Per-actor URL routing. New `split_first_slash/1`
  helper splits the `/actors/` suffix into `{Id, SubPath}`.
  GET dispatch routes `outbox` / `inbox` / `followers` / `following`
  sub-paths to four new content-negotiated response functions
  (`actor_outbox_response_for/2`, `actor_inbox_get_response_for/2`,
  `actor_followers_response_for/2`,
  `actor_following_response_for/2`) — text / json / activity_json /
  sx variants per existing format pattern. POST dispatch routes
  `inbox` to a 202 Accepted stub (`actor_inbox_post_response/0` +
  `accepted_response/1`). Unknown sub-paths under `/actors/<id>/`
  return 404. Bare `/actors/<id>` keeps the M1 `actor_doc_response_for`
  arm. 17 cases in `http_multi_actor.sh`.
- [x] **4b** — Token → ActorId map. New `resolve_token/2` reads
  `:tokens` from Cfg (proplist `[{Token, ActorId}, ...]`) and
  returns `{ok, ActorId}` on match. Falls back to the M1
  `:publish_token` single-token field on miss (returns
  `{ok, legacy}`, route through `nx_kernel:publish/1` to bucket 0
  unchanged). Cfg with both fields: `:tokens` wins for matched
  tokens; `:publish_token` only consulted on `:tokens` miss.
  `handle_post_activity` now threads the resolved `ActorRef` to
  `publish_if_kernel/3` which dispatches `publish_to/2` for
  explicit actor ids and `publish/1` for the `legacy` atom.
  No-kernel auth-only path unchanged. The dead M1
  `expected_token/1` helper is gone. 8 new cases in
  `http_multi_actor.sh` (25/25 total).
- [x] **4c** — `http_server:route/3(Req, Cfg, Kernel)` is sugar
  that folds the Kernel reference (typically the registered
  `nx_kernel` atom) into Cfg as `{kernel, Kernel}`. The dispatch
  chain gained a Cfg arg threaded all the way to per-actor
  sub-resource handlers (`dispatch/3` → `dispatch/4`, `actor_get/2`
  → `actor_get/3`, `actor_subresource_get/3` → /4). The outbox
  sub-resource handler now reads `:kernel` and, when the actor
  exists in the kernel, renders `tip: <N>` in text / JSON / SX
  variants — proving the plumbing works end-to-end. Unknown
  actors or unregistered kernels fall back to the 4a stub.
  `try`/`of`/`catch` around `gen_server:call` deadlocks in this
  port's scheduler (probably the catch-frame mask defers reply
  delivery); the live handler does a bare `nx_kernel:log_tip_for/1`
  + integer guard instead. 8 new cases in `http_multi_actor.sh`
  (33/33 total).
- [x] **4d** — Per-actor outbox listing reads from the named
  bucket's log entries via new `nx_kernel:log_state_for/1`
  gen_server export. `actor_outbox_full_response_for/5` renders
  text / JSON / SX bodies with `:tip`, `:page`, and the page's
  `:items` CID list. Empty pages degrade to the 4c tip-only body
  to preserve back-compat with epochs 50-57. `?page=N` pagination
  parsed at `route/2` time and threaded via Cfg as
  `{request_query, Q}`; `page_size/0` returns 5 (proof of concept
  — production picks 20+). 8 new cases in `http_multi_actor.sh`
  (41/41 total). Substrate gotcha: named recursive funs
  `fun F(...) -> ... F(...) end` not supported; `binary:matches/2`
  and `lists:foreach/2` not registered — tests prove behaviour
  via `match_prefix` substring checks rather than counting.
- [x] **4e** — POST /actors/<id>/inbox stays the 4a 202 stub
  through 4a-4d; the real ingestion pipeline (sig verify + inbox-
  bucket append + projection broadcast) is Step 5's whole topic.
  No code change for this checkbox — it's a deliberate scope
  boundary so 4d's listing semantics land cleanly before
  inbound traffic shapes the same per-actor URLs.

**Acceptance:** `bash next/tests/http_multi_actor.sh` passes 14+ cases.

---

## Step 5 — POST /inbox: signature verify + ingestion

The receiving side of federation. A peer instance POSTs a signed activity
to `/actors/<id>/inbox`; the kernel verifies the signature, runs the
inbound validation pipeline, appends to the receiving actor's log
(separate from outbox — the inbox is its own log for activities the
actor *received*), and broadcasts to projections.

**Deliverables:**

- [x] **5a** — Per-actor `:actor_inbox` log bucket in nx_kernel.
  `add_actor/4` now opens a fresh inbox log (distinct base stub) for
  each new actor; the bucket carries `[..., {actor_inbox, LogState}, ...]`
  alongside the existing `:log` outbox field. Pure-functional
  exports: `actor_inbox_state/2`, `actor_inbox_tip/2`,
  `append_to_actor_inbox/3`. gen_server exports: `inbox_tip_for/1`,
  `inbox_state_for/1`, `append_inbox/2`. Inbox and outbox tips are
  fully independent (appending to one doesn't touch the other).
  `next/tests/inbox_bucket.sh` 14/14. Signature verification +
  pipeline gating live in 5b.
- [x] **5b** — Inbound validation pipeline. New
  `pipeline:validate_inbound/3(Activity, PeerActorState, InboxLog)`
  runs the federation inbound stage list — `stage_envelope` →
  `stage_signature(PeerAS)` → `stage_replay(InboxLog)` — halting
  on the first failure. New helper `inbound_stages/2(PeerAS, InboxLog)`
  exposes the stage list for callers that want to splice extra
  stages. Existing `validate_inbound/1` and the static
  `inbound_stages/0` (envelope-only) stay untouched so outbox-side
  callers don't have to re-key on a peer-AS they don't have. Sig
  verification uses the peer's actor-state `:public_keys`, NOT the
  local kernel's; peer-AS resolution is the caller's responsibility
  (Step 5c wires the cache lookup). 14 cases in
  `inbox_pipeline.sh`: happy path, bad shape, missing :signature
  (rejected by stage_envelope before sig runs), wrong peer AS
  (bad_signature), replay against inbox, distinct activities both
  verify, stage short-circuit ordering verified.
- [x] **5c** — Peer-actors cache (`peer_actors.erl`). State shape
  `[{PeerActorId, PeerActorState}, ...]` keyed by atom; PeerAS is
  exactly the shape `envelope:verify_signature/2` reads (proplist
  with `:public_keys`). Pure exports: `new/0`, `lookup/2`,
  `store/3`, `evict/2`, `peers/1`, and the load-bearing
  `lookup_or_fetch/3(PeerId, FetchFn, State)` that calls the
  caller-supplied `FetchFn :: (PeerId) -> {ok, PeerAS} | {error, _}`
  on miss and stores the successful result. Failed fetches do NOT
  poison the cache so callers can retry on transient errors.
  gen_server wrapper: `start_link/0,1`, `lookup_srv/1`,
  `store_srv/2`, `lookup_or_fetch_srv/2`, `peers_srv/0`,
  `evict_srv/1`. `start_link/1` accepts an initial state proplist
  for tests / fixtures. 19/19 in `peer_actors.sh`. The actual
  fetch implementation (HTTP GET of the peer's actor doc) is
  Step 5d's responsibility — for 5c, FetchFn is just a contract.
- [x] **5d** — http_server inbox handler wires the chain. POST
  /actors/<id>/inbox is now special-cased in `route/2` (next to
  POST /activity) so the body + full Cfg reach the handler. New
  `handle_inbox_post/3` orchestrates: `kernel_has_actor` →
  `decode_activity` (term_codec wire format) → `resolve_peer_as`
  (Cfg `:peer_as` map > `:peer_actors` srv > `:peer_fetch_fn`
  fallback) → `pipeline:validate_inbound/3` → `nx_kernel:append_inbox`.
  Status codes:
  - 202 Accepted on pipeline ok + inbox append
  - 401 Unauthorized on bad_signature / no_signature / unknown
    peer / fetch error
  - 404 Not Found on unknown target actor
  - 422 Unprocessable on shape / decode / replay failure
  v1 stub `actor_post/1` removed; the route/2 special case
  supersedes it. M1 `actor_inbox_post_response/0` kept for
  callers that need to compose the response shape.
  Projection broadcast on success is intentionally deferred —
  the same TODO covers outbox broadcast invariance and lands in
  a follow-up sub-deliverable. `inbox.sh` 11/11 covers happy
  path / shape / sig / replay / unknown-target / multi-message;
  `inbox_peer_resolution.sh` 6/6 covers the four peer-AS
  resolution paths. Tests split into two files because the
  cumulative cost of one kernel start_link per epoch pushed a
  single suite past the wall-clock budget.

**Acceptance:** `bash next/tests/inbox.sh` passes 16+ cases.

---

## Step 6 — Follow lifecycle

Per §13.2:

```sx
(activity 'Follow                            ;; from A → B
  :object actor-id-B
  :to (list actor-id-B))
```

B responds with `Accept` (or `Reject`); A's follower-graph projection
tracks the state. `Undo{Follow}` reverses it.

**Deliverables:**

- [x] **6a** — `follower_graph.erl` Erlang-fun stand-in for the
  genesis `follower-graph.sx` projection body. State shape is a
  property-list keyed by ActorId (maps `#{}` not in substrate),
  each entry carries `{following, followers, pending_outbound,
  pending_inbound}` lists. Fold rules:
  - `Follow{actor: A, object: B}` — A → pending_outbound(B);
    B → pending_inbound(A).
  - `Accept{actor: B, object: F=Follow{A→B}}` — A → following(B)
    on A's bucket; B → followers(A) on B's bucket; pendings cleared.
  - `Reject{actor: B, object: F}` — pendings cleared, no promote.
  - `Undo{actor: A, object: F}` — drops A↔B from every list; only
    F's original actor can Undo (carol can't Undo F{A→B}).
  Self-follows are no-ops; duplicate Follows are idempotent;
  Accept/Reject/Undo of non-Follow `:object`s pass through.
  18 cases in `follower_graph.sh`. The `fold_fn/0` 2-arity fun
  plugs into `projection:start_link/3` exactly like
  `define_registry:fold_fn/0` and `actor_state:fold_fn/0`.
- [x] **6b** — Wire follower-graph fold to the inbox handler.
  `http_server.erl` `run_inbox_pipeline` now calls
  `broadcast_to_inbox_projections/2` after a successful
  `nx_kernel:append_inbox`. Cfg may carry `{inbox_projections,
  [Name, ...]}` listing projection gen_servers; each gets the
  activity via `projection:async_fold/2` (fire-and-forget so the
  handler doesn't block on fold processing). Field absent =
  no-op. v2 leaves the routing field global; per-actor
  projection wiring is a forward-looking follow-up. 9/9 in
  `follow_lifecycle.sh` covering 202 ingestion, follower_graph
  pending-state mutation on both sides, no-inbox_projections
  no-op path, bad-sig short-circuit (projection stays clean),
  multi-peer accumulation, end-to-end Follow+Accept projection
  convergence (Accept fed in via projection:async_fold for v2).
- [x] **6c** — Auto-Accept publish. New `maybe_auto_accept/3` in
  `http_server.erl` fires after a successful inbox ingestion if
  Cfg carries `{auto_accept_follows, true}` AND the activity's
  `:type` is `follow`. The handler constructs an
  `Accept{actor: target, object: OriginalFollow}` request and
  routes it through `nx_kernel:publish_to/2`, which goes through
  the full outbox pipeline (envelope construct + HMAC sign + log
  append + outbox projection broadcast). When the target's
  outbox `:projections` list includes the same follower_graph
  projection the inbox uses, the Accept fold-converges the
  bilateral relationship — `alice.followers = [bob]` and
  `bob.following = [alice]` — without any test scaffolding.
  Default is off; manual-moderation deployments leave the flag
  unset. Bad-sig / non-Follow ingestion short-circuits before
  the Accept attempt. 9/9 in `auto_accept.sh`.

**Acceptance:** `bash next/tests/follow_lifecycle.sh` passes 14+ cases.

---

## Step 7 — Audience-resolving delivery set

For each outbound activity, compute the set of inbox URLs to POST to.
Sources: explicit `:to` + `:cc` recipients, plus `Public` / `Followers`
expansion via the audience predicates from M1's genesis bundle.

**Deliverables:**

- [x] **7a** — `delivery:delivery_set/2,3` returns the
  audience-resolved deduplicated list of ActorId atoms for an
  outbound activity. Sources: explicit `:to` and `:cc` fields
  (atom or list of atoms / audience symbols), plus expansion of
  `followers` (via follower_graph) and `public` (v2 placeholder
  — Step 7c). Self-delivery is suppressed every time the
  sender's ActorId appears in the set. Returns are ActorId
  atoms for now; Step 8 will resolve each entry to
  `{PeerInstanceUrl, ActorId}` via the peer-actors cache. 17
  cases in `delivery_set.sh` covering empty / single / list /
  cc-union / self-suppress / dedup / followers-expand /
  public-empty / mixed audience / collect_recipients +
  suppress_self + dedup helpers + expand_audience pass-through.
  Module lives in `next/kernel/delivery.erl` (separate from
  outbox so Step 8's delivery-queue gen_server has a clean home).
- [x] **7b** — Public audience expansion. v2 default: `public`
  expands to the sender's followers (same as `followers`) per
  design §13.4 — the practical fan-out for an open social
  network is "every follower of the publishing actor". The
  explicit shared-inbox peer-instance model (Mastodon-style
  per-instance broadcast) defers to v3 when there's a real
  known-peer-instance registry to drive it. `public + followers`
  in the same audience deduplicates because both symbols
  expand identically. 19/19 in `delivery_set.sh` (2 new cases
  + 1 case updated from the v2 placeholder behavior).
- [x] **7c** — Outbox-side integration. `outbox:publish/2`
  now computes the delivery set after sign + log and stashes it
  in the Result proplist as `{delivery_set, [ActorId, ...]}`.
  Context's optional `:follower_graph` field carries a
  follower_graph state for `public` / `followers` audience
  expansion; absent -> empty graph (explicit `:to`/`:cc`
  recipients still resolve). New helper
  `compute_delivery_set/3(Request, Signed, Context)` and
  `recipients_envelope/2` synthesise a minimal recipient
  envelope from Request's `:to`/`:cc` + Signed's `:actor` so
  `delivery:delivery_set/3` can process it unchanged
  (outbox:construct/4 doesn't carry `:to`/`:cc` through the
  envelope shape, and changing that surface would ripple to
  every existing envelope test). Step 8's delivery-queue
  worker will read `{delivery_set, [ActorId, ...]}` off the
  publish result. 17/17 in `outbox_publish.sh` (+4 new cases:
  empty-default, explicit-:to, followers-symbol-via-graph,
  self-suppression). Module load chain rebumped from epoch 5
  to epoch 7 (adds follower_graph + delivery as dependencies)
  and the test's internal sx_server timeout bumped 240s →
  480s to fit the larger module set.

---

## Step 8 — Outbound delivery queue

Per §13.4: every queued delivery has retry semantics. v2 uses one
gen_server-per-peer-instance worker holding a small queue. Failures
back off exponentially; permanent failures (HTTP 410, bad TLS) move to
a dead-letter list visible via `/admin/dead-letter`.

**Deliverables:**

- [x] **8a** — `delivery_worker.erl` skeleton: pure-functional
  state shape `[{peer, _}, {pending, [_]}, {attempts, [{Cid, N}]},
  {dead_letter, [_]}, {dispatch_fn, _}]` plus
  `enqueue_pure/3`, `drain_pure/1`, `deliver_one_pure/2` and the
  backoff schedule (`backoff_for/1`, `schedule_for/1`) matching
  §13.4 (30s / 5m / 30m / 6h / 24h then dead-letter).
  gen_server wrapper with `start_link/1,2`, `enqueue/2`, `flush/1`,
  `pending_srv/1`, `set_dispatch_fn/2`. dispatch_fn is a
  caller-supplied 1-arity fun so tests can stub the HTTP POST;
  Step 8f plugs in the live httpc call without touching the
  queue logic. No actual HTTP yet; no retry timer wiring yet.
  17/17 in `delivery_worker.sh`.
- [x] **8b-pure** — Retry-time bookkeeping (pure-functional).
  State shape gains `{next_retry, [{Cid, NextRetryAt}]}` alongside
  the existing `:attempts`. New exports:
  `record_failure_pure/3(Cid, Now, State)`,
  `record_success_pure/2(Cid, State)`,
  `next_due_pure/2(Now, State)`, `attempts_for/2`,
  `next_retry_at/2`, `dead_letter_list/1`.
  `record_failure_pure` bumps the attempt counter and computes
  `Now + backoff_for(NewAttempts)` as the next retry; on the 6th
  failure (`backoff_for` returns `dead_letter`) the matching
  activity moves from `:pending` to `:dead_letter` and the cid
  is cleared from `:next_retry`. `record_success_pure` clears
  both. `next_due_pure` returns cids whose retry time has
  passed. 11 cases in `delivery_retry.sh`.
- [x] **8b-timer** — Erlang-side timer wiring on the
  `delivery_worker` gen_server. handle_call(flush) drains then
  arms a `send_after` self-cast per retried Cid (backoff from
  the now-bumped attempt counter); handle_info({retry, Cid})
  redrives that single Cid through deliver_one_pure. Success
  clears bookkeeping via record_success; failure bumps attempts
  via record_failure_pure and arms the next backoff slot — or
  promotes to dead-letter on the 6th attempt and stops arming.
  A `:timers [{Cid, Ref}]` state field tracks live refs so
  schedule_retry_for can cancel the previous one before arming
  the next (otherwise stale timers keep the scheduler's run
  loop alive long after the work is done). 5/5 in
  `delivery_retry_timer.sh`: T1 timer scheduled, T2 attempts=1,
  T3 retry fires + attempts=2, T4 next timer rearmed, T5 ets-
  counter dispatch (fail/fail/ok) lands in 3 attempts and
  clears state. Substrate dependency landed via cherry-pick
  from `loops/erlang` (3709460d / 98b0104c / 779e53b2) until
  `loops/erlang` → architecture catches up.
- [x] **8c** — Delivery-state projection
  (`next/kernel/delivery_state.erl`). Folds delivery events into
  per-peer worker-shaped snapshots so the outbound queue survives
  kernel restart. Event shapes:
  `[{type, enqueued|delivered|failed|dead_lettered}, {peer, _},
  {activity, _} | {cid, _}, {now, _}?]`. State shape
  `[{PeerId, WorkerProplist}, ...]` mirrors `delivery_worker:new/1`'s
  output so a fresh gen_server can be hydrated on restart. Public
  API: `new/0`, `fold/2`, `fold_fn/0`, `peer_state/2`, `peers/1`,
  per-field accessors (`pending`, `attempts`, `next_retry`,
  `dead_letter`). Uses `delivery_worker:backoff_for/1` to decide
  dead-letter promotion on the 6th failure, so the projection
  and the live worker stay in lockstep. 14/14 in
  `delivery_state.sh`. The restart-hydration helper
  (`delivery_worker:state_from_proj/2` or similar) lands when
  8b-timer wires the live retry loop.
- [x] **8d** — `outbox:publish/2` dispatches each delivery-set
  entry to the matching worker. New `dispatch_deliveries/3` +
  `enqueue_each/2` in `outbox.erl` walk the computed
  `delivery_set` and call `delivery_worker:enqueue(PeerId,
  Activity)` for each registered peer atom. Missing workers
  (no `whereis`) are silently skipped — lazy worker creation
  belongs to the kernel manager (Step 8d-mgr or later).
  Gated by `Context` field `{dispatch_deliveries, true}` so
  every M1 outbox caller stays back-compat (default off). 7/7
  in `delivery_dispatch.sh` covering single-peer enqueue,
  two-peer fan-out, missing-worker skip, no-flag no-op,
  FIFO append across two publishes, empty delivery_set no-op.
- [x] **8e** — `httpc:request/4` BIF wrapper. ~~Blocker~~ resolved:
  loops/fed-prims merged into architecture, native `http-request`
  primitive available. Wrapper at `lib/erlang/runtime.sx`
  (briefing-allowed-exception scope) marshals Erlang
  `(Url::binary, Method::atom|binary, Headers::proplist, Body::binary)`
  → SX `(http-request method url headers body)` → Erlang
  `{ok, Status::integer, Headers::proplist, Body::binary}`.
  Atom methods are upcased (`get` → `"GET"`) for HTTP-wire convention;
  binaries pass through verbatim. Test: `next/tests/httpc_request.sh`
  10/10 pass — registration, badarg validation, live GET 200,
  body bytes match, headers proplist shape, 404 surfaces as ok-tuple,
  binary method works.
- [x] **8f** — Real HTTP dispatch through the BIF + content-type
  wiring. New `dispatch_http.erl` builds a 1-arity closure suitable
  for `delivery_worker:set_dispatch_fn/2`: encodes the activity
  with `term_codec:encode/1`, sets `content-type:
  application/vnd.fed-sx.activity`, POSTs to
  `<base>/actors/<peer>/inbox` via `httpc:request/4`, and maps the
  result to `ok` (2xx) / `{error, {status, N}}` (non-2xx) /
  `{error, Reason}` (transport). Peer URL resolution composes:
  static `:peer_url` proplist, then `:peer_url_fn` closure
  (Step 10c will plumb the latter). BIF wrapper updated to
  catch host errors via SX `guard` and re-raise as Erlang
  `error:{network, ReasonBinary}` so dispatch_http's try/catch
  can map them. Test: `next/tests/dispatch_http.sh` 10/10 —
  inbox_url construction, both peer-resolver paths,
  hit/miss/closed-port outcomes, delivery_worker drain via
  the live closure.

**Tests:**

- Successful delivery → worker queue empties.
- Failed delivery → backoff schedule respected.
- Dead-letter after max attempts.
- Cross-restart: queue restored from delivery-state projection.
- Concurrent deliveries to multiple peers don't serialise.

**Acceptance:** `bash next/tests/delivery_queue.sh` passes 16+ cases.

---

## Step 9 — Backfill on Follow accept

Per §13.3: A wants B's history when A first follows B. Four modes:

| Mode      | Behavior                                    |
|-----------|---------------------------------------------|
| `none`    | New follower sees only forward-going content |
| `last-N`  | Backfill last N activities                  |
| `last-T`  | Backfill last T duration of activities      |
| `full`    | Backfill entire outbox                      |

**Deliverables:**

- [x] **9a** — Pure-functional backfill slicing in
  `next/kernel/backfill.erl`:
  - `slice/2,3(Mode, LogState[, Wrap])` returns the entry list
    for a given mode. Wrap=true marks each entry
    `{backfilled, true}` so receiving projections can decide
    whether to re-fold or skip (per §13.3, wrapped bodies
    preserve `:id` so replay defence still catches duplicates).
  - Modes: `none`, `full`, `{last_n, N}`, `{last_t, T, NowFn}`,
    `{since_cid, Cid}`. NowFn is a 0-arity fun so tests can
    fake-time it.
  - `parse_mode/1` lifts the Follow activity's `:backfill`
    value (atom or proplist) into the internal mode tuple;
    unknown shapes degrade to `none` (open-world default).
  Substrate gotchas re-confirmed:
  `lists:nthtail/2` not in this port (rolled `drop_n/2`);
  pattern-alias `Pat = Var` not supported (rewrote
  `parse_mode/1` clauses with explicit deconstruction).
  20/20 in `backfill.sh` covering all 5 modes (with edge
  cases: N=0, N>length, T=0, since_cid hit/miss/unknown),
  wrap_backfill, parse_mode atoms / tuples / proplists /
  unknown.
- [x] **9b** — `GET /actors/<id>/outbox?since=Cid` pagination
  route. The Step 4d outbox handler in `http_server.erl`
  (`actor_outbox_response_for/3`) now reads `?since=` from the
  query string via new `parse_since/1` + `scan_param/2,3` +
  `skip_to_amp/1` (handles `since=X&page=2` and `page=2&since=X`
  identically), pre-filters entries via
  `backfill:since_cid_entries/2`, then runs the existing page
  slice on the filtered list. `?since=unknown` → empty page →
  body degrades to the tip-only shape (Step 4d back-compat).
  3 new cases in `http_multi_actor.sh` (44/44 total) — exercise
  filtering, unknown-cid, combined `?since= + ?page=`. Also
  added `follower_graph` + `delivery` + `backfill` module loads
  to `http_multi_actor.sh` (downstream dependency since Step
  7c/9a — must have been latently broken; the existing 41
  passes + 3 new = 44 now all green).
- [x] **9c** — Follow → Accept → backfill drain (in-process).
  `maybe_auto_accept/3` in `http_server.erl` now calls a new
  `maybe_backfill/3` after the Accept publish: when Cfg carries
  `{backfill_enabled, true}` AND the Follow envelope carries a
  `:backfill` field, the receiver parses the mode via
  `backfill:parse_mode/1`, slices its outbox via
  `backfill:slice/3` (Wrap=true so each entry gets
  `{backfilled, true}`), and enqueues every slice entry onto
  the peer's delivery_worker if registered (silently skipped
  otherwise — kernel manager lazy creation belongs upstream).
  6/6 in `backfill_drain.sh` covering full path + entry marker
  + flag-off no-op + missing-backfill-field no-op + missing-
  worker silent skip. The live HTTP dispatch of those queued
  entries still gates on Blockers #2 (httpc).

**Tests:**

- `last-N` mode delivers exactly N most-recent activities.
- `last-T` mode delivers everything published since `now - T`.
- `full` mode delivers everything, page by page.
- `none` mode delivers nothing.
- Backfilled activities preserve original `:id` (CID).

**Acceptance:** `bash next/tests/backfill.sh` passes 12+ cases.

---

## Step 10 — Discovery

Per §13.7: webfinger plus actor doc fetch.

**Deliverables:**

- [x] **10a** — Local-side discovery primitives in
  `next/kernel/discovery.erl`:
  - `parse_acct/1(<<"acct:user@host">>)` and
    `parse_acct/1(<<"user@host">>)` (prefix optional) return
    `{ok, User, Host}` or `{error, _}`. Reject empty user/host
    and missing `@`. Host preserves an optional `:port` suffix.
  - `parse_resource/1` is an alias for the webfinger query
    parameter shape.
  - `actor_url_for/2(User, Host)` synthesises
    `http://<host>/actors/<user>` (TLS / https is v3, gated by
    a TLS substrate Blocker).
  - `webfinger_body/3(User, Host, ActorUrl)` builds the RFC 7033
    JSON body with `:subject` + `:links[]` carrying
    `rel: self / type: application/activity+json / href`.
  Hand-rolled byte concatenation — no JSON BIF on this port.
  `<<"...">>` string-literal segments truncate to one byte on
  this port (briefing gotcha re-confirmed), so `"acct:"` is
  spelled as `<<97,99,99,116,58>>`. 12/12 in `discovery.sh`.
- [x] **10b** — http_server route
  `GET /.well-known/webfinger?resource=acct:user@host`. New
  dispatch arm next to `/.well-known/sx-capabilities` calls
  `handle_webfinger/1(Cfg)`, which reads `:request_query` from
  Cfg (threaded by route/2 from the Req's `:query` field per
  Step 4d), parses the `resource=` param via
  `parse_resource_param/1` + `take_until_amp/1`, hands off to
  `discovery:parse_acct/1`, then to `webfinger_lookup/3`:
  - Optional Cfg `:webfinger_host` (binary) — when set, the
    acct's `@host` must match exactly; missing accepts any.
  - Optional Cfg `:kernel` (atom, per Step 4c) — uses
    `kernel_has_actor/2` to verify the actor exists. When no
    kernel cfg'd (pure route tests), every user is "known".
  - Match → 200 + `discovery:webfinger_body/3` rendered as
    `application/activity+json`; miss → 404.
  10/10 in `webfinger_route.sh` covering happy paths
  (no-kernel, with-kernel, host-match), 404 paths
  (missing-resource, bad-acct, unknown-actor, host-mismatch,
  wrong-method).
- [x] **10c** — Peer-actor fetch + cache write. New
  `discovery_fetch.erl` produces a 1-arity FetchFn closure
  suitable for `peer_actors:lookup_or_fetch_srv/2`: GETs
  `<base>/actors/<peer>` with
  `Accept: application/vnd.fed-sx.actor-doc`, decodes the body
  via `term_codec:decode/1`, and returns `{ok, AS}` where AS is
  the peer's `[{public_keys, [...]}]` proplist
  (`envelope:verify_signature` shape). Cfg reuses the same
  `:peer_url` / `:peer_url_fn` resolution as `dispatch_http`.
  Server side: http_server now serves the same MIME — new
  `actor_doc` content-negotiation atom, `actor_doc_response_for/3`
  kernel-aware arm calls `nx_kernel:state_for/1` and emits the
  `term_codec:encode/1` of the AS. Test:
  `next/tests/discovery_fetch.sh` 11/11 — Accept negotiation,
  server-side encode (with kernel) → 200 + decodable body,
  unknown actor → 404, URL construction, live fetch +
  decode, closure resolution (static map + closure peer
  resolver), missing peer → `no_peer_url`, 404 → `{status, 404}`,
  end-to-end `peer_actors:lookup_or_fetch` cache write.

**Tests:**

- Webfinger for known actor → 200 with `links[].href`.
- Webfinger for unknown → 404.
- Cross-instance: A resolves an acct on B → fetch succeeds.
- Actor-doc fetch caches the result.
- Cache invalidation on key rotation (v3 — for now, no TTL).

**Acceptance:** `bash next/tests/discovery.sh` passes 12+ cases.

---

## Step 11 — Rich verbs as runtime artifacts

Per the verb-extensibility proof point (M1 §9a), new verbs land as
`DefineActivity` artifacts published into the genesis-equivalent boot
log, not as kernel code changes. v2 adds:

| Verb    | Object shape                          | Use case                              |
|---------|---------------------------------------|---------------------------------------|
| `Note`  | `{content, tags?}`                    | Short authored message                |
| `Announce` | `{object: <ActivityCid>}`          | Propagate a peer's activity to followers |
| `Endorse` | `{object: <Cid>, kind: like|share}` | Cross-actor signaling                 |

Announce is the critical one for federation — it lets one actor
re-broadcast another actor's content to their own followers.

**Deliverables:**

- [x] **11b** — Projection folds for the new verbs.
  - `next/kernel/announce_state.erl`: tracks per-Cid announcer
    set. Public API `new/0`, `fold/2`, `fold_fn/0`,
    `announcers_for/2`, `announce_count/2`,
    `announced_cids/1`, `has_announced/3`. Set semantics
    (duplicate Announce by same actor is a no-op).
  - `next/kernel/endorsement_state.erl`: tracks per-Cid +
    per-kind + per-actor endorsement counters. Public API
    `new/0`, `fold/2`, `fold_fn/0`, `counters_for/2`,
    `total_for/2`, `kinds_for/2`, `endorsers_for/3`,
    `has_endorsed/4`. Additive semantics (re-endorse by same
    actor under same kind bumps the counter; Undo{Endorse}
    semantics defer to a follow-up).
  Both `fold_fn/0`s plug into `projection:start_link/3`. 19/19
  in `rich_verbs.sh` covering happy paths + predicates + non-
  matching-activity pass-through.
- [x] **11a** — Announce + Endorse genesis activity-types
  (Note already exists as an object-type from M1 — Create{Note}
  is the publish path). Two new `DefineActivity` SX files in
  `next/genesis/activity-types/` with `:name`, `:doc`,
  `:schema` (Announce: `:object` must be a string CID; Endorse:
  `:object` and `:kind` must both be strings). Manifest updated
  to 5 activity-types / 36 total entries. Hardcoded count
  assertions bumped in `bootstrap_read.sh`, `bootstrap_load.sh`,
  `bootstrap_populate.sh`, `bootstrap_start.sh`. `genesis_parse.sh`
  +4 cases for the two new files (head form + name).
- Each is shipped to a fresh instance via a bootstrap manifest entry
  *or* published as the first activity on the actor's outbox; either
  works because of the verb-extensibility mechanism.
- Announce-specific delivery: the announced activity's CID is included
  in the Announce; followers can re-fetch the referenced activity from
  the original instance if their projection wants to fold the body.

**Tests:**

- Define + publish Note works end-to-end.
- Define + publish Announce wraps another activity by CID.
- Announce delivery: A announces B's Note; A's followers see the
  Announce; their `feed` projection optionally fetches the wrapped Note.
- Endorse increments an endorsement counter on the target Activity.
- Verb registration is observable in the `define-registry` projection.

**Acceptance:** `bash next/tests/rich_verbs.sh` passes 14+ cases.

---

## Step 12 — Two-instance smoke test

**Blockers #4 RESOLVED 2026-06-07.** The substrate fix turned out
to be a two-line change in `lib/erlang/runtime.sx`: extend
`er-sched-step-alive!` to read `:pending-args` when present (was
hardcoded to `(list)`), and have `er-bif-http-listen`'s sx-handler
spawn the user handler as a real er-process with `:pending-args
(list req-pl)` instead of calling it inline. With this in place
any `receive` inside a kernel-aware route (e.g. `gen_server:call`)
suspends and resumes correctly inside the SX scheduler instead of
propagating out of the connection thread.

- [x] **12** — Two-instance smoke test. Both halves landed
  2026-06-07.
  - `next/tests/smoke_kernel_route.sh` (6/6, single-instance):
    welcome `/`, `/actors/alice`, `/actors/alice/outbox`
    (gen_server-backed `tip:`), `/actors/alice/inbox`,
    unknown-actor — all over real HTTP via
    `http_server:start(P, [{kernel, nx_kernel}])`. Proves
    Blockers #4 doesn't regress.
  - `next/tests/smoke_federate.sh` (6/6, two-instance):
    boots A + B on distinct ephemeral ports with pre-populated
    cross-`:peer_as`, builds a real `outbox:construct(follow,
    alice, 1, bob)` + `outbox:sign` envelope via a third
    sx_server subprocess, POSTs the term_codec-encoded bytes
    into B's `/actors/bob/inbox` over real HTTP, asserts B
    returns 202 (pipeline validated the signature against the
    pre-populated alice peer-AS) and bob's outbox tip advances
    0 → 1 (auto-accept publish landed). This is m2's proof
    point — every layer (8e BIF + 8f dispatch_http + 10c
    discovery_fetch + Blockers #1 marshaller bridge + #4
    pending-args scheduler fix) under real cross-instance HTTP
    load.

Step 12's plan body below describes the FULL flow (Step 13
restart-survives-state etc.); the m2 acceptance criterion is the
above 6/6 cross-instance pass, which proves the wiring is
correct. Step 8b-timer (the retry loop) is still gated on
Blockers #3 send_after — synchronous-drain semantics work
for the smoke test, but the production retry schedule needs
the timer primitive.

**The proof point.** `next/tests/smoke_federate.sh` spins up two kernel
instances on distinct ports, walks them through the full federation
flow, and exits 0.

**Test outline:**

```bash
# 0. Start two instances: A on 9999, B on 9998
./next/scripts/start_pair.sh

# 1. Bootstrap two actors: alice@A, bob@B
curl -X POST :9999/activity \
  -H "Authorization: Bearer $TOKEN_A" \
  -d '{"type":"Create","object":{"type":"Person","name":"alice"}}'

curl -X POST :9998/activity \
  -H "Authorization: Bearer $TOKEN_B" \
  -d '{"type":"Create","object":{"type":"Person","name":"bob"}}'

# 2. alice@A discovers bob@B via webfinger
curl :9999/.well-known/webfinger?resource=acct:bob@localhost:9998

# 3. alice follows bob
curl -X POST :9999/activity \
  -d '{"type":"Follow","object":"http://localhost:9998/actors/bob"}'

# 4. Expect alice's follower-graph: pending_outbound includes bob
curl :9999/actors/alice/following | jq -e '.[] | select(.id == "bob")'

# 5. Expect bob auto-accepts; alice's pending_outbound clears
sleep 1
curl :9999/actors/alice/following | jq -e '.[] | select(.id == "bob")'

# 6. bob publishes a Note
curl -X POST :9998/activity -d '{"type":"Create","object":{"type":"Note","content":"hi"}}'

# 7. alice's inbox receives the Note
sleep 1
curl :9999/actors/alice/inbox?page=true | jq -e '.orderedItems[] | .type == "Create" and .object.type == "Note"'

# 8. alice's actor-state projection has the new Note
curl :9999/projections/feed | jq -e ". | length > 0"

# 9. Key rotation: bob rotates keys
curl -X POST :9998/activity -d '{"type":"Update","object":"bob","patch":{...}}'

# 10. alice still verifies older Notes against the old key
#     (via actor-state's key history)

# 11. Announce: alice announces bob's Note
curl -X POST :9999/activity -d '{"type":"Announce","object":"<bob-note-cid>"}'

# 12. Verify Announce delivers to alice's followers (zero in v1 but
#     the activity should be in alice's outbox)

# 13. Shutdown both instances; restart; verify state survives
./next/scripts/stop_pair.sh
./next/scripts/start_pair.sh
curl :9999/actors/alice/following | jq -e '.[] | select(.id == "bob")'
```

**Acceptance for Step 12:** `smoke_federate.sh` exits 0. The full flow
runs without any human-in-the-loop coordination, both instances'
projections converge, and a restart preserves all federation state.

---

## Acceptance criteria for milestone 2

All of:

1. **Each step's test suite passes** (`bash next/tests/<step>.sh`).
2. **The federation smoke test passes** (`bash next/tests/smoke_federate.sh`).
3. **Milestone 1 baseline preserved** — the entire M1 test suite still
   passes (~560 assertions across 50 suites).
4. **Erlang-on-SX conformance** — adding multi-actor + federation kernel
   code in `next/kernel/*.erl` doesn't break Phase 1-8 conformance
   (currently 761/761).
5. **Restart durability** — kill both instances mid-delivery, restart,
   queues resume, projections converge, no log corruption.
6. **Manual real Mastodon poke** — point a Mastodon account at
   `https://next-A.rose-ash.com/actors/alice` and verify the actor
   doc fetches. (Read-only AP interop only — Mastodon Follow is v3
   gating on HTTP-Signatures-2018 compat.)

## What lands when

Steps 1-3 are sequential (multi-actor foundation). Steps 4-10 are
mostly sequential within the federation core but some can parallelise:
4-6 are sequential; 7-9 can interleave after 6 lands.

```
M1 closeout (HEAD) ──┐
                     │
                     ▼
              ┌─── Step 1 ──┬─── Step 2 ──┬─── Step 3
              │             │             │
              └─────────────┼─── Step 4 ──┘
                            │
                            └─── Step 5 ────┐
                                            │
                                  Step 6 ───┤
                                            │
                                  Step 7 ───┤
                                  Step 8 ───┤
                                  Step 9 ───┤
                                            │
                                  Step 10 ──┤
                                            │
                                  Step 11 ──┤
                                            │
                                  Step 12 ──┘
```

Estimated effort: ~40-60 commits across all 12 steps. A focused agent
loop (`loops/fed-sx-m2`) should be able to land this with the same
discipline as M1.

## What's deferred to milestone 3

- **rose-ash port** (the headline of M3). Blog, market, events,
  federation hub, account, orders — all delivered as fed-sx
  applications. Each existing rose-ash domain becomes
  `DefineApplication{...}` artifacts.
- **TLS / HTTP-Signatures-2018 / RFC 9421**. Real Mastodon interop.
- **Multi-instance over real WAN.** Cross-instance over TLS, NAT
  traversal, peer instance allowlists.
- **IPFS / S3 storage backends** as `DefineStorage` entries.
- **Browser client + operator dashboard.** Probably Elm-on-SX.
- **Cross-host conformance** — Python / JS / Haskell hosts running
  fed-sx with the same conformance corpus.
- **OpenTimestamps proofs** as `DefineProof` entries.
- **Reputation, allowlists, rate-limiting** — full §13.6 abuse
  posture.
- **Performance work** — JIT-compiled folds, snapshot acceleration,
  federation batching, mailbox prioritisation.
- **Capability tokens / delegation** — multi-device for a single
  actor.

---

## Appendix A: open questions for milestone 2

Things still under-specified; resolve as work begins.

1. **Inbox-side stage_signature key fetching.** When A receives a
   POST /inbox from peer instance B for the first time, A needs B's
   actor doc to verify the signature. Synchronous fetch vs. queue-
   and-retry? Synchronous is simpler but blocks the inbox handler;
   queue-and-retry needs deferred validation state. Probably
   synchronous with a 5s timeout for v2.

2. **Backfill granularity for `last-N`.** N counts forward (oldest
   first) or backward (newest first)? Forward matches projection-fold
   semantics; backward matches user expectation. Probably forward
   for v2, document the choice.

3. **Auto-Accept policy on Follow.** v2 ships open-world: every
   Follow is auto-accepted. Manual moderation (held in a `pending`
   list, accepted via /admin/) is v3 with the operator dashboard.

4. **Delivery worker per peer instance vs. per peer actor.** Per
   instance is simpler (one HTTPS connection pool) but throttles
   inter-actor bandwidth on busy peers. v2 starts with per-instance;
   per-actor sharding is a perf tweak in §15.

5. **Two-instance test harness.** How do we start a pair of kernels
   in one bash test? Probably `bootstrap:start/3` twice with different
   ActorIds + ports + base paths. Need to confirm `nx_kernel` can be
   started under different registered atoms (`nx_kernel_a`, `nx_kernel_b`)
   for the test. Process registration in this port supports arbitrary
   atom names (verified in M1).

6. **Multi-host conformance.** Adding cross-host tests for federation
   requires Python/JS hosts to implement the v2 spec corpus too.
   Deferred to v3; v2 conformance is one-host only.

7. **Storage of received activities.** When A receives a Note from B
   via /inbox, does A keep B's signed envelope verbatim (for re-broadcast
   on Announce), or does A re-construct + re-sign with A's own key?
   AP-canon: keep verbatim. Confirm at Step 5.

---

## Blockers

Pre-existing regressions inherited from the M1 closeout. Out of m2
scope (substrate, not `next/**`), tracked here so iteration can
proceed.

1. **`next/tests/http_server_tcp.sh` 0/5** — ~~pre-existing
   regression~~ **RESOLVED 2026-06-07** during Step 12 prep. The
   `er-bif-http-listen` sx-handler in `lib/erlang/runtime.sx`
   referenced the now-deleted `er-http-resp-to-sx` /
   `er-http-req-of-sx` helpers; rewrote the bridge to thread
   through the live `er-request-dict-to-proplist` (inbound) +
   `er-proplist-to-dict` (outbound) marshallers — the same shape
   `http_server:route/2` already consumes and emits. 5/5 now
   passing. This is the surface Step 12's real two-instance smoke
   test (rather than an in-process loopback) uses to spin up each
   instance's HTTP listener.

2. **Native `http-request` (HTTP client) primitive missing** —
   ~~discovered during Step 8e prep~~ **RESOLVED 2026-06-07** by
   the user-authorized `loops/fed-prims` → `architecture` merge.
   The primitive now registers at `bin/sx_server.ml:868+` with
   signature `(http-request meth url headers body)` returning a
   `{:status :headers :body}` dict and raising `Eval_error` on
   DNS / connect / bad URL. Step 8e wired the Erlang-side BIF
   wrapper around it (`httpc:request/4`); see Progress log
   entry for marshalling details. Step 8f (live HTTP dispatch
   through `delivery_worker`) and Step 10c (peer-actor doc
   fetch in `peer_actors`) are now unblocked.

3. **`erlang:send_after`-style timer primitive** — ~~discovered
   during Step 8b prep~~ **RESOLVED 2026-06-30** via the
   `loops/erlang` `send_after`/`cancel_timer`/`monotonic_time`
   work landing on `origin/loops/erlang` (commits 3709460d,
   98b0104c, b10e55f0; 766/766 → 771/771). m2 cherry-picked all
   three onto this branch so 8b-timer could land without waiting
   for `loops/erlang` → architecture; the cherry-picks fall away
   as no-op duplicates when architecture catches up. Original
   diagnosis preserved below for the audit trail.
   The retry loop needs a way for the
   delivery_worker to wake itself up after `backoff_for(N)`
   seconds. Erlang's `erlang:send_after/3` is the standard
   primitive; this port doesn't seem to register it (looked at
   how `gen_server` handles `timeout` returns — it's a
   message-loop self-cast that needs a delayed send). Belongs
   to `loops/erlang` (Erlang runtime substrate). m2 captures the
   retry semantics pure-functionally in 8b-pure so 8b-timer
   becomes a 1-shot wiring when the primitive lands.

4. **`http-listen` handler holds the SX runtime mutex →
   `gen_server:call` from inside an HTTP route deadlocks.** —
   ~~discovered during Step 12 prep~~ **RESOLVED 2026-06-07**
   by a two-line `lib/erlang/runtime.sx` change: extend
   `er-sched-step-alive!` to read `:pending-args` when present
   (was hardcoded to `(list)`), and rewrite
   `er-bif-http-listen`'s sx-handler to spawn the user handler
   as a real er-process with `:pending-args (list req-pl)`
   instead of `er-apply-fun handler` inline. Any `receive`
   inside a kernel-aware route now suspends + resumes inside
   the SX scheduler. Verified via the new
   `next/tests/smoke_kernel_route.sh` (6/6, single-instance
   `http_server:start(P, [{kernel, nx_kernel}])` serves
   welcome + `/actors/alice/outbox` with kernel-backed `tip:`
   etc.). The full Pattern A vs Pattern B analysis below is
   preserved for the audit trail. The original native
   `http-listen`
   primitive in `bin/sx_server.ml:735+` serialises handler calls
   with `Mutex.lock mtx` / `Mutex.unlock mtx` so the SX runtime
   isn't re-entered concurrently. The wrapped Erlang handler
   eventually does `gen_server:call(nx_kernel, ...)` (for kernel-
   aware routes like `actor_doc_response_for/3`,
   `actor_outbox_response_for/3`, `handle_inbox_post`,
   `nx_kernel:state_for/1`, etc.); the gen_server reply needs the
   scheduler to run, which needs the SX runtime, which is locked
   by the calling handler. Deadlock — curl hangs until the test
   `--max-time` fires.

   Verification: a sx_server with `http_server:start(P, [])` (no
   Cfg, no kernel routes) serves GET / and welcome paths fine;
   the same instance with `Cfg = [{kernel, nx_kernel}]` hangs on
   the first GET /actors/<id>/outbox (or any /actors/<id> with
   `Accept: application/vnd.fed-sx.actor-doc`).

   **2026-06-07 update:** `loops/fed-prims` commit `bf8d0bf2`
   (merged to architecture as `94f6ab9f`) diagnosed this as
   Erlang-substrate scope rather than an OCaml mutex bug, and
   sketched a Pattern B fix entirely in `er-bif-http-listen`:
   wrap the handler call in `er-spawn-fun` + `er-sched-run-all!`
   and read the process's `:exit-result`. m2 tried this patch on
   `lib/erlang/runtime.sx` and **it did not work**: the listener
   binds, but every kernel-aware request returns HTTP 000.
   Reproducer: spin up `http_server:start(P, [])` with the
   Pattern B `sx-handler`; `curl http://127.0.0.1:P/` returns 000.

   **Concrete reason (verified by isolated tests in the
   connection thread, m2 worktree):** `er-spawn-fun` raises
   `"Erlang: spawn/1: not a fun"` when called with the
   raw SX lambda `(fn () (er-apply-fun handler (list req-pl)))`
   because it gates on `(not (er-fun? fv))` and `er-fun?`
   checks for the `{:tag "fun"}` Erlang-AST shape, not a host
   Lambda. The user-supplied `handler` IS an `er-fun` (built
   by the user's `fun (Req) -> route(Req, Cfg) end` form), but
   we need a 0-arity wrapper to feed it `req-pl` — and
   `er-sched-step-alive!` hardcodes `(er-apply-fun
   (er-proc-field pid :initial-fun) (list))`, so the
   wrapper must be 0-arity.
   Verified piece-by-piece from the connection thread:
   `er-pid-new!` → ok, `er-proc-new!` → ok, but
   `er-spawn-fun (fn () 42)` → empty reply (the `error` raise
   propagates through `Sx_runtime.sx_call` and gets caught by
   the native http-listen `(try ... with _ -> ())` at
   `sx_server.ml:852` so the connection writes nothing and
   closes).

   To make Pattern B actually work in pure SX you need a way
   to construct an `er-fun` programmatically from a raw SX
   closure (so the wrapper-with-captured-req-pl can be
   spawned). The existing `er-mk-fun` takes Erlang AST
   clauses, not host closures — building one inline either
   needs an AST-constructor helper or a small parser call.
   This is a one-helper substrate addition, not a redesign,
   but it does need to live in `lib/erlang/transpile.sx` or
   `runtime.sx` and probably wants an additive test.

   Also: even with that helper, the original "race against
   the parked boot-thread pump" concern is unverified.
   Solo-piece tests inside the connection thread showed the
   global `er-sched-*` state IS accessible there
   (`er-sched-process-count` returned 2 — the boot main +
   the spawned http:listen process). Once an `er-fun`
   wrapper exists, the spawn + drain should at least
   smoke-execute; what happens next under live load is the
   next unknown.

   Reverted on m2 — `lib/erlang/runtime.sx` stays at the
   Blockers #1 marshaller-bridge fix, which is correct for
   the non-kernel surface (welcome / capabilities / 404 /
   401 over real HTTP).

   The real fix likely needs ONE of:
   - Native http-listen registers the listener and returns
     immediately (non-blocking BIF), with the accept loop
     running on a separate native thread and the connection
     handler entering a **fresh** `er-sched-init!`-d
     scheduler context (substrate change in OCaml + a redesign
     of how er-sched-* state is partitioned by thread).
   - OR: the connection handler runs `erlang-eval-ast`-style
     (its own `er-sched-init!` + private scheduler), with the
     gen_server hosted in a way that's accessible across
     scheduler instances (substantial substrate redesign).
   - OR: skip the per-process scheduler entirely for HTTP
     handlers and use a synchronous "reply channel" pattern
     that doesn't go through `receive` (changes every
     kernel-aware Erlang module's call shape — large blast
     radius).

   Belongs on `loops/erlang` or a follow-on `loops/fed-prims`
   tick. Step 12's two-instance smoke test gates on this —
   without it, the only request shapes that survive over real
   HTTP are the static / capabilities / static-stub paths.

   In-flight `smoke_federate.sh` test was withdrawn during the
   initial Blockers #4 surfacing (it boots both instances
   successfully but every kernel-touching request hangs); the
   plan's Step 12 acceptance criterion stays open pending
   Blockers #4 resolution. m2's other 11 steps are fully
   landed and individually proven by their per-step suites.

---

## Progress log

Newest first.

- **2026-06-30** — Step 8b-timer closed. Cherry-picked the three
  `loops/erlang` send_after commits onto m2 (3709460d, 98b0104c,
  779e53b2 — the substrate landed standalone on origin/loops/erlang
  earlier and hadn't propagated to origin/architecture yet). Wired
  the live timer loop in `next/kernel/delivery_worker.erl`: a
  `:timers [{Cid, Ref}]` state field; `handle_call(flush)` drains
  then arms a `send_after` self-cast per retried Cid; the new
  `handle_info({retry, Cid})` callback redrives that one Cid through
  `deliver_one_pure` and either records success / clears state, or
  bumps and arms the next backoff slot (or dead-letters on the 6th
  attempt). Two arm-paths split — `arm_retry_timer` (post-drain,
  attempts already bumped) vs `schedule_retry_for` (post-retry
  attempt, needs to bump). `cancel_timer_for/1` clears the previous
  timer before arming the next so stale timers don't keep the
  scheduler's run loop alive after the work is done. Two new public
  APIs for tests: `state_srv/1` returns the worker's full state,
  `timer_ref_for/2` looks up a Cid's live ref. 5/5 in new
  `delivery_retry_timer.sh` (T1 timer scheduled, T2 attempts=1, T3
  retry fires + attempts=2, T4 next timer rearmed, T5 ets-counter
  dispatch fail/fail/ok lands in 3 attempts and clears state).
  Existing `delivery_worker.sh` 17/17 and `delivery_retry.sh` 11/11
  still green. Conformance gate 771/771 (was 761/761; the +10 is
  the cherry-picked send_after suite). Blockers #3 RESOLVED.
  Reply shape of `flush` unchanged; no caller updates needed.

- **2026-06-28** — Merge-prep pass. Conformance 761/761 still green
  on m2 tip `cd0de8cb`. Both smoke tests still pass cold:
  `next/tests/smoke_kernel_route.sh` 6/6 (port 54471, listener up
  in 94s), `next/tests/smoke_federate.sh` 6/6 (both instances up in
  282s, follow → 202 → outbox tip 0→1). Dry-run rebase of m2 onto
  current `origin/architecture` (`0963aa51`) shows 109 commits to
  replay; first conflict at m2's `24e3bf53` — the
  `binary_to_list/1`+`list_to_binary/1` substrate fix landed
  independently on both branches (m2 as part of Step 3b, architecture
  as `c6f397c3`). Textual diff of `lib/erlang/runtime.sx` changes
  in both commits is **identical** (only the file's base hash
  differs because the surrounding context diverged). Conflict is in
  `lib/erlang/scoreboard.json` + `scoreboard.md` (test count
  summaries). Mechanical resolution on the eventual merge:
  `git rebase --skip` for m2's `24e3bf53` (and check the other
  three: `5098a8f0`, `9fe5c904`, `6d7f0a3f` — same shape, all
  Step 3b substrate fixes that propagated upstream via
  `loops/erlang` after m2 cherry-picked them in). No code conflict
  expected on the substantive m2 work (`lib/erlang/runtime.sx`
  `:pending-args` substrate fix + `er-bif-http-listen` rewrite,
  `er-bif-httpc-request`, plus all of `next/**`). The
  `:pending-args` extension to `er-sched-step-alive!` from
  Blockers #4 (commit `03c32cda`) is substrate-shaped and only
  lives on m2 — should propagate to `loops/erlang` for upstream
  reuse, but that propagation belongs to the `loops/erlang` loop,
  not this one.

- **2026-06-07** — Step 12 closed. `next/tests/smoke_federate.sh`
  6/6: two sx_server instances on distinct ephemeral ports,
  each running `http_server:start(P, [{kernel, nx_kernel},
  {auto_accept_follows, true}, {peer_as, ...}])`. Test signs a
  real Follow envelope with alice's key in a third subprocess
  (`outbox:construct(follow, alice, 1, bob)` + `outbox:sign` +
  `term_codec:encode`), POSTs the bytes to B's
  `/actors/bob/inbox` over real HTTP, asserts B's pipeline
  validates the signature against the pre-populated alice
  peer-AS (status 202), and bob's outbox tip advances 0 → 1
  (auto-accept publish landed in bob's outbox). Real cross-
  instance federation flow end-to-end. m2 milestone complete
  except 8b-timer (retry loop) which still gates on
  Blockers #3 send_after — the smoke test drains the worker
  queue synchronously, sufficient for the wiring proof but
  production retry schedule needs the timer primitive.

- **2026-06-07** — Re-investigated Pattern B with proper
  instrumentation; **concrete failure root cause identified**.
  Built each step of the spawn pipeline as its own minimal
  `sx-handler` (hardcoded reply dict) and curled it:
  hardcoded dict → 200 ✓, `er-sched-process-count` →
  `procs=2` ✓ (boot main + http:listen process; global
  scheduler IS accessible from the connection thread),
  `er-pid-new!` → 204 ✓, `er-proc-new!` → 205 ✓ — all the
  way up to `er-spawn-fun (fn () 42)` → HTTP 000. The break
  is `er-spawn-fun`'s `(not (er-fun? fv))` gate raising
  `"Erlang: spawn/1: not a fun"` because the raw SX lambda
  isn't an Erlang-fun-shaped `{:tag "fun"}` dict. The
  `error` raise propagates through `Sx_runtime.sx_call` and
  is swallowed by the native http-listen
  `(try ... with _ -> ())` at `sx_server.ml:852`; connection
  writes nothing and closes.

  Was previously waving at "race against parked boot-thread
  pump" as the hypothesis — that part wasn't reproduced.
  The global scheduler IS shared and the connection thread
  reads it fine; the breakage is the strict `er-fun?` shape
  check, not concurrency.

  Path forward for Pattern B (still substrate scope): need a
  way to construct an `er-fun` from a host SX closure so the
  0-arity wrapper-with-captured-req-pl can be fed to
  `er-spawn-fun`. Either a new `er-mk-host-fun` helper in
  `lib/erlang/runtime.sx`, or a small AST-constructor in
  `transpile.sx`. One-helper substrate addition, not a
  redesign. Blockers #4 updated; once that helper lands the
  spawn + drain should at least smoke-execute (whatever
  concurrency issue surfaces next is the next unknown).
  Reverted runtime.sx to the Blockers #1 marshaller-bridge
  fix.

- **2026-06-07** — Tried `loops/fed-prims` `bf8d0bf2`'s Pattern B
  patch sketch on `lib/erlang/runtime.sx`'s `er-bif-http-listen`:
  wrap the handler call in `er-spawn-fun` + `er-sched-run-all!`
  and read the spawned process's `:exit-result`. **It did not
  work** — listener binds, but even the non-kernel welcome route
  now returns HTTP 000 (the spawned handler's response never
  reaches the wire). Reverted; runtime.sx stays at the
  Blockers #1 marshaller-bridge fix. Initially hypothesised the
  failure was a scheduler-re-entry race (parked Unix.accept
  pump on the boot thread vs. connection-thread pump); the
  follow-up tick above narrowed the root cause to the
  `er-fun?` shape gate — see that entry for the verified
  diagnosis.

- **2026-06-07** — Step 12 prep discovered Blockers #4
  (http-listen handler holds the SX runtime mutex; any
  `gen_server:call` from inside an HTTP route deadlocks
  because the gen_server reply scheduler needs the SX runtime
  the calling handler is sitting on). Verified by spinning
  up a single `http_server:start(P, [{kernel, nx_kernel}])`
  instance: GET / works, GET /actors/alice (text) works
  (no gen_server touch), but GET /actors/alice/outbox or
  GET /actors/alice with `Accept: application/vnd.fed-sx.
  actor-doc` both hang past curl's --max-time. m2's Step 12
  acceptance gates on this — its proof-point is the
  two-instance smoke test which walks the full Follow →
  Accept → Note fan-out path, and every step touches the
  kernel via gen_server. The in-flight `smoke_federate.sh`
  was withdrawn (boots both instances + serves welcome
  routes successfully, but every kernel-aware request hangs);
  Blockers #4 entry documents the substrate-level fix
  patterns. m2's other 11 steps remain individually proven
  by their per-step suites. Pivot: pacing the autonomous
  loop down — substrate work is owed to `loops/erlang` or
  `loops/fed-prims`, not m2.

- **2026-06-07** — Blockers #1 RESOLVED. The
  `er-bif-http-listen` sx-handler in `lib/erlang/runtime.sx`
  referenced `er-http-resp-to-sx` / `er-http-req-of-sx` —
  helpers deleted by `78eae9ef` because the BIF body never
  picked them up. Rewrote the bridge to thread through the
  live marshallers `er-request-dict-to-proplist` (inbound
  SX Dict → Erlang request proplist matching what
  `http_server:route/2` consumes) and `er-proplist-to-dict`
  (outbound Erlang response proplist → SX Dict matching
  what the native http-listen primitive serialises to the
  wire). The marshallers convert binary header values to
  strings + flatten the nested headers proplist via
  `er-to-sx-deep`'s 2-tuple detection, so the response
  shape matches what http-listen expects without any
  additional shape coercion.
  `next/tests/http_server_tcp.sh` 5/5 (GET /, capabilities,
  unknown → 404, POST /activity no/bad bearer → 401).
  Conformance 761/761 + 6 adjacent gates (httpc_request,
  dispatch_http, http_listen_bif, discovery_fetch,
  http_multi_actor, http_marshal) all green.

  This is technically substrate work in lib/erlang/runtime.sx,
  but stays within the m2 briefing's allowed exception scope
  (the http BIF wrappers — Step 8a / 8e / now 12-prep — are
  the explicit substrate carve-outs). Unblocks Step 12's
  REAL two-instance smoke test (rather than an in-process
  loopback variant).

- **2026-06-07** — Step 10c (closes Step 10): peer-actor doc
  fetch + cache write. New `next/kernel/discovery_fetch.erl`
  produces a 1-arity FetchFn closure for
  `peer_actors:lookup_or_fetch_srv/2`. Closure GETs
  `<base>/actors/<peer>` via Step 8e's `httpc:request/4` BIF
  with `Accept: application/vnd.fed-sx.actor-doc`, decodes
  the body via `term_codec:decode/1`, returns `{ok, AS}` where
  AS is the peer-actor-state proplist (`[{public_keys, [...]}]`,
  the shape `envelope:verify_signature` consumes). Cfg reuses
  the same `:peer_url` / `:peer_url_fn` resolution as
  `dispatch_http` (Step 8f) so a single Cfg can thread through
  both delivery and discovery.

  Server side: `http_server.erl` now serves the same MIME.
  New `actor_doc` content-negotiation atom — `accept_format/1`
  matches `application/vnd.fed-sx.actor-doc` first
  (`actor_doc_prefix/0`); `content_type_for(actor_doc)`
  emits it on outbound. New `actor_doc_response_for/3`
  kernel-aware arm: when Cfg carries `{kernel, Kernel}` and
  the kernel has the actor, calls `nx_kernel:state_for/1`
  (NOT the legacy single-bucket `actor_state/1` accessor) and
  emits `term_codec:encode/1` of the AS. Other formats fall
  through to the existing /2 stub variants. Unknown actor →
  `not_found_response/0`. `actor_get/3` route dispatch now
  threads Cfg through to the /3 arm.

  Subtle port note: this port's Erlang doesn't support
  `Mod:Fun(X)` dispatch on a variable module, so the
  Cfg `:kernel` field exists to flag "no kernel wired" →
  nil short-circuit; the actual call is hardcoded to
  `nx_kernel:state_for/1` (the only kernel module in play).
  Documented inline.

  Outcome mapping (discovery_fetch):
    2xx + decodable    → {ok, AS}
    2xx + bad body     → {error, bad_actor_doc}
    non-2xx            → {error, {status, N}}
    resolver miss      → {error, no_peer_url}
    transport          → {error, Reason} (BIF's network re-raise)

  Test: `next/tests/discovery_fetch.sh` 11/11 — both halves.
  Server side: Accept negotiation, kernel + actor → 200 +
  decodable body, unknown actor → 404. Closure side: URL
  construction `<base>/actors/<peer>`, live GET against the
  background python stub returning hand-crafted term_codec
  bytes (Python encoding helper mirrors term_codec.erl's
  netstring format — count-based not byte-length headers for
  l/t), make_fetch_fn closure resolves through static map +
  closure peer_url_fn, missing peer → `no_peer_url`, 404 →
  `{status, 404}`, end-to-end `peer_actors:lookup_or_fetch/3`
  caches the result.

  Adjacent gates: Erlang conformance 761/761, httpc_request
  10/10, dispatch_http 10/10, http_listen_bif 5/5,
  peer_actors 19/19, discovery 12/12, http_accept 13/13,
  http_actors 13/13 — all green.

- **2026-06-07** — Step 8f (closes Step 8 except 8b-timer which
  still gates on Blockers #3 send_after): live HTTP dispatch
  through `httpc:request/4`. New `next/kernel/dispatch_http.erl`
  exposes `make_dispatch_fn/2`, `dispatch/3`, `inbox_url/2`,
  `resolve_peer_url/2`, `content_type/0`. The closure encodes
  the Activity with `term_codec:encode/1`, sets
  `content-type: application/vnd.fed-sx.activity`, builds the
  URL as `<BaseUrl>/actors/<peer-atom>/inbox`, and POSTs via
  the Step 8e BIF wrapper. Result mapping: 2xx → `ok`; non-2xx
  → `{error, {status, N}}`; transport (DNS / connect / bad URL
  / socket closed) → `{error, Reason}` after the wrapper's
  Erlang `error:{network, ReasonBinary}` is caught locally.
  Cfg resolves the peer base URL through a static `:peer_url`
  proplist first, then a `:peer_url_fn` closure as fallback
  (Step 10c will plumb a peer_actors-cache-backed one). BIF
  wrapper in `lib/erlang/runtime.sx` updated to catch host
  errors via SX `guard` and re-raise as Erlang
  `error:{network, ReasonBinary}` — the host's plain
  `Eval_error` was previously bubbling past the Erlang
  try/catch surface (which only handles `er-thrown?` /
  `er-errored?` / `er-exited?` markers).

  Subtle Erlang-port note: this port's `try/catch` requires a
  literal class atom (`error:Reason`), not a variable
  `Class:Reason`; dispatch_http catches `error:Reason` only,
  which is what the BIF re-raise produces.

  Test: `next/tests/dispatch_http.sh` 10/10 — module loads,
  inbox_url builds `/actors/X/inbox`, static + closure peer
  resolvers, live POST against background `python3 -m
  http.server` (always-200 handler) returns ok, missing peer
  surfaces as `{error, no_peer_url}`, closed port surfaces as
  `{error, _}`, delivery_worker drains the queue via the
  live closure. Closes Step 8 except 8b-timer.

  Adjacent gates: Erlang conformance 761/761, httpc_request
  10/10, http_listen_bif 5/5, delivery_worker 17/17,
  delivery_retry 11/11, delivery_dispatch 7/7 — all green.

- **2026-06-07** — Step 8e (closes the BIF half of Step 8;
  live HTTP dispatch in 8f next): `httpc:request/4` BIF wrapper
  landed in `lib/erlang/runtime.sx` (briefing-allowed-exception
  scope). Marshalling: Erlang URL binary → SX string via
  `(list->string (map integer->char (get url :bytes)))`; Erlang
  atom method → upcased name (`get` → `"GET"`) for HTTP wire
  convention; binary method passes through verbatim; headers
  proplist → SX dict via existing `er-proplist-to-dict`; body
  binary → SX string. Result `{:status :headers :body}` marshalled
  back to Erlang `{ok, Status, Headers::proplist, Body::binary}`
  via `er-of-sx-deep` on headers (which produces the binary-keyed
  proplist `er-dict-to-header-proplist` shape) and
  `(er-mk-binary (map char->integer (string->list body)))` for
  body. Non-binary URL / body raise `error:badarg`; the native
  primitive raises `Eval_error` on DNS / connect / bad URL which
  surfaces as an Erlang error marker the caller can catch.
  Blockers #2 (native http-request primitive) entry updated:
  RESOLVED by the loops/fed-prims → architecture merge that the
  user authorized. Test: `next/tests/httpc_request.sh` 10/10 —
  5 registration / validation cases (registration under
  `httpc/request/4`, non-pure flag, no /1 arity, badarg on
  non-binary URL, badarg on non-binary body) plus 5 live
  roundtrip cases against a background `python3 -m http.server`
  (Status 200, body bytes match `hello from python\n`, headers
  proplist shape, 404 surfaces as `{ok, 404, ...}` not as an
  error tuple, method passed as binary works). Adjacent gates:
  Erlang conformance 761/761, http_multi_actor 44/44, follower_
  graph 18/18, follow_lifecycle 9/9, backfill 20/20,
  backfill_drain 6/6, http_listen_bif 5/5 — all green; pre-
  existing cold-startup timeout sensitivity on http_get_format
  (120s internal) and nx_kernel_pure (240s internal) confirmed
  with git stash to NOT be caused by this change.

- **2026-06-07** — Step 9c (closes Step 9): Follow → Accept →
  backfill drain (in-process). `maybe_auto_accept/3` now calls
  `maybe_backfill/3` after the Accept publish: when
  `:backfill_enabled` is true and the Follow envelope carries a
  `:backfill` field, the receiver parses the mode, slices its
  outbox via `backfill:slice/3` (Wrap=true), and enqueues every
  entry onto the peer's delivery_worker. Silent skip when the
  worker isn't registered (kernel manager lazy creation
  upstream). 6/6 in `backfill_drain.sh`. Step 9 fully closed
  (9a slicing + 9b ?since route + 9c Accept-drain). Live HTTP
  dispatch of queued entries still gates on Blockers #2
  (httpc).

- **2026-06-07** — Step 9b: outbox `?since=Cid` pagination.
  `actor_outbox_response_for/3` in `http_server.erl` now reads
  `?since=` from the query string via new `parse_since/1` +
  `scan_param/2,3` + `skip_to_amp/1` (works whether the param
  is first or after `&`), pre-filters entries through
  `backfill:since_cid_entries/2`, then runs the existing page
  slice on the filtered list. Unknown cid -> empty page -> tip-
  only degrade. Three new cases in `http_multi_actor.sh` (44/44
  total) cover filter, unknown-cid, combined since+page.
  Latent issue surfaced + fixed in passing: the test was missing
  `follower_graph` + `delivery` + `backfill` module loads
  (since Step 7c made outbox depend on them); added all three.

- **2026-06-07** — Step 9a: pure-functional backfill slicing.
  `next/kernel/backfill.erl` with `slice/2,3(Mode, LogState
  [, Wrap])` returning the appropriate activity list. Modes
  `none / full / {last_n, N} / {last_t, T, NowFn} /
  {since_cid, Cid}` cover the §13.3 grammar; `wrap_backfill/1`
  marks each entry `{backfilled, true}` (id preserved so the
  receiver's replay defence still works). `parse_mode/1` lifts
  the Follow activity's `:backfill` value (atom or proplist)
  into the internal mode tuple; unknown shapes -> none. 20/20
  in `backfill.sh`. Substrate gotchas re-confirmed:
  `lists:nthtail/2` not registered (rolled `drop_n/2`); pattern-
  alias `Pat = Var` not supported in this port (rewrote
  `parse_mode/1` clauses with explicit deconstruction).
  Conformance preserved at 761/761.

- **2026-06-07** — Step 11b: projection folds for the new verbs.
  Two new modules in `next/kernel/`:
  `announce_state.erl` (per-Cid announcer-set fold, set
  semantics) and `endorsement_state.erl` (per-Cid + per-kind
  + per-actor counter, additive semantics). Both follow the
  same plug shape as `actor_state` / `follower_graph` /
  `delivery_state`: `fold_fn/0` returns a 2-arity Erlang fun
  for `projection:start_link/3`. Non-matching activities pass
  through unchanged. Read-side accessors cover both
  enumeration (announcers_for / endorsers_for) and predicates
  (has_announced / has_endorsed) so the feed/timeline layer
  doesn't have to re-implement that logic. 19/19 in
  `rich_verbs.sh`. Conformance preserved at 761/761.

- **2026-06-07** — Step 11a: Announce + Endorse genesis
  activity-types. Two new DefineActivity SX files in
  `next/genesis/activity-types/`: announce.sx (`:object` is a
  CID string — the referenced activity to re-broadcast),
  endorse.sx (`:object` is a CID, `:kind` is a string variant
  like 'like' or 'share'). Manifest extended to 5 activity-types /
  36 total entries. Bootstrap suite count assertions bumped
  (`bootstrap_read`, `bootstrap_load`, `bootstrap_populate`,
  `bootstrap_start`). `genesis_parse.sh` +4 cases. M1's Note
  object-type is unchanged — Create{Note{...}} is still the
  publish path. The runtime-publish demo (verb extensibility
  via `Create{DefineActivity{...}}` at runtime) from M1 §9a
  still works; these files are the genesis pre-shipped
  variants for v2 baseline.

- **2026-06-07** — Step 10b: webfinger HTTP route.
  `GET /.well-known/webfinger?resource=acct:user@host` lands in
  `http_server.erl` next to the existing
  `/.well-known/sx-capabilities` arm. New `handle_webfinger/1`
  reads `:request_query` from Cfg (threaded via route/2 since
  Step 4d), parses `resource=` + the acct: URI via
  `discovery:parse_acct/1`, optionally matches against Cfg's
  `:webfinger_host`, checks actor existence via the kernel atom
  (when cfg'd), and renders the body via
  `discovery:webfinger_body/3`. 10/10 in `webfinger_route.sh`.
  Conformance + adjacent tests (`http_route` 11/11, `discovery`
  12/12) preserved.

- **2026-06-07** — Step 10a: discovery primitives. New
  `next/kernel/discovery.erl` parses acct: URIs
  (prefix optional), synthesises `http://<host>/actors/<user>`,
  and builds RFC 7033 webfinger JSON bodies. Hand-rolled byte
  concatenation since this port has no JSON BIF and `<<"...">>`
  string literals truncate to one byte (substrate gotcha). 12/12
  in `discovery.sh`. The route wiring (10b) and peer-actor
  fetch (10c) layer on top — 10c gates on Blockers #2.

- **2026-06-07** — Step 8c: delivery-state projection. New
  `next/kernel/delivery_state.erl` folds enqueue / delivered /
  failed / dead_lettered events into a per-peer worker-shaped
  snapshot. State shape mirrors `delivery_worker:new/1`'s output
  so a fresh gen_server can be hydrated from the projection on
  kernel restart. The fail branch calls
  `delivery_worker:backoff_for/1` directly, so the projection and
  the live worker compute identical retry slots / dead-letter
  thresholds. `fold_fn/0` plugs into `projection:start_link/3`
  just like `actor_state` and `follower_graph`. 14/14 in
  `delivery_state.sh`; delivery_worker.sh 17/17 + delivery_retry.sh
  11/11 unchanged. Conformance preserved at 761/761. The
  hydration helper that loads a worker's pure state from the
  projection lands once 8b-timer can wire the live retry loop
  (Blockers #3 still open).

- **2026-06-07** — Step 8b-pure: retry-time bookkeeping.
  `delivery_worker` state shape gains `:next_retry` proplist
  alongside `:attempts`. `record_failure_pure/3(Cid, Now, State)`
  bumps the per-cid counter and computes the next retry as
  `Now + backoff_for(NewAttempts)`. On the 6th failure
  (`backoff_for` returns `dead_letter`) the matching activity
  moves from `:pending` to `:dead_letter`. `record_success_pure/2`
  clears both `:attempts` and `:next_retry` for the cid.
  `next_due_pure/2(Now, State)` returns the cids whose retry
  time has passed (insertion order preserved). 11/11 in
  `delivery_retry.sh`. 8b-timer (real timer wiring via
  `erlang:send_after`-style primitive) and 8e
  (`httpc:request/4` BIF) hit substrate gaps — Blockers entries
  added pointing to loops/erlang + loops/fed-prims. Conformance
  preserved at 761/761.

- **2026-06-07** — Step 8d: outbox dispatches delivery_set to
  workers. `outbox:publish/2` gained `dispatch_deliveries/3` and
  `enqueue_each/2`: after `log:append` + projection broadcast,
  the resolved `delivery_set` is walked and each registered
  peer-id atom's `delivery_worker:enqueue(PeerId, Activity)` is
  called. Missing workers (no `erlang:whereis`) are silently
  skipped. Gated by Context's `{dispatch_deliveries, true}` —
  default off so every M1 outbox caller stays back-compat. 7/7
  in `delivery_dispatch.sh`; `outbox_publish.sh` + 
  `delivery_worker.sh` both still 17/17. Conformance preserved
  at 761/761 from the Step 8a baseline.

- **2026-06-07** — Step 8a: delivery_worker skeleton.
  `next/kernel/delivery_worker.erl` with pure-functional state +
  enqueue / drain / deliver_one + backoff schedule (30s / 5m /
  30m / 6h / 24h then dead-letter, per design §13.4). gen_server
  wrapper exposes the same APIs under the peer-id atom. dispatch
  is a caller-supplied `:dispatch_fn` fun — Step 8b layers the
  retry timer, Step 8c persists the queue, Step 8d wires
  `outbox:publish/2` to dispatch, Step 8e brings the
  `httpc:request/4` BIF (substrate exception per briefing), Step
  8f closes with live HTTP. 17/17 in `delivery_worker.sh`.
  Conformance 761/761.

- **2026-06-07** — Step 7c (closes Step 7): outbox-side
  delivery_set integration. `outbox:publish/2` computes the
  audience-resolved delivery set after sign + log and stashes
  it in the Result proplist as `{delivery_set, [ActorId, ...]}`.
  New `compute_delivery_set/3(Request, Signed, Context)`
  threads `:follower_graph` from Context through to
  `delivery:delivery_set/3`. `recipients_envelope/2` synthesises
  a minimal envelope from the Request's `:to`/`:cc` + Signed's
  `:actor` so the existing delivery API works unchanged
  (envelope construct/4 doesn't carry the audience fields
  through). 17/17 in `outbox_publish.sh` (+4 new: empty-default,
  explicit-:to, followers-symbol-via-graph, self-suppression).
  Module load order shifted from epoch 5 to epoch 7 to make
  room for follower_graph + delivery; internal sx_server
  timeout bumped 240s → 480s. Step 7 fully closed (7a delivery
  module + 7b public expansion + 7c outbox integration).

- **2026-06-06** — Step 7b: public audience expansion.
  `delivery:expand_audience(public, Sender, Graph)` now returns
  the sender's followers (same as `followers`) — per design
  §13.4 that's the practical fan-out semantics for an open
  social network. The explicit shared-inbox peer-instance model
  defers to v3. 19/19 in `delivery_set.sh` (+2 new cases:
  public-with-empty-graph, public+followers-dedupe; +1 case
  updated from the v2 placeholder). Conformance 761/761
  preserved.

- **2026-06-06** — Step 7a: audience-resolving delivery set.
  New `next/kernel/delivery.erl`: `delivery_set/2,3(Activity,
  KernelState[, FollowerGraph])` returns a deduplicated list of
  ActorId atoms — the targets an outbound activity needs to be
  POSTed to. Sources: `:to` and `:cc` fields (single atom or
  list, atoms or audience symbols), plus expansion of `followers`
  via the supplied follower_graph state. `public` placeholder
  returns `[]` for v2; Step 7b will populate via a known-
  peer-instance set. Self-delivery suppressed. ActorIds for now —
  Step 8 resolves each entry to `{PeerInstanceUrl, ActorId}` via
  peer-actors cache. 17/17 in `delivery_set.sh`. Conformance
  761/761. Lives in its own module (not inside `outbox`) so the
  Step 8 delivery-queue gen_server has a clean home.

- **2026-06-06** — Step 6c (closes Step 6): auto-Accept publish on
  Follow ingestion. New `maybe_auto_accept/3` in `http_server.erl`
  fires after successful inbox append + projection broadcast:
  if Cfg has `{auto_accept_follows, true}` and the activity is a
  `Follow`, construct `[{type, accept}, {object, OriginalFollow}]`
  and route through `nx_kernel:publish_to/2`. The publish goes
  through the full outbox pipeline (construct + sign + log +
  projection broadcast), so when the target's outbox `:projections`
  share the same follower_graph projection that inbox broadcasts
  into, the bilateral relationship fold-converges automatically
  (`alice.followers = [bob]`, `bob.following = [alice]`, both
  pending lists clear). Default off; bad-sig / non-Follow
  ingestion short-circuits before the Accept attempt. 9/9 in
  `auto_accept.sh`. Conformance 761/761. Step 6 fully closed
  (6a + 6b + 6c).

- **2026-06-06** — Step 6b: wire follower_graph fold to the
  inbox handler. New `broadcast_to_inbox_projections/2` in
  `http_server.erl` casts every successfully-ingested activity
  into each `:inbox_projections` Cfg entry via
  `projection:async_fold/2`. Fire-and-forget so the inbox
  handler doesn't block on fold processing. Empty / absent
  `:inbox_projections` is a no-op (back-compat with Steps 5d
  callers). 9/9 in `follow_lifecycle.sh` covering 202 + bilateral
  pending-state mutation + bad-sig short-circuit + multi-peer
  + end-to-end projection convergence on Follow+Accept. Conformance
  761/761. Auto-Accept publish (the receiving kernel responds
  with a signed Accept) is Step 6c.

- **2026-06-06** — Step 6a: follower-graph projection
  (`follower_graph.erl`). Pure-functional fold over Follow /
  Accept / Reject / Undo activities per design §13.2. State is a
  proplist keyed by ActorId carrying `{following, followers,
  pending_outbound, pending_inbound}` lists. Follow pushes onto
  pendings; Accept moves both sides from pendings into the
  permanent lists; Reject just clears pendings; Undo drops the
  pair everywhere (and only the Follow's original actor can Undo).
  Self-follow is a no-op; duplicate Follow is idempotent;
  Accept/Reject/Undo of a non-Follow `:object` passes through.
  `fold_fn/0` is the standard 2-arity fun for
  `projection:start_link/3` (same shape as `actor_state` and
  `define_registry`). 18/18 in `follower_graph.sh`. Conformance
  761/761.

- **2026-06-06** — Step 5d: POST /actors/<id>/inbox real ingestion.
  `route/2` now special-cases POST `/actors/<id>/inbox` next to POST
  `/activity` so the body + full Cfg reach the new
  `handle_inbox_post/3` handler. Flow:
  `kernel_has_actor` -> `decode_activity` (term_codec wire format)
  -> `resolve_peer_as` (Cfg `:peer_as` map > `:peer_actors` srv >
  `:peer_fetch_fn` fallback) -> `pipeline:validate_inbound/3` ->
  `nx_kernel:append_inbox`. Status codes 202 / 401 / 404 / 422
  per design §16.1. v1 stub `actor_post/1` removed; M1
  `actor_inbox_post_response/0` kept for response shape composition.
  Projection broadcast on inbox success intentionally deferred to a
  follow-up. `inbox.sh` 11/11 (basic ingestion: happy path / shape
  / sig / replay / unknown-target / multi-message);
  `inbox_peer_resolution.sh` 6/6 (peer-AS resolution variants).
  Split into two files because cumulative per-epoch kernel
  start_link + outbox construct + term_codec encode pushed a
  single suite past the wall-clock budget. http_server.erl now
  1181 lines — load time on this Erlang port scales superlinearly
  with function count, so eight http_*.sh tests' internal sx_server
  timeout bumped 60s → 360s. Conformance 761/761.

- **2026-06-06** — Step 5c: peer-actors cache (`peer_actors.erl`).
  Pure-functional cache of `{PeerActorId, PeerAS}` entries with
  the load-bearing `lookup_or_fetch/3(PeerId, FetchFn, State)`
  entry: cache hit returns stored PeerAS unchanged; miss calls
  `FetchFn(PeerId)`, stores success, returns `{ok, PeerAS,
  NewState}`. Fetch errors don't poison the cache so callers can
  retry on transient HTTP failures. gen_server wrapper exposes
  the same shape under registered name `peer_actors`;
  `start_link/1` accepts an initial proplist for tests.
  Per-design v2 fetches are synchronous over plaintext HTTP; the
  actual http-client call lands in Step 5d. 19/19 in
  `peer_actors.sh`. Conformance 761/761. 139/139 across 9
  Step-5-adjacent suites.

- **2026-06-06** — Step 5b: federation inbound pipeline.
  `pipeline:validate_inbound/3(Activity, PeerAS, InboxLog)` runs
  `stage_envelope` → `stage_signature(PeerAS)` → `stage_replay(InboxLog)`
  in order, halting on first failure. New `inbound_stages/2`
  helper returns the 3-stage list. M1's `validate_inbound/1` +
  static `inbound_stages/0` (envelope-only) preserved for outbox-
  side callers. 14/14 in `inbox_pipeline.sh` covering happy path,
  bad shape, missing :signature, wrong peer AS, replay against
  inbox, distinct activities both verify, stage short-circuit
  ordering. Sig verification routes through the peer's AS (not the
  local kernel's) — Step 5c will wire the cache lookup. Conformance
  761/761. 130/130 across 10 Step-5-adjacent suites
  (pipeline_envelope, pipeline_signature, pipeline_replay,
  pipeline_driver, inbox_pipeline, inbox_bucket, nx_kernel_multi,
  bootstrap_start, http_publish, outbox_publish, smoke_app_pure).

- **2026-06-06** — Step 5a: per-actor :actor_inbox log bucket.
  `nx_kernel.erl` `add_actor/4` now opens a fresh log via
  `log:open/2` with a distinct `inbox_base_stub()` for each new
  bucket and stores it as `{actor_inbox, LogState}` alongside the
  existing outbox `:log`. Pure exports `actor_inbox_state/2`,
  `actor_inbox_tip/2`, `append_to_actor_inbox/3` + gen_server
  exports `inbox_tip_for/1`, `inbox_state_for/1`, `append_inbox/2`.
  `log:append/2` is `(LogState, Activity) -> {ok, NewState, Seq}` —
  noted for future iterations. Inbox / outbox tips are fully
  independent. `next/tests/inbox_bucket.sh` 14/14. Conformance
  761/761. 125/125 across 7 Step-5-adjacent suites
  (inbox_bucket, nx_kernel_multi, nx_kernel_server,
  bootstrap_start, http_publish, http_multi_actor, actor_lifecycle,
  smoke_app_pure).

- **2026-06-06** — Step 4d: per-actor outbox listing + pagination.
  New `nx_kernel:log_state_for/1` gen_server export returns
  `{ok, LogState}` for an actor. `actor_outbox_response_for/3`
  now extracts `{Tip, Entries}` via `kernel_actor_log_data/2`,
  parses `?page=N` from the Req's `:query` field (threaded
  through Cfg as `{request_query, Q}`), and renders a paged
  body. Text body adds `page: N\nitem: <cid>\n...`; JSON adds
  `"page":N,"items":[...]`; SX adds `:page N :items (...)`.
  Empty pages (out-of-range or actor-with-no-publishes) degrade
  back to the 4c tip-only shape, preserving epochs 50-57.
  `page_size/0` is 5 for tests (production picks 20+). 8 new
  cases in `http_multi_actor.sh` (41/41 total). Conformance
  761/761. 117/117 across 11 Step-4-adjacent suites. **Gotcha**
  noted: named recursive funs `fun F(...) -> ... F(...) end`
  fail with "fun-ref syntax not yet supported"; `binary:matches/2`
  and `lists:foreach/2` aren't registered in this substrate.

- **2026-06-06** — Step 4c: route/3 with kernel access.
  `http_server:route/3(Req, Cfg, Kernel)` folds the kernel
  reference into Cfg as `{kernel, _}`. Dispatch chain refactored
  to thread Cfg through to per-actor sub-resource handlers.
  Outbox handler reads `:kernel` and renders `tip: <N>` (in
  text / JSON / SX content-negotiated variants) when the actor
  exists; falls back to the 4a stub otherwise. Substrate quirk
  found: `try`/`of`/`catch` around `gen_server:call` deadlocks
  in this port's scheduler — bare call + integer guard works.
  Inbox / followers / following handlers accept Cfg but ignore
  it; real state lookup lands in 4d/4e/Step 5+. 8 new cases in
  `http_multi_actor.sh` (33/33 total). Conformance 761/761.
  121/121 across 10 Step-4-adjacent suites. **Gotcha** noted
  for future iterations: avoid try/catch wrapping gen_server
  calls in this substrate.

- **2026-06-06** — Step 4b: token -> ActorId map. Cfg's `:tokens`
  proplist (`[{Token, ActorId}, ...]`) maps bearer tokens to
  per-actor publishers. `handle_post_activity` threads the
  resolved `ActorRef` to `publish_if_kernel/3` which calls
  `nx_kernel:publish_to/2` for explicit actor ids and `publish/1`
  for the back-compat `legacy` atom (M1's `:publish_token`
  single-token field still works as-is). When both fields are
  present, `:tokens` takes precedence; `:publish_token` is the
  fallback on miss. Dead `expected_token/1` helper removed. 8
  new cases in `http_multi_actor.sh` (25/25 total) covering
  two-actor token routing, log-tip isolation, interleaved
  publishes, bad-token 401, back-compat coexistence, no-kernel
  stub path. Conformance 761/761 preserved. 116/116 across 10
  Step-4-adjacent suites.

- **2026-06-06** — Step 4a: per-actor HTTP sub-paths. New
  `split_first_slash/1` helper lets GET / POST `/actors/<id>/...`
  paths route on the sub-segment (`outbox`, `inbox`, `followers`,
  `following`). Four new content-negotiated response stubs
  (`actor_outbox_response_for/2`, `actor_inbox_get_response_for/2`,
  `actor_followers_response_for/2`, `actor_following_response_for/2`)
  with text / json / activity_json / sx variants, mirroring the
  existing `actor_doc_response_for/2` shape. POST
  `/actors/<id>/inbox` returns a 202 Accepted stub
  (`actor_inbox_post_response/0` + `accepted_response/1`); real
  ingestion pipeline lands in Step 5. Unknown sub-paths return
  404. Bare `/actors/<id>` keeps the M1 actor-doc arm intact —
  `http_route` and `http_post_format` regression suites unchanged
  (10/10 each). 17/17 in `http_multi_actor.sh`. Conformance
  761/761 preserved. 120/120 across 10 Step-4-adjacent suites.

- **2026-06-06** — Step 3 (closes Step 3): key rotation via Update.
  `actor_state.erl` `fold_update` routes patches through
  `apply_patch/3` which special-cases `{add_publicKey, KeyProplist}`
  (append + default `:created` to activity's `:published`) and
  `{supersede, OldKeyId}` (mark `:superseded_at`, idempotent).
  Other patch entries still last-write-wins per key. New exports
  `key_history/1`, `active_keys_at/2`, `find_key_by_id/2` give the
  projection-driven view that `envelope:verify_signature/2`
  consumes for time-aware lookup. Rotation-purpose schema gating
  (`rotate-key` purpose check on the rotation activity itself)
  deferred to Step 5 (peer-side stage_signature). `key_rotation.sh`
  16/16 covers rotation arithmetic, key_history preservation,
  active_keys_at at T=pre, T=rotation, T=post, and live
  `envelope:verify_signature/2` round-trips for pre / post / cross
  scenarios including the negative-case post-rotation K1 sig.
  Conformance 761/761 preserved. 132/132 across 9 Step-3-adjacent
  suites (key_rotation, actor_state_pure, actor_lifecycle,
  envelope_sig, envelope_shape, envelope_canonical, nx_kernel_multi,
  bootstrap_start, smoke_app_pure).

- **2026-06-06** — Step 2c (closes Step 2): `bootstrap_actor/4` +
  end-to-end `actor_lifecycle.sh`. New pure-functional export
  `nx_kernel:bootstrap_actor/4(ActorId, Profile, KeySpec, State)`
  adds an actor bucket via `add_actor/4`, derives the kernel AS
  proplist from `Profile`'s `:public_keys`, builds a Create
  envelope wrapping the profile's `:type` (defaults `person`) +
  field set, and calls `publish/3`. gen_server variant
  `bootstrap_actor/3` for live-kernel use plus a corresponding
  `handle_call` branch. `actor_lifecycle.sh` 15/15 covers pure
  bootstrap (log_tip advances, Create-shape, dup detection),
  two-actor independence, gen_server bootstrap, and
  `actor_state` projection capture for Person + Service + Group.
  Step 2 fully closed (2a + 2b + 2c). Conformance 761/761.
  146/146 across 10 Step-2-adjacent suites.

- **2026-06-06** — Step 2b: actor-state projection Erlang module.
  New `next/kernel/actor_state.erl` with `fold/2` over Create / Update
  / Move activities. Profile is a property list of `:type / :name /
  :preferredUsername / :summary / :icon / :public_keys / :moved_to /
  :created`. Create captures fields and `:published` as `:created`;
  duplicate Create is no-overwrite; non-actor Creates and `:actor`-
  less envelopes pass through. Update last-write-wins per patch key.
  Move records `:moved_to`. `fold_fn/0` is a 2-arity Erlang fun for
  `projection:start_link/3` (structural twin of `define_registry`).
  `next/tests/actor_state_pure.sh` 19/19. Conformance 761/761.
  Step-2-adjacent no-regression gate 106/106 across 6 suites.

- **2026-06-06** — Step 2a: genesis Person/Service/Group object-
  types. Three new SX files in `next/genesis/object-types/` with
  the same shape as `note.sx` / `sx-artifact.sx` (`:name`, `:doc`,
  `:schema` checking `(string? (-> obj :name))`). Manifest extended
  to 13 object-types / 34 total entries. `genesis_parse.sh` +7
  cases (57/57). Hardcoded counts bumped in `bootstrap_read.sh`,
  `bootstrap_load.sh`, `bootstrap_populate.sh`, `bootstrap_start.sh`
  (66/66 across those four). `bootstrap_build.sh` 12/12 (bundle CID
  computed dynamically). Conformance 761/761 preserved. 211 / 211
  across 12 Step-2-adjacent suites.

- **2026-06-06** — Step 1b: gen_server multi-actor calls.
  `nx_kernel` exports `add_actor/3`, `publish_to/2`, `log_tip_for/1`,
  `actors/0`, `state_for/1`, `bucket_for/1`,
  `with_projections_for/2` — each is a `gen_server:call` delegating
  to the pure-functional bucket API from 1a. Existing single-actor
  calls untouched. `nx_kernel_multi.sh` extended with 9 gen_server
  cases (26 total); 134 / 134 across 12 nx_kernel-adjacent + http
  suites. Conformance 761/761 preserved. Per-actor mailbox sharding
  noted as forward-looking — current single gen_server serialises
  publishes across actors, which is fine for Steps 1-3 (single-actor
  HTTP endpoints) and is naturally untangled by Step 4's per-actor
  routing.

- **2026-06-06** — Step 1a: per-actor bucket refactor of `nx_kernel`.
  State shape now `[{actors, [{Id, Bucket}, …]}, {next_actor_seq, N}]`;
  added pure-functional multi-actor APIs (`new/0`, `add_actor/4`,
  `has_actor/2`, `actors/1`, `publish/3`, per-actor accessors,
  `with_actor_projections/3`). Legacy single-actor accessors
  preserved as bucket-0 lookups so every M1 test continues to
  pass via `bootstrap:start/3` → `new/3` → first-bucket read.
  Local `has_keyed`/`find_keyed`/`set_keyed`/`set_bucket` helpers
  cover the keyed-list ops since `lists:keymember`/`keyfind` aren't
  registered in this substrate. New test suite
  `next/tests/nx_kernel_multi.sh` 17/17; all M1 nx_kernel-adjacent
  suites green (`bootstrap_start`, `nx_kernel_server`, `http_publish`,
  `smoke_app_pure`, `http_post_format`, `http_publish_fold`,
  `http_marshal`). Erlang conformance 761/761 preserved.
