A missing Porkbun record was never the gate: Caddy issues one certificate per
name and every one is in public CT logs, so the *arr hostnames were discoverable
and the edge accepted them. Each of the five vhosts now carries a remote_ip
source gate (LAN proxies through, everything else gets 404) - the posture the
Sonarr comment already claimed. AGENTS.md records the LAN-gate shape as the
required form of internal-only, and Caddy.md records why DNS secrecy is fiction.
Measured 2026-11-04: the router's 80/443 forward plus Caddy's vhost list are the
only gates — a public A record buys discoverability, not access — so internal-
only means an absent record. Publishes the outstanding fact: the whole
authentik-guarded set now carries a record; the exempt self-authenticating names
and the legacy redirects do not. Adds the dynamic-WAN caveat and the 1800 s
negative-cache trap that makes a fresh record look broken.
A 2026-11-04 crash left no trace in the engine's own log and reported
Result=success with NRestarts=1: the unit's ExecStart ends in 'exec podman
wait', whose exit status is 0, while the container's real code is printed to
stdout. The only copy of the reason was the client-facing error text. Both
traps now live in the endpoint page's Laws, with the observed trigger shape
(concurrent triple-digit-thousand-token prefills) and the instruments that
ruled out OOM, GPU hang, MCE and swap pressure.
Calibre-Web, RomM and Seerr sat inside "Media Library Apps" carrying a WIP
badge and a "not open to family users yet" sentence each, so the household
grid mixed doors they can use with doors they cannot. They now render under
their own "Work in progress" heading, placed after AI on the user page, and
the copy drops the restated "Work in progress:" for the ask-Frank line the
section heading no longer needs to repeat.
- app.js: SECTION_ORDER.user gains "Work in progress" last.
- services.json: the three cards' section, plus one-line copy trim.
- homepage_config.bats / homepage_pages.bats: the section contract now
includes the new heading and pins the new order (the old test pinned AI
last, which this change deliberately overturns).
- Homepage.md: the classification list and the SECTION_ORDER/wip contract
lines catch up (the user order in the page was already stale, and "down"
was an undocumented wip value).
The admin page is untouched: its WIP cards (Gluetun, mariadb, valkey,
Postgres) are plumbing inside Infrastructure, not applications.
The picker's decoration rows carried two payload-swap drifts: frank-dragoon
still named halogen 0.16.2 (live 0.18.20) and frank-zealot build 10777 /
3x262,144 / MTP off (live build 12118 / 3x200,704 / draft-mtp armed). Worse,
their params.context.length values had swapped, so OpenWebUI advertised more
window than the zealot slot grants and gave up 61k tokens on dragoon. Live
rows re-keyed through the admin API and ui.default_models set to
frank-archon; the swap procedure in AGENTS.md now names the re-key step.
Jellyfin authenticates against Authentik over LDAP, so telling the household
that it "will not take the family password" sent people to the wrong door. The
tile's provider id (alex -> LdapAuthenticationProviderPlugin) and a live bind +
memberOf search against the running outpost are the proof; six places repeated
the claim, and all six now say to sign in with the franksdank.cloud username and
password. Added the failure mode that actually happens: a password that works on
the homepage but is refused here means the name is not in jellyfin_users yet.
Plex and the e-book door stay the two genuinely separate accounts.
Marreta's tile loses its VPN/headless-browser mechanics and keeps one honest
clause: "does not work on all sites".
The models page gains the TTS pair the household keeps meeting: frank-tts
(Qwen3 talker + 12 Hz codec on the iGPU, five cloned voices, live list at
/v1/audio/voices) and Kokoro (82M, stock presets, runs in the user's own
browser) - which is why the AI chat's speaker list never matches the server's.
Verified in a browser against an isolated copy of the tree: both tiles render
the new copy, #models carries both tables, zero broken images, and every
retired phrase is absent from the rendered text. ./tests/run.sh: 841 pass.
Provisioned jason (pk 24, human-user template) and drove his first SSO
login in a clean browser: the row appeared as role='user' with chat and
wilwheaton TTS working, no manual step. The pending-then-flip law is
therefore wrong for a claim-carrying account - ENABLE_OAUTH_ROLE_MANAGEMENT
+ the roles scope decide it, and 0.12.5 refuses a claim-less login with 403
instead of falling back to ui.default_user_role. Census gains jason.
Diagnosed 'authentik login does not work in Jellyfin' 2026-11-04: nothing was
broken. The Jellyfin admin account 'dank' is a LOCAL-provider account, and the
operator's authentik identity (akadmin) was not in jellyfin_users, so the plugin
search found nothing and Jellyfin fell back to local auth - same UI message,
opposite fix. akadmin is now in jellyfin_users (directory data, DB-owned).
- Jellyfin: the admin-filter law (DN match, so a group-OU base grants nothing;
memberOf shape is the working one), the AuthenticationProviderId diagnostic,
re-verified LDAP path (bind + memberOf search from Dankserver).
- Authentik Outpost Reference: settle the token-intent argument (the plugin
sends a password, never a token), verify the bind DN live, and record the
instruments/traps: the outpost journal never shows a bind, Event is keyed by
created/action, Outpost has no apps, and piping ak shell steals its heredoc.
- Authentik: stop restating the LDAP outpost digest - the unit file owns it.
Both drivers' help text recommended `--after "$(stat -c %Y <journal>)"`. Measured
2026-11-04 that epoch is answered by whatever scan finished before the question was
asked: on Jellyfin it reads an earlier task's EndTimeUtc, and the Plex door cannot be
a waiter at all, since PMS stamps section_locations.scanned_at when it accepts the
request. The laws are on the vault pages (main 146c28d); this puts the warning at
point of use, in the text an agent reads before calling the door.
`status` compares a recorded timestamp with `--after`. Measured 2026-11-04: Plex
answered `settled` in the same second its own trigger was accepted (stamp
19:46:59Z for a request accepted 19:46:58Z) and still answered `settled` from that
one stamp 30 minutes later while the walk ran. So no epoch survives it — a journal
mtime fails and the trigger's epoch fails, because the trigger satisfies the door it
was asked to wait for. That is the correction of b82655d, which prescribed the
trigger's epoch for this door.
Jellyfin's `EndTimeUtc` only advances when a scan finishes, so there the trigger's
epoch is sound and a file mtime is not; its `wait` already refuses an epoch an
earlier task passes. The proof a scan did its work stays `media-verify.py`
replaying the journal: a scan can clear rows but cannot invent one.
The page's own example passed the journal mtime. Measured 2026-11-04: a chain
using it fell straight through its wait loop, because status only asks whether
the section has scanned since that epoch and the section had been walked earlier
the same day. The verify that followed read a database that had not yet processed
that day's renames — a false success, which is the direction that gets believed.
`wilwheaton` kept failing in OpenWebUI with the 29-name Kokoro enum even
after the dropdown was set to Default. The page's 2026-10-18 conclusion —
"no user.settings row carries an audio override, so the choice lives in that
browser's own storage" — was a probe-depth bug: the personal audio settings
live in table `user`, and the 0.12.x front end nests them under `ui`
(`settings.ui.audio.tts.engine`). The row did carry `browser-kokoro`, and the
SPA re-applies the server copy on every load, so the UI fix never stuck.
Corrected facts on the owner page:
- where the override really lives, and the version-dependent nesting;
- repair BOTH engine and voice: engine "" still sends the stored voice, so a
Kokoro-era `voice: "alloy"` returns as frank-tts's own 400;
- the one-line tells: an `/wasm/ort-wasm-simd-threaded.jsep.wasm` fetch means
Kokoro.js is live in that browser, and uvicorn logs every request so a
missing `/api/v1/audio/speech` line is evidence;
- deployed build is 0.12.5 (pin `0b399a8b`, bumped 2026-10-30) — the page
still claimed 0.11.4 — and the 401 paragraph's duplicated sentence is gone.
Verified 2026-11-04: with the row repaired, `POST /api/v1/audio/speech` as a
real user over the vhost returns 200 audio/mpeg and frank-tts logs
`speech voice=wilwheaton`.
The duplicate-prune, TV-duplicate, Ghibli and promo-clip holds went out of the
array trash on Frank's word: ~1.2 TB back. This replaces the three "purge pending
Frank's word" rows with what happened.
Two procedures the page did not carry:
- The purge gate. `REAL: 0` in both databases only says no row points at a moved
path; it says nothing about whether the film survived. Every held file needs a
same-work carrier in the live library at >= its size, re-derived at purge time,
because the relayout renames folders AND keeper files, accents defeat ASCII
matching (Nausicaä), and a carrier is usually another container at another
size. That supersedes the 2026-11-01 "re-verify by size" law, which is exactly
the test that reports survivors as lost.
- The purge mechanics. The repo purger acts on a root's own .AGENTTRASH and
nothing else, so a partial purge is staged into a scratch root while the
unapproved buckets sit parked at the array root as .HOLD-<date>-*.
./tests/run.sh: 824 assertions, all pass.
Plex.md and Jellyfin.md keep every law and point at the driver that now carries
them, so the procedure has one owner and the pages keep the failure signatures.
Jellyfin.md also records where `jellyfin.db` actually lives on this host (the
quadlet's config bind, not the stock path) and demotes `immutable=1` on the
`ApiKeys` read to the `-shm` fallback, because the database is WAL and
`immutable` skips the log. Plex.md gains the live section keys and the
`scanned_at == scannedAt` fact.
Media Relayout.md's hand-off after an apply names both drivers and states the
ordering law: Jellyfin first and alone, since its one door walks every library
while Plex forks a scanner per section, and the array is what both share.
Media Duplicate Prune.md gains the guard the 2026-11-04 hold taught: 32 files
carrying real film titles were 8-20-second h264 clips (49 MiB, 25 of them in a
`_release-group watermarks/` folder), invisible to every duplicate screen
because nothing shares their size and the feature-length guard hides them. The
census that finds the next batch is a duration floor, not a hash.
Both media servers' scan procedure lived only as a hand-written curl block in
the vault, and that block has bitten this fleet twice: the escaped-quote form
that reads exactly like a dead API key, and a config `url` line meeting a
command-line URL so curl quietly performs a second transfer and dumps the whole
task table on your stdout. Jellyfin's door is now
`scripts/media-refresh-jellyfin.py` (trigger / status / wait) and Plex's is
`scripts/media-refresh-plex.sh` (trigger / status);
`tests/media_refresh.bats` pins the contracts an operator observes, against a
stub and with no live server.
The Jellyfin `wait` encodes the settle law instead of a poll count: the Scan
Media Library task must be seen Running — or finish while watching, or already
pass the `--after` it is given — before any quiet read counts toward settled.
A watcher that saw no Running otherwise reports the pre-scan library as the
fresh one, which is how a rename looks applied while nothing has rescanned.
Plex's `status` needs no token at all: `section_locations.scanned_at` carries
the same epoch the API prints as `scannedAt` (measured on section 1 today).
Neither driver lets a secret reach argv or a URL: the Jellyfin key rides a
mode-600 curl config as the quoted MediaBrowser form, and the Plex token is
read inside the container, where the HTTPD actually answers, into a config file
that is shredded there.
frank-archon.md's intro promised 2 conversations while its own shape table said
MAX_SEQS=4; AGENTS.md carried --max-num-seqs 2 and a 44,928 MiB host pool against
a live 4 and 49,152. Both now quote the env.
dankclaw-iommu-translation-off.md re-transcribed frank-dragoon's boot ledger,
including a pool figure that was superseded the same day, and asserted in the
present tense that no NPU endpoint is deployed - falsified by the XDNA2 embedding
lane on 2026-10-19. Both facts now belong to [[frank-dragoon]] alone.
Deployment Flow.md had a sentence broken by an earlier link removal.
Census: every tunable in the three scripts/<svc>.env against its endpoint page
(only truncated-sha and elided-path forms, which the pages omit on purpose), live
/health + /v1/models on all three endpoints, check-okf.py clean at 125 pages, no
duplicate titles, no page unlinked from an index.
Three numbers on the family-facing model cards had drifted from scripts/*.env:
archon said three submissions where FRANK_ARCHON_MAX_SEQS=4 (1bb6399), zealot
said 262k context where the contract is 200,704 a slot (CTX_SIZE 602112 / PARALLEL
3, and llama.cpp 400s over the slot rather than truncating), and zealot's warning
still promised censored-base refusals after the Uncensored-LoRA went into the load
line (LORA_FILE set, LORA_SCALE=1). archon's role dropped an unverifiable
"fastest"; zealot's card dropped a tokens/s figure nobody is comparing.
Deletes the assertion that zealot's warning must not claim uncensored output. It
was written 2026-10-17, when the censored base was live; the adapter landed after
it, so the pin now enforces a false safety claim on the one surface the household
reads. The general invariant survives and still fires: a card whose spec says
uncensored or abliterated must carry the legal warning (same seam, lines 100-104).
Task-first instead of app-first: start here, pick what you want to do, open the
door. Every door gets its exact address, the button to press, and the failure the
household actually hits.
Screenshots are files now (img/onboarding/*.webp, 14 of them, all captured logged
in as a standard user) instead of inline data URIs: the page is 23 KB of prose
instead of 30 KB of base64, and the images cache on their own. Zero-JS kept.
Facts re-checked against the live services rather than against the old page: the
front door is www.franksdank.cloud (the apex 308s there); the standard user's
Open WebUI account sees all three models and opens on frank-dragoon (read from
/api/models as that user); WebDAV answers only on the house network (207 on
192.168.0.235:3923, a guard challenge through the public door); Audiobookshelf
iOS is a limited-seat TestFlight beta, Android is the Play release.
shared/homepage/dankserver.services.json was read by nothing: no reference
anywhere in the repo, and deploy_homepage never copies it. It sat beside the
real registry looking like a second live card set, which is exactly how a
future agent gets misled about what the page renders. Moved to the gitignored
shared/homepage/.AGENTTRASH/ beside it, so it stays recoverable.
Jellyfin's card still read "Work in progress: not open to family users yet"
with no link; the server answers 12.4.0 on :8096 and carries a second
(non-admin) account, so the card flips to live status with its Open link on
http://jelly.franksdank.cloud. It has no Authentik link, and the copy says so.
Audiobookshelf moves category admin -> user. A card renders on exactly one
page, so it leaves /admin: Authentik SSO made it a household app, and the
household page is the door that matters.
Marreta's description now names what it is - the content-cleaning proxy in the
Gluetun netns with a headless companion - and its honest limit.
Also in this change:
- docs.html: a "Media & files" line for the audiobook door and its SSO, plus
the pre-existing unclosed <ul> under frank-dragoon, which made the whole
frank-archon block parse inside the dragoon bullet.
- Caddyfile: the jelly. vhost gains the exemption reason the External Exposure
Policy requires (comment only - Caddy strips comments, so no proxy bounce).
- Homepage.md: classification list gains Audiobookshelf, and the section now
states that services.json is the authority for placement/WIP/link.
The SSO paragraph left "whether an access policy should be bound to the
audiobookshelf application" open, which invites the next agent to relitigate
it. Frank's answer of 2026-11-03: accepted on purpose — every signer becomes a
listener with all libraries and downloads, no access policy on the application,
narrowing done per account in Settings -> Users. What that makes load-bearing is
authentik's own signup flow, now named in the same paragraph as the thing that
must stay operator-only.
A wrong path was mistaken for a dead break-glass credential. Local login is
POST <base>/login; /api/* is behind the bearer guard, which answers a bare 401
and logs nothing, so a bad path looks exactly like a bad password. The recorded
value bcrypt-matches users.pash, so local really is the door when authentik is
down, and the rate-limit line was protecting a path that cannot carry a login.
Also records what auto-registration actually grants, measured from the state DB
because GET /api/users returns the limited shape: every SSO signer arrives with
accessAllLibraries and download already on, and carries no local password.
Rewrite the listener onboarding, the role row, and the fail-closed law: the
claim grants every signer, revocation happens on the ABS side (disable or
delete the user), and what actually guards the library is who can reach the
audiobookshelf application in authentik. Name both e2e gates in Verification.
spec 24- signs in through SSO as qa-e2e — an identity in no Audiobookshelf
group — and must land inside the app with type "user". It is the counterpart
to 23-, which pins the admin escalation: together they catch both directions
of a scope-mapping regression (household locked out, or household made site
admin). Verified red against the pre-change mapping — the identity never
received a token — and green after re-provisioning: 4 passed.
Joined to the standard project, which is the only project whose identity
observes the default.