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.

# 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:

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.