# SocialSecretary Activity Log Specification
## Version 1

*What SocialSecretary did, in the order it did it.*

The Handoff Envelope Specification defines what a client tells SocialSecretary.
The Mailbox Specification defines what the Fediverse tells the client, through
SocialSecretary. This document defines the third and last direction: what
SocialSecretary itself did with both — every envelope it accepted or refused,
every handle it resolved or could not, every activity it built, every inbox
it delivered to or failed to reach, every follower it gained or lost.

Together the three specifications are the complete interface. A client that
reads the log needs no hook, callback, or API to learn the outcome of what it
sent. The outcome is a file, like everything else.

---

## The Contract in One Paragraph

SocialSecretary appends one line of JSON to the current log file for every
event this specification defines, in both directions, without exception:
every envelope Secretary reads reaches exactly one of four terminal
outcomes and every one is logged (see Terminal events, under Events —
Outbound); every activity that passes verification is logged exactly once,
whether or not it was routed anywhere (see Events — Inbound). Nothing is
left out because it seemed unimportant — that completeness is the reason
this log exists rather than the console. It never rewrites, reorders, or
deletes a line. When the current file grows past a configured size, it starts
a new one with a later name. A client that reads every file in the log
directory, in name order, has the whole history; a client that reads only the
most recent file, or only the lines that mention an id it knows, has exactly
the part it wanted and nothing misleading. That is all there is to it.

---

## Location

```
<baseDir>/actors/<domain>/<actor>/log/
  activity_log-20260408T000000Z.jsonl
  activity_log-20260702T113045Z.jsonl
  activity_log-20260915T141200Z.jsonl      ← current
```

`baseDir`, `domain`, and `actor` come from `socialsecretary_config.mjs`.
The directory is created on first write. A client should treat a missing
directory as "nothing has been logged yet."

### Filename

```
activity_log-<UTC timestamp of the file's first line, compact>.jsonl
```

The timestamp is that of the first line the file was opened to hold, in
compact ISO form (no separators, no milliseconds, trailing `Z`). Files
therefore sort into chronological order under any plain name sort, and
every line in a file is later than every line in every file with a lesser
name. Nothing else is encoded in the name. The line contents are
authoritative; the name only orders the files.

### The definitive reading rule

**To reconstruct the complete history: read every `*.jsonl` file in the
directory, in ascending name order.** There is no manifest, no index of
files, no "current" pointer. That concatenation is the log.

Most clients do not want the complete history, and should not read it. See
"Reading Efficiently" below, which describes the partial reads this
specification expects and supports.

### Rollover

Secretary appends to the file with the greatest name. Before an append that
would carry that file past `logRollSizeMB` (config, default 50), Secretary
starts a new file named for the line about to be written and appends there
instead. A quiet site has one file forever; a busy site rolls when it needs
to. No schedule is set, and none should be.

Names have one-second resolution, so a file can exceed the limit in exactly
one case: more than `logRollSizeMB` arrived within a single second, and the
replacement file would have had the same name. At the default this does not
happen in practice, and a client should not assume a hard ceiling on file
size in any case.

At 50 MB a file holds on the order of 150,000–300,000 lines. That is not
dangerously large, but it is not trivial either, and it is the reason the
efficient-reading section exists.

### Appends

Each line is written with a single append of the complete line including its
trailing newline. On a local POSIX filesystem an append of this size is not
interleaved with other appends, so records are never corrupted by
concurrency. A reader that catches a line in the instant between the write
starting and completing may see a partial final line; a client reading a
file while Secretary is running should tolerate a final line that fails to
parse, and re-read it later.

---

## What a Client May Assume — and May Not

The log is designed for a reader who has less than all of it.

**Older files may be deleted, and this is expected.** An operator who does
not want gigabytes of history removes any file other than the current one,
at any time, with no notice to Secretary. This is a supported operation,
not a failure mode. A client reading a set with gaps loses the events that
were in the missing files, and nothing else.

**The current file may be deleted too.** Secretary remembers which file it
opened. If that file is gone at the next write, Secretary starts a new one
named for that write, rather than appending to an older file. A later line
therefore never lands in an earlier-named file, and name order stays
chronological order regardless of what the operator removed.

**No line depends on any other line.** Every line carries, in full, every
id needed to understand it. Secretary never writes a "continued from
previous file" line, a "state at rollover" summary, or any line whose meaning
requires a line before it. This is the property that makes the two previous
paragraphs true, and it is a constraint on Secretary, not a courtesy.

**Files must not be renamed.** Name order is chronological order. A renamed
file is misplaced in time. Secretary sets permissions so that only its own
user can write to the directory; a client with group membership can read and
cannot rename. Beyond that, the log is the operator's to keep or lose.

**Unknown events and fields must be ignored.** A later Secretary may add
events and add fields to existing events. It will not remove a field from an
event or change a field's meaning without incrementing the line version
(below). A client that skips what it does not recognise keeps working.

**Lines are not deduplicated.** If Secretary restarts mid-broadcast and the
operator re-queues the envelope, the second run logs its own events. The log
records what happened, including things that happened twice.

---

## Line Shape

Every line is one JSON object, with no newlines inside it, terminated by
`\n`.

```json
{"v":1,"at":"2026-09-15T14:12:00.442Z","direction":"outbound","event":"delivery.succeeded","secObjectId":"20260915T141159Z-note-7f3k9q","sourceEnvelopeId":"my-post-20260915141130","activityId":"https://example.com/activities/create-20260915T141159Z-note-7f3k9q","inbox":"https://mastodon.social/inbox","status":202}
```

### Field conventions

These hold for every field on every line, and a client may rely on them.

- **A field is present with a value, or absent. Never `null`.** A field
  marked *optional* below is one that may be absent; when present it has a
  value of the stated type. `"error" in line` and `line.error !== undefined`
  give the same answer.
- **Every field has one type, always.** `at` is a string; `status` is a
  number; `to` and `cc` are arrays of strings; nothing is sometimes one and
  sometimes another.
- **`error` is always a string.** Secretary coerces whatever it caught —
  an `Error`, a string, anything — to `String(e.message ?? e)` before
  writing. It never serialises an error object, which JSON would render as
  `{}` and a client would find unreadable.
- **Strings are never truncated.** A URL, a handle, an error message appear
  whole. Content does not appear at all; the log records what happened to a
  post, not the post.

### Fields present on every line

| Field | Type | Meaning |
|---|---|---|
| `v` | number | Line schema version. `1` for this document. A client should skip lines whose `v` it does not understand. |
| `at` | string | ISO 8601 UTC with milliseconds: when Secretary wrote the line. Secretary's clock. |
| `direction` | string | `"outbound"` — Secretary acting on a client's behalf; `"inbound"` — the Fediverse acting on Secretary. |
| `event` | string | What happened. One of the events below. Dotted: `<subject>.<verb>`. |

### Identifying fields, present when known

These appear on any line where Secretary knows the value at the time of
writing. A client filters on whichever one it has.

| Field | Type | Meaning |
|---|---|---|
| `sourceEnvelopeId` | string | The `id` the client put in its envelope. **This is the field a client filters by.** It appears on every outbound line from acceptance onward. |
| `secObjectId` | string | Secretary's own id for the archived object — the archive filename without `.json`. Appears on every outbound line from acceptance onward. |
| `activityId` | string | The `id` of the ActivityPub activity Secretary built and sent, exactly as it went over the wire. Appears from `activity.built` onward. For inbound lines, the `id` of the activity received. |
| `objectUrl` | string | For outbound content, the canonical post URL (envelope `url`). For likes, boosts, and undos, the remote post acted on. For inbound, the activity's `object` (a URL, or the `id` of an embedded object). |
| `actor` | string | Inbound: the remote actor URL that sent the activity. Outbound `accept.*` and `follower.*`: the remote actor concerned. |
| `inbox` | string | The remote inbox URL a delivery went to. |

### Event-specific fields

Listed with each event. A field named there is present on every line of that
event; a field marked *optional* may be absent.

---

## Events — Outbound

Outbound events narrate the life of an envelope from the moment Secretary
reads it. In normal operation a content envelope produces this sequence:

```
envelope.accepted
handle.resolved / handle.unresolved   (one per handle in mentions + to)
attachment.skipped                    (per attachment dropped, if any)
activity.built
delivery.filtered                     (once, only if deliveryAllowlist excluded targets)
delivery.succeeded / delivery.failed  (one per inbox actually attempted)
inbox.pruned / retry.queued           (per hard / soft failure)
broadcast.recorded  or  broadcast.deferred
watermark.advanced                    (once per run, after all envelopes)
```

A like, boost, or undo produces the same sequence with one delivery and no
watermark.

### Terminal events

**Every envelope Secretary reads produces exactly one of these four events
per run**, and it is the last line for that `sourceEnvelopeId` in that run:

| Event | Meaning |
|---|---|
| `broadcast.recorded` | At least one inbox got it. |
| `broadcast.deferred` | No inbox got it; every delivery was a soft failure and is queued for retry. |
| `envelope.skipped` | Secretary declined to act, for a stated reason. |
| `envelope.failed` | Processing threw; the envelope is in `failed/`. |

A client waiting for the outcome of an envelope it just wrote waits for
whichever of these appears first. A deferred envelope produces further
`retry.*` lines on later runs, ending in `retry.succeeded` or
`retry.abandoned` per inbox; a client that cares about eventual delivery
follows those, and a client that only wants to know the run is over does
not have to.

### `envelope.accepted`

Secretary read the envelope from `outbox/`, found it well-formed, and created
its archive record. From here the envelope is Secretary's responsibility.
This is the earliest line that can carry a given `sourceEnvelopeId`.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId` | |
| `secObjectId` | |
| `envelopeType` | `create`, `update`, `delete`, `like`, `boost`, `undo` |
| `objectUrl` | *optional* — absent when the envelope had none |

### `envelope.skipped` *(terminal)*

Secretary read the envelope and will not act on it. The file is removed from
`outbox/`. Not an error: this is the watermark doing its job, or a delete
for a post Secretary never sent.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId` | |
| `envelopeType` | |
| `reason` | `unknown-type` — the envelope's `type` is not one of the six this pipeline knows (`create`, `update`, `delete`, `like`, `boost`, `undo`); logged rather than silently dropped so a client using a type this version does not recognise learns that at once, not by absence; `below-watermark` — `published`/`updated` is not newer than the last broadcast; `missing-published` — a content envelope with no date; `unknown-object` — an update, delete, or undo referencing something not in the archive; `not-undoable` — an undo whose target is not a like or boost |

### `envelope.rejected`

Secretary could not begin acting on this envelope, for a reason outside
the envelope's own content. Two cases, distinguished by which fields are
present:

- The file could not be parsed as JSON. No envelope id was ever known.
  The file is left in `outbox/`; Secretary logs this again on every run
  until the operator removes or fixes it.
- The file parsed fine — its `sourceEnvelopeId` and `envelopeType` are
  known — but a filesystem operation on it failed before processing could
  start (moving it into `processing/`, most commonly). The file is left in
  `outbox/` too, but this case is ordinarily transient: the same envelope
  is simply tried again next run without operator action.

| Field | Meaning |
|---|---|
| `file` | filename in `outbox/` — present for the parse-failure case only |
| `sourceEnvelopeId`, `envelopeType` | present for the second case only, when the envelope itself was readable |
| `reason` | the parse error, or the underlying filesystem error |

### `envelope.failed` *(terminal)*

Processing threw after the envelope was read. The envelope was moved to
`failed/` with a sibling `.error.txt`. It will not be retried.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId` | |
| `secObjectId` | *optional* — absent if the throw preceded acceptance |
| `error` | |

### `handle.resolved`

A handle in `mentions` or `to` resolved via WebFinger to an actor URL.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `secObjectId` | |
| `handle` | as given in the envelope, `@user@domain` |
| `actorUrl` | |
| `source` | `mentions`, `to`, or `mentions,to` if the handle appeared in both |

### `handle.unresolved`

A handle in `mentions` or `to` did not resolve. **The broadcast proceeded
without it**: no Mention tag, no notification, no addressing. This is the
line that closes the silence between a mistyped handle and the person who
was never notified.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `secObjectId` | |
| `handle` | |
| `source` | as above |
| `error` | why: `invalid handle format`, `GET … returned 404`, `no ActivityPub self link`, a timeout, and so on |

### `attachment.skipped`

An attachment in the envelope will not be sent. Every attachment Secretary
drops produces this line — silently dropping media a publisher attached is
exactly the kind of decision this log exists to make visible, whether or
not anyone is watching the console at the time.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `secObjectId` | |
| `url` | the attachment's own URL, as given in the envelope |
| `mediaType` | as given in the envelope |
| `reason` | `unsupported-media-type` — not image/video/audio of a type Mastodon accepts; `max-attachments` — the envelope had more than `config.maxAttachments` (default 4) and this one was past the limit, counted in the order the envelope listed them |

### `activity.built`

Secretary constructed the ActivityPub activity it is about to send. The
`activityId` on this line is the id on the wire; it is also written to the
archive record.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `secObjectId`, `activityId` | |
| `activityType` | `Create`, `Update`, `Delete`, `Like`, `Announce`, `Undo` |
| `objectUrl` | the activity's object: the Note id, the Tombstone id, the remote post, or — for `Undo` — the id of the Like or Announce being undone |
| `to` | array of addressing URIs |
| `cc` | array of addressing URIs |

### `delivery.filtered`

`config.deliveryAllowlist` is set and excluded one or more otherwise-valid
targets from this delivery. One line per envelope (not per excluded inbox —
this is a policy decision applied once, not a delivery outcome for each
target), so a client can tell "nothing was attempted for N targets, on
purpose" apart from every target simply not existing.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `secObjectId`, `activityId` | |
| `reason` | `allowlist` |
| `filtered` | count of targets excluded |
| `attempted` | count of targets that remained and were actually delivered to |

### `delivery.succeeded`

One inbox returned 2xx.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `secObjectId`, `activityId`, `inbox` | |
| `status` | HTTP status |

### `delivery.failed`

One inbox did not return 2xx.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `secObjectId`, `activityId`, `inbox` | |
| `severity` | `hard` — 404 or 410; the inbox is gone and will be pruned. `soft` — anything else; a retry is queued. |
| `status` | *optional* — HTTP status when there was a response |
| `error` | *optional* — when there was no response: timeout, DNS, connection refused |

### `inbox.pruned`

A hard failure removed an inbox — and every follower behind it — from
`followers.json`.

| Field | Meaning |
|---|---|
| `inbox` | |
| `status` | the 404 or 410 |
| `sourceEnvelopeId`, `secObjectId` | the delivery that found it |

### `retry.queued`

A soft failure was written to `retry.json`. The stored body is the exact
bytes that failed.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `secObjectId`, `activityId`, `inbox` | |
| `dueAt` | ISO 8601 |

### `retry.succeeded`

A due retry delivered.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `secObjectId`, `activityId`, `inbox` | |
| `attempts` | count including this one |

### `retry.failed`

A due retry soft-failed again and was rescheduled.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `secObjectId`, `activityId`, `inbox` | |
| `attempts`, `dueAt` | |
| `status` / `error` | as `delivery.failed` |

### `retry.abandoned`

A retry entry was dropped without delivering: either it reached
`retryMaxAttempts`, or it was written by a version of Secretary that did not
store the body and cannot be replayed faithfully.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `inbox` | |
| `secObjectId`, `activityId` | *optional* — absent for pre-1.0.1 entries |
| `reason` | `max-attempts` or `no-stored-body` |
| `attempts` | |

### `broadcast.recorded` *(terminal)*

The archive record's transport state was advanced. For content this means at
least one inbox succeeded; for a like, boost, or undo it means the one inbox
did.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `secObjectId`, `activityId` | |
| `delivered` | inboxes that succeeded on this attempt |
| `attempted` | inboxes attempted |

### `broadcast.deferred` *(terminal)*

No inbox succeeded and none failed hard: every delivery is queued for
retry. The archive record stays `queued` — nothing has been delivered
anywhere — with `lastError` set. The envelope is done for this run; the
retries are not.

| Field | Meaning |
|---|---|
| `sourceEnvelopeId`, `secObjectId`, `activityId` | |
| `attempted` | inboxes attempted, all of which are now in `retry.json` |

### `watermark.advanced`

`last_broadcast.txt` was written. Once per run that broadcast anything.

| Field | Meaning |
|---|---|
| `watermark` | the new value, ISO 8601 |

### `accept.sent` / `accept.failed`

Secretary answered an inbound `Follow` with a signed `Accept`. Outbound in
direction — it is a send — though it is triggered by an inbound activity.
`accept.failed` is not retried by Secretary: the follower's server re-sends
the Follow if it does not receive an Accept, and Secretary answers again.

| Field | Meaning |
|---|---|
| `actor` | the follower |
| `inbox` | |
| `activityId` | *optional* — the id of the Follow being accepted |
| `error` | `accept.failed` only |

---

## Events — Inbound

Inbound events record what arrived at `/inbox` and what Secretary did with
it. Every activity that reaches the routing step is logged exactly once,
whether or not it was routed to a mailbox; that is the property the previous
log had, kept.

### `activity.received`

An activity passed signature verification and the denylist. This is the
inbound line; the `disposition` says what happened next.

| Field | Meaning |
|---|---|
| `activityType` | `Create`, `Like`, `Announce`, `Follow`, `Undo`, `Delete`, `Accept`, … |
| `actor` | |
| `activityId` | *optional* — the activity's own `id`, when it had one |
| `objectUrl` | *optional* — as defined above |
| `disposition` | `routed` — written to a mailbox; `handled` — acted on by Secretary itself (Follow, Undo(Follow), a follower's own deletion); `dropped` — logged and not acted on |
| `mailbox` | *optional* — `replies`, `mentions`, `private`, `likes`, `boosts`, `deletions`, `other`; present when `disposition` is `routed` |
| `file` | *optional* — the mailbox filename written; present when `routed` |
| `reason` | *optional* — present when `dropped`: `unhandled-type`, `unmatched-delete`, `unmatched-undo`, `no-inbox`, … |

### `activity.rejected`

An activity failed before routing. It was answered and discarded. New in
1.0.1: previously rejections went only to the console.

| Field | Meaning |
|---|---|
| `activityType` | *optional* — if the body parsed |
| `actor` | *optional* — as claimed in the body, unverified |
| `reason` | `unparseable`, `signature`, `denylist` |

### `follower.added` / `follower.removed`

`followers.json` changed. `added` follows a `Follow`; `removed` follows an
`Undo(Follow)` or a `Delete` of the follower's own actor. Pruning after a
delivery failure is `inbox.pruned`, above, not this.

| Field | Meaning |
|---|---|
| `actor` | |
| `inbox` | |
| `reason` | `removed` only: `undo-follow` or `actor-deleted` |

### `dereference.failed`

Someone requested `GET /activities/:id` or `GET /objects/:id` with an id
that looks like one Secretary could have generated — it matches the known
shape — but nothing at that id resolves the way the shape implies. This is
new since 1.0.1, added after a real gap: for a stretch of this project's
history, every activity id but `create-` 404'd on dereference, and nothing
recorded that it was happening — the gap was found by deliberately going
looking for it, not because anything surfaced it. This event is what
would have surfaced it on its own, within days rather than however long it
actually took.

Deliberately asymmetric with every other event pair in this specification:
there is no `dereference.succeeded`. A successful fetch is exactly what is
supposed to happen, indistinguishable from ordinary web traffic, and
logging every one would mean logging every crawler and every legitimate
Mastodon fetch alongside it — noise with no diagnostic value. Only the
failing case earns a line, and only when the id was plausible to begin
with: something that doesn't even match a known shape — a scanner, a typo,
unrelated junk — is not logged either. The `reason` field below only ever
describes an id that looked like it should have worked.

`direction` is `inbound` for this event, by this specification's own
definition of the word — "the Fediverse acting on Secretary" — even though
no ActivityPub activity and no HTTP Signature is involved. A plain GET from
anyone, verified or not, is still someone from outside acting on Secretary.

| Field | Meaning |
|---|---|
| `path` | The requested path, e.g. `/activities/like-20260917…` or `/objects/20260917…` |
| `reason` | `unknown-object` — the id parses but no record exists for it; `wrong-kind` — a record exists but is a different kind than the id's own prefix claims (an `announce-` id pointing at what is actually a `like` record, say); `not-public` — a record exists, the right kind, but its visibility isn't public, so it is correctly withheld from an endpoint that has no way to authenticate the requester; `unmatched-delete` — a `delete-` id whose target either isn't a note or hasn't actually been deleted |

---

## Reading Efficiently

The definitive rule above reconstructs everything. Almost no client needs
everything, and a client that reads a multi-gigabyte directory to answer
"did the post I wrote ten seconds ago go out" has taken the rule too
literally. The following partial reads are all correct, and this
specification expects clients to use them.

### The three clients

Three kinds of client read this log, and each wants a different slice.

**Fire-and-forget.** The client writes its envelope, fires the trigger, and
exits. It reads the log later, if ever, when the user asks for the status of
something. It wants the most recent lines for one id, and it wants them
fast.

**Synchronous illusion.** The client writes its envelope and holds the
terminal, with a spinner, until the outcome is known. It polls the *current*
file for lines carrying its id, and stops at the first terminal event. It
must never re-read the file from the beginning on each poll.

**Tailing daemon.** A separate process stays open, follows the current file
as `tail -f` does, and presents events as they happen — a dashboard. It
reads only what is appended after it starts, and it must notice rollover:
when a new file appears with a greater name, it moves to that file.

### Techniques

**Reverse name order, stop at the floor.** A client looking for one
`sourceEnvelopeId` reads files newest-first. The first matching line it
finds is that envelope's *latest* state; the `envelope.accepted` line is
its *earliest*, and no older file can hold anything about that id. A client
that wants only the latest state stops at the first match. A client that
wants the whole story stops at `envelope.accepted`. Either way it reads a
small fraction of the directory.

**Match before parsing.** `JSON.parse` on 200,000 lines is a perceptible
pause in a command-line tool; `line.includes('"sourceEnvelopeId":"…"')` on
the same lines is not. Test the raw line for the id first, and parse only
the lines that match. The quoted form of the key and value in the test
keeps `my-post-1` from matching `my-post-10`.

**Stream, don't slurp.** Read a file as a line stream — in Node,
`fs.createReadStream` piped into `readline` — and destroy the stream the
moment the search is satisfied. Reading a 50 MB file into memory to look at
its last kilobyte is the failure mode; streaming from the end, or streaming
from the start and stopping early, is the fix.

**Tail from an offset.** A polling client records the byte length of the
current file at each poll and next time reads only from that offset. A
tailing client does the same, or uses `fs.watch`. Both must handle the
appearance of a new, greater-named file as "the current file has changed."

**Trigger, then read.** Secretary runs its outbound pass on its own
interval and when a client hits the trigger endpoint (see the Handoff
Envelope Specification). A client that writes an envelope and immediately
tails the log without triggering will wait for the interval. Fire the
trigger first.

### A worked example

The synchronous-illusion client, in Node, with no dependencies. It has just
written an envelope with id `ID` and fired the trigger.

```js
import { readdirSync, statSync, createReadStream } from 'fs';
import { createInterface } from 'readline';
import { join } from 'path';

const TERMINAL = new Set([
  'broadcast.recorded', 'broadcast.deferred', 'envelope.skipped', 'envelope.failed'
]);

function currentFile(logDir) {
  const files = readdirSync(logDir).filter(f => f.endsWith('.jsonl')).sort();
  return files.length ? join(logDir, files[files.length - 1]) : null;
}

// Reads lines appended since `offset`, returns the terminal event for ID if
// one arrived, and the new offset. Only lines mentioning ID are parsed.
async function poll(file, offset, needle) {
  const size = statSync(file).size;
  if (size <= offset) return { done: null, offset };
  const rl = createInterface({ input: createReadStream(file, { start: offset }) });
  let done = null;
  for await (const line of rl) {
    if (!line.includes(needle)) continue;
    let ev;
    try { ev = JSON.parse(line); } catch { continue; }   // partial last line: retry next poll
    if (TERMINAL.has(ev.event)) { done = ev; break; }
  }
  rl.close();
  return { done, offset: size };
}

export async function waitFor(logDir, id, timeoutMs = 60_000) {
  const needle = `"sourceEnvelopeId":${JSON.stringify(id)}`;
  let file = currentFile(logDir), offset = file ? statSync(file).size : 0;
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const now = currentFile(logDir);
    if (now !== file) { file = now; offset = 0; }          // rollover: new greater name
    if (file) {
      const r = await poll(file, offset, needle);
      offset = r.offset;
      if (r.done) return r.done;
    }
    await new Promise(res => setTimeout(res, 500));
  }
  return null;                                             // caller decides what to say
}
```

Note the starting offset: the client begins at the *end* of the current
file, because its envelope cannot have produced any line before it was
written. It never reads history it does not need.

---

## Filtering — Examples

The log is meant to be read with ordinary tools. Every example assumes the
actor directory is in `$ACTOR`. These are complete-history reads; for a
large directory, apply the techniques above.

The whole story of one envelope:

```bash
cat $ACTOR/log/*.jsonl | grep '"sourceEnvelopeId":"my-post-20260915141130"'
```

The same, readable:

```bash
cat $ACTOR/log/*.jsonl \
  | jq -c 'select(.sourceEnvelopeId == "my-post-20260915141130") | {at, event, inbox, handle, error, status}'
```

Just the latest state of that envelope — the last line wins:

```bash
cat $ACTOR/log/*.jsonl | grep '"sourceEnvelopeId":"my-post-20260915141130"' | tail -1 | jq .event
```

Every mention that was never notified, ever, with why:

```bash
cat $ACTOR/log/*.jsonl | jq -c 'select(.event == "handle.unresolved") | {at, sourceEnvelopeId, handle, error}'
```

Which instances are failing this week:

```bash
cat $ACTOR/log/*.jsonl \
  | jq -r 'select(.event == "delivery.failed" and .at > "2026-09-08") | .inbox' \
  | sort | uniq -c | sort -rn
```

Watch it happen (the tailing-daemon client, in one line):

```bash
tail -f "$(ls $ACTOR/log/*.jsonl | tail -1)" | jq -c '{at, event, sourceEnvelopeId, inbox, handle, error}'
```

Everything that arrived from one actor:

```bash
cat $ACTOR/log/*.jsonl | jq -c 'select(.direction == "inbound" and .actor == "https://mastodon.social/users/alice")'
```

Followers gained and lost, in order:

```bash
cat $ACTOR/log/*.jsonl | jq -c 'select(.event | startswith("follower.")) | {at, event, actor}'
```

A bash script parses it with `jq`, a Node script with `JSON.parse`, a
person with `tail`. No library is required; that is the point of JSONL.

---

## Relationship to the Archive

The archive (`archive/`) is **state**: one record per object Secretary has
accepted, holding its current transport state and — from 1.0.1 — the
`activityId` of what was sent, so that a later action (an undo) can name it.
The log is **history**: what happened, in order, including everything the
archive record does not keep, such as each individual delivery and each
handle resolution.

A client that needs to *act* reads the archive. A client that needs to
*know what happened* reads the log. They agree, because the same code writes
both, but they answer different questions and neither substitutes for the
other.

## Relationship to the Console

Secretary's console output (the systemd journal) remains what it was: a
running narrative for an operator watching the process. It is not a contract
and its wording may change at any time. Nothing a client needs is only in the
console; anything worth a client's attention is also a log line.

## Lines Written Before 1.0.1

SocialSecretary 1.0.0 wrote `activity_log.jsonl` in the actor directory with
inbound lines only, in this shape:

```json
{"receivedAt":"2026-09-06T18:11:01.442Z","type":"Like","actor":"https://…","object":"https://…"}
```

On first start after upgrade, Secretary moves that file into `log/`, named
for its first line's `receivedAt`, without rewriting its contents, and never
appends to it: new lines go to a new file, so no file mixes the two shapes.
Those
lines have no `v`, `at`, `direction`, or `event`. A client that wants them
can recognise them by the absence of `v` and read `receivedAt` as `at`,
`type` as `activityType`, and `direction` as `inbound`. A client that skips
lines without a recognised `v`, as this specification says it may, will skip
them, and lose nothing it was promised.

---

*SocialSecretary Activity Log Specification — Version 1 — September 2026*
