# SocialSecretary — Filesystem Manifest
## socialsecretary.pub, as of 2026-09-20

Every path on the server that belongs to SocialSecretary or to the project
site, with who owns it, what mode it has, and — the column that explains the
other two — what writes to it at runtime. zdat's paths are not here; they
are zdat's manifest's.

The rule behind every row: **a file is owned by whatever writes it, and by
root if nothing does.** Static files are root's because nothing running on
the server should be able to change them. Everything under
`/opt/socialsecretary` is `secretary`'s because Secretary writes there, and
the systemd unit (`ProtectSystem=strict`, `ReadWritePaths=/opt/socialsecretary`)
makes the rest of the filesystem read-only to it regardless of ownership.

Modes: directories `755` unless noted; files `644` unless noted. The actor
data directories carry the setgid bit (`2775`, shown by `ls` as `drwxrwsr-x`)
from their creation in April; it is harmless — new files inherit the
`secretary` group, which is the group that should own them — and is left as
found.

---

## The service — `/opt/socialsecretary/`

Owner `secretary:secretary` throughout.

| Path | Mode | Written by | Notes |
|---|---|---|---|
| `/opt/socialsecretary/` | 755 | release deploy (as root, then `chown -R`) | |
| `index.mjs` `inbound.mjs` `outbound.mjs` `ledger.mjs` `followers.mjs` `signatures.mjs` `watermark.mjs` `config.mjs` | 644 | release deploy | Never at runtime |
| `socialsecretary_config.mjs` | 644 | you, by hand | **Never in a release archive.** Back it up. See L-021 for what happens when a stub lands here |
| `stubs/` and contents | 755 / 644 | release deploy | Templates; never read at runtime |
| `tools/` and contents | 755 / 644 | release deploy | `make-release.sh` and `audit-permissions.sh` 755 |
| `docs/` and contents | 755 / 644 | release deploy | Same files as `public/docs/` on the site |
| `LICENSE` `README.md` `RELEASE-NOTES.md` `RELEASE-NOTES.html` `VERSION` `socialsecretary-signing-key.asc` | 644 | release deploy | `RELEASE-NOTES.html` rendered by pandoc, same as `docs/*.html` (1.0.1) |
| `keys/` | 755 | you, once (INSTALL Step 4) | Secretary reads; never writes |
| `keys/socialsecretary.pub/rob/private.pem` | **600** | you, once (Step 5) | The identity. Readable by `secretary` only. Back up offline |
| `keys/socialsecretary.pub/rob/public.pem` | 644 | you, once | Also embedded in `actor.json` |

### Actor state — `/opt/socialsecretary/actors/socialsecretary.pub/rob/`

Owner `secretary:secretary` throughout. Created by Secretary on first run.

| Path | Mode | Written by | Growth / clearing |
|---|---|---|---|
| `outbox/` | 2775 | the publishing client (zdat) writes envelopes; Secretary removes them | Empty when idle |
| `processing/` | 2775 | Secretary | Empty when idle; stale files warned about, never auto-cleared (L-007) |
| `failed/` | 2775 | Secretary | Empty when healthy; you clear after reading the `.error.txt` |
| `mailboxes/{replies,mentions,private,likes,boosts,deletions,other}/` | 2775 | Secretary writes; the client moves to `processed/` | Bounded only by the client running |
| `mailboxes/processed/*/` | 2775 | the client | Clear at will (OPERATOR §5.5) |
| `archive/` | 2775 | Secretary (`ledger.mjs`) | Permanent record; never clear |
| `archive_summary.json` | 664 | Secretary | Derived |
| `index/by-url/` | 2775 | Secretary (`ledger.mjs`) | Derived from `archive/` |
| `index/by-envelope-id/` | 2775 | Secretary (`ledger.mjs`) | Derived from `archive/`. New 1.0.1 — self-healing: a lookup miss walks `archive/` and writes the entry it finds, so this directory is never load-bearing on its own |
| `index/received/` | 2775 | Secretary (`inbound.mjs`); seeded once by `triage_deletions.mjs` | Derived from `mailboxes/` |
| `followers.json` | 664 | Secretary | The only record of who follows you. Back up |
| `last_broadcast.txt` | 664 | Secretary (`watermark.mjs`) | Derived |
| `retry.json` | 664 | Secretary | `[]` when healthy |
| `log/` | **2750** (not the 2775 the rest of `actors/` uses) | Secretary (`log.mjs`), append only | The third contract (`docs/activity-log-spec.md`). **Do not rotate — do not point logrotate at it** (OPERATOR §5.4): Secretary manages its own growth, starting a new `activity_log-<timestamp>.jsonl` inside this directory whenever the current one would pass `logRollSizeMB`. Superseded the single top-level `activity_log.jsonl` in 1.0.1; on first start after upgrading, that old file is moved in here unmodified, named for its own first line's timestamp, and never appended to again |
| `log/activity_log-<timestamp>.jsonl` | **640** (not the 664 the rest of `actors/` uses) | Secretary (`log.mjs`) | Deliberately narrower than every other file here — `log.mjs`'s own header comment: group-readable so a future client added to the `secretary` group can read the log, but never group-writable, so nothing but Secretary itself can extend or alter an append-only record. A file migrated in from 1.0.0 keeps whatever mode it already had (the migration moves it "without rewriting a byte," metadata included) — L-033 found one still at 664 for exactly this reason; `tools/audit-permissions.sh` checks for it now |
| `denylist.txt` | 664 | you, by hand | Read on every request |
| `inbox/` | 2775 | nothing | Legacy, empty, created by `index.mjs`; removal is a 1.0.1 code change |

## The service user's home — `/opt/secretary/`

| Path | Owner | Mode | Notes |
|---|---|---|---|
| `/opt/secretary/` | `secretary:secretary` | 755 | Home for `$HOME`'s sake; nothing of Secretary's lives here |
| `/opt/secretary/.pm2/` | `secretary:secretary` | — | pm2 state for `zdat-site` only. Goes when zdat migrates to systemd |

## The unit — `/etc/systemd/system/`

| Path | Owner | Mode | Written by |
|---|---|---|---|
| `socialsecretary.service` | `root:root` | 644 | you, from `stubs/socialsecretary.service` |

## The project site — `/opt/sites/socialsecretary.pub/public/`

Owner `root:root` throughout. Read by Caddy (`caddy` user, via world-read).
**Nothing running writes here.** All updates are by hand as root, or by the
release procedure.

| Path | Mode | Purpose | Updated when |
|---|---|---|---|
| `public/` | 755 | site root | — |
| `index.html` | 644 | home page | copy edits |
| `RELEASE-NOTES.html` | 644 | rendered by pandoc | every release, from `RELEASE-NOTES.md` |
| `actor.json` | 644 | ActivityPub identity | profile change, key rotation (never) |
| `avatar.png` | 644 | avatar | — |
| `socialsecretary-signing-key.asc` | 644 | signing key, armored | key renewal (2028-09) |
| `.well-known/` | 755 | | |
| `.well-known/webfinger` | 644 | discovery | handle change (never) |
| `.well-known/openpgpkey/` | 755 | WKD | |
| `.well-known/openpgpkey/policy` | 644 | required by WKD; empty | never |
| `.well-known/openpgpkey/hu/` | 755 | | |
| `.well-known/openpgpkey/hu/xarhuw9jcphm6ir9akb945o6mpabjubu` | 644 | signing key, binary | key renewal |
| `docs/` | 755 | documentation | every release |
| `docs/*.html` (7) | 644 | rendered by pandoc | every release, from `docs/*.md` |
| `docs/*.md` (7) | 644 | sources, offered for download | every release |
| `docs/style.css` | 644 | shared stylesheet | design change |
| `dist/` | 755 | releases | |
| `dist/socialsecretary-<v>.tar.gz` `.sha256` `.asc` | 644 | each release, kept | every release; **never replaced once published** |
| `dist/socialsecretary-latest.tar.gz` `.sha256` `.asc` | 644 | copies of the newest | every release |

## Caddy — `/etc/caddy/`

| Path | Owner | Mode | Notes |
|---|---|---|---|
| `Caddyfile` | `root:root` | 644 | Three owners' routes in one file; the header comment says which is which. Reference copy: `Caddyfile.live` in the project outputs |

---

## Verifying this manifest

All of the following, run together in one pass, is `tools/audit-permissions.sh`
— it exits non-zero if anything below would have printed. The blocks here
stay as a fallback for a box that doesn't have the script yet, and as the
reference the script is checked against, not the other way around: if the
two ever disagree, this table and these blocks are what's right, and the
script gets fixed to match — see `docs/LEDGER.md` L-033 for why that order
matters (the reverse, script-as-ground-truth, is how a real drift went
unnoticed for months).

Run on the server. Anything printed is a deviation.

```bash
# Nothing under /opt/socialsecretary owned by anyone but secretary
sudo find /opt/socialsecretary ! -user secretary -o ! -group secretary

# The private key is 600 and nothing else under keys/ is more permissive than 644
sudo find /opt/socialsecretary/keys -type f ! -name private.pem ! -perm 644
sudo find /opt/socialsecretary/keys -name private.pem ! -perm 600

# No data file under the actor directory is executable
sudo find /opt/socialsecretary/actors -type f -perm /111

# log/ is narrower than the rest of actors/: 2750 not 2775, 640 not 664
# (log.mjs's own design; L-033 found a file that had drifted to 664)
sudo find /opt/socialsecretary/actors -type d -name log ! -perm 2750
sudo find /opt/socialsecretary/actors -path '*/log/*' -type f ! -perm 640

# Nothing under the site root owned by anyone but root, or writable by anyone but root
sudo find /opt/sites/socialsecretary.pub/public ! -user root -o ! -group root
sudo find /opt/sites/socialsecretary.pub/public -perm /022

# The unit and the Caddyfile are root's and not world-writable
ls -l /etc/systemd/system/socialsecretary.service /etc/caddy/Caddyfile
```

To restore the whole state in one pass, in the order it matters — the
broad `actors/` sweep first, then the `log/` exception narrowed back down
afterward, because a later `find` wins over an earlier one on the same
files:

```bash
sudo chown -R secretary:secretary /opt/socialsecretary
sudo find /opt/socialsecretary -type d -exec chmod 755 {} \;
sudo find /opt/socialsecretary -type f -exec chmod 644 {} \;
sudo chmod 755 /opt/socialsecretary/tools/make-release.sh
sudo chmod 755 /opt/socialsecretary/tools/audit-permissions.sh
sudo chmod 600 /opt/socialsecretary/keys/socialsecretary.pub/rob/private.pem
sudo find /opt/socialsecretary/actors -type d -exec chmod 2775 {} \;
sudo find /opt/socialsecretary/actors -type f -exec chmod 664 {} \;
sudo find /opt/socialsecretary/actors -type d -name log -exec chmod 2750 {} \;
sudo find /opt/socialsecretary/actors -path '*/log/*' -type f -exec chmod 640 {} \;
sudo chown -R root:root /opt/sites/socialsecretary.pub/public
sudo find /opt/sites/socialsecretary.pub/public -type d -exec chmod 755 {} \;
sudo find /opt/sites/socialsecretary.pub/public -type f -exec chmod 644 {} \;
```

The second block is idempotent and safe to run at any time; it is what
`sudo chown -R` in OPERATOR §2.1 is shorthand for. Before L-033, this
block had the same drift its own `actors/` sweep would have caused —
resetting `log/` to 2775/664 — since written before that exception
existed; running it today does the same broad sweep, then correctly
narrows `log/` back down in the two lines added for it, rather than
leaving `log/` wherever the sweep left it.
