🏛️

Appolis — Living Breakdown

The hub, Appolis ID, and the Connector · appolis.app
← Appolis · the city of apps

APPOLIS — Master Breakdown (Living Doc)

Appolis — the city of apps, at the apex: https://appolis.app. The master portal that serves every app/creation to people. Tyler is the master admin (sees the whole city, edits the catalog in place); the public sees what's flipped public; unlisted apps travel by share link. The future: PWA installs from the city gate, paid apps.

(1) appolis_compliance_list is bounded by default (todo_2639). With no app it used to return every open directive's full requirement text once PER APP — measured 2026-09-04 at ~98,700 characters in one tool result, on Appolis's own surface, while cmp_18c8b53201 names exactly that defect. Now the no-argument call returns one light row per directive per app (id · title · severity · rev · state · criteria count) from a new compliance.openSummary(); app:<slug> still returns that app's directives in full (what a session acting on them needs); id:<cmp_…> returns one directive in full with every app's state on it; detail:true returns the old everything-in-full shape on request. ⚠️ Bounded by SHAPE, never by truncation — a clipped requirement would read as a shorter requirement, and a session would conclude an app owes less than it does. The tool description says all of this so a session finds the right call without guessing.

**(2) Criterion 12 of cmp_progress0001 (rev 7, issued today — Tyler: "when something completes, whether it's successful or not, you should see an indicator of that as well"). The centred wait popup no longer vanishes on success. progressEnd(result) resolves the SAME card into the outcome ("✓ App saved · 3s · Done.") for 1.8s, then lets it go; api() hands it the outcome on res.ok only — a 4xx never shows a tick; the card goes and the reason takes its place as before — from a by-route DONE_LABELS table beside WAIT_LABELS, so a route added later gets an outcome by construction and every route the portal calls today has a specific one; a quick action that never showed the card still pops nothing (criterion 1 holds); a new wait clears a lingering Done card. /progress.html gains ⑦ The outcome** with a live demo, the build-your-own list carries criterion 12, the footer names rev 7.

🧪 compliance.mjs +5 (the light rows carry no body or acceptance text and are strictly shorter; the full text stays one call away) · progress-driver.mjs +10 (resolves in place · names the outcome · says Done · goes on its own · quick action pops nothing · failure path lets the card go · every route has a specific outcome · api() passes it on success only · page demonstrates ⑦ · list carries criterion 12) — 543 assertions across 11 suites.

VERIFIED LIVE after deploy (parent P-steps): served app.js v0.24.0 · the no-argument compliance list answers under 6,000 characters (was ~98,700) with no acceptance text · /progress.html serves ⑦ and names rev 7 · docs hub serves this entry · appolis attested cmp_progress0001 at rev 7 with the proof page as evidence.)

1 · THE TEST BUTTON'S DEFAULT RECIPIENT LOOKED FOR AN ACCOUNT THAT DOES NOT EXIST. /id/admin/email/test defaulted to from acc_root — a seed id emptyRegistry() creates only on a fresh install. The LIVE registry (13 accounts) has no such row; its master is an ordinary account with role master. So a POST without to answered 400 "no valid recipient address" from deep inside sendEmail, with no hint why. The panel happened to prompt for an address every time, which is how it stayed hidden; the audit's own bare POST found it. Now: prefer the master account, accept the seed id, and if neither exists say so from the route — "give to, or add a master account" — before any send is attempted.

2 · THE SHELL DRESSED AN UNKNOWN APP IN APPOLIS'S COLOURS. themeFor('nope') fell back to Appolis gold and the APPOLIS mark — criterion 10's exact prohibition ("YOUR OWN ACCENT AND MARK, never another app's"), done silently by the shell that publishes the rule. A typo in a slug would have shipped a Kosmos mail dressed as Appolis with every test green. It now throws, naming the fix (add the app to APP_THEME). The one assertion that pinned the old behaviour now pins its opposite.

3 · THE SUITE-DOMAIN GUARD WAS CHECKING THE SETTING AGAINST ITSELF. assertSuiteAddress compared the resolver's address against apexOf(cfg.from).domain — an apex derived from the same config the resolver read — so a From mis-set to no-reply@evil.example would resolve kosmos.evil.example and PASS, both sides wrong together. A guard that cannot fail is decoration. The suite apex is now a constant (SUITE_APEX = 'appolis.app') and the address must sit inside both it and the configured apex.

4 · THE LAST FIVE FIXED-LENGTH TEST WINDOWS (slice(p, p + 1500) and four others) anchored to the next route, so none remain in any suite — one of them lost an assertion to the comment block in item 1 during this very build, which is the whole point.

🧪 528 assertions across 11 suites (+7); 4 mutations killed (shell falls back to Appolis · guard forgets the constant · recipient goes back to the seed id · the missing-recipient message is dropped).

📋 cmp_2b641d8cfe closed — its only target (kosmos) attested on 2026-08-22 and it had sat "open" since; read back after the write.

⚠️ ID_SECRET STAYS A vars ENTRY FOR NOW, AND HERE IS EXACTLY WHY. Moving it to a Wrangler secret (same value, no rotation) was attempted: wrangler secret put ID_SECRET fails with Cloudflare API 10053 "Binding name 'ID_SECRET' already in use" — a var and a secret cannot share a name, and the deployed worker carries it as a var. The only path is a deploy WITHOUT the var, then the secret, then another deploy — a window in which every legacy x-id-internal caller gets a 500. Not this shift: wrangler.jsonc was restored byte-for-byte and no deploy went out without it. The move needs a quiet moment and a caller inventory first; a rotation is a separate carry-over (the value is in git history either way). Filed on the board.

VERIFIED LIVE after deploy: served app.js v0.23.4 · one real test send WITHOUT to answered 200 from the master account's address · the features deck and docs hub carry this entry.)

🔍 HOW IT WAS FOUND, WHICH IS THE PART WORTH KEEPING. Not by anything failing. Tyler said Kosmos and Hermes had signed off, so this lane audited THEIR attestations — seven read-only auditors against the two apps' shipped code and served bytes, two independent skeptics on every serious finding (13 survived, 20 were refuted away). One auditor was pointed at Appolis itself, because Appolis's own row on the email directive still read stale at rev 2 while it had shipped v0.22.0, v0.22.1 and v0.23.x without re-attesting. Nothing in the suite was checking the control centre against the control centre's own rule.

⚠️ EIGHT ASSERTIONS ALREADY COVERED THAT ROUTE AND NONE OF THEM CAUGHT IT — the From on the wire, the fallback, the roster check, the surface label, the health record. Every one was written by somebody thinking about identity, so not one asked what the mail LOOKED like. Same lesson test/sender-identity.mjs opens with: a green suite proves the properties somebody thought to name.

THE FIX. The test mail is rendered by tpl.renderEmail() — the shared shell, both parts from one call — and asked for the app being tested, not for Appolis, so testing Kosmos's sender arrives in Kosmos's accent and mark rather than in ours. The body now states the From line it should have arrived with, so the reader can compare header against body, and surfaces any apex fallback inside the mail, to the one person who can fix an unverified subdomain. Subject: ✉️ <App> · <surface> — email test (the old one said the label twice).

🧪 +9 assertions, 4 mutations killed (drop the html part · ask the shell for Appolis instead of the app under test · remove the From block · stop showing a recorded fallback). ⚠️ Two of the older assertions had silently fallen off the end of a fixed 4600-character source window as the route grew; both slices now end at the next route, not at a magic number.

🔎 TWO SMALLER ONES FROM THE SAME AUDIT. The per-app health row showed the From a send stamped but never the fallback recorded beside it — the table said whether a subdomain is verified now, nothing said what the last real send actually went out as, and those differ for exactly as long as it takes somebody to notice. And public/email.html — the published bar other apps copy from — credited the <style> block with dark mode; dark mode is pinned in the head's meta tags and that block carries only the mobile refinement.

📋 Appolis re-attests cmp_34f01cf47c at rev 3 with this shipped. What this entry does NOT claim: the relay at /id/mail/send still carries the calling app's own body and must — the directive is explicit that the subject and body stay the app's. Only the mails Appolis composes itself are covered.)

⚠️ THE DEFECT FIRST, BECAUSE TWO ENTRIES BELOW THIS ONE ARE WRONG. From v0.10.0 to v0.21.0, lib/email.js sendEmail() built its Resend payload as { from: cfg.from, … } and never read the From the route had computed. v0.20.0's per-app name was calculated, passed and discarded; v0.20.1's test button reported the From it had computed, not the one Resend received; and the v0.20.1 entry's "three identities, one address, visible side by side in one inbox" cannot have happened through the door — those three mails were accepted by Resend as Appolis <no-reply@appolis.app>. Every mail from every app left as Appolis for eleven days. The Kosmos lane's suggestion sug_8523034ccf (08-26, "the From line still says Appolis") was right; the Hermes lane found the line from a real inbox (todo_2152, 08-29) and handed the fix over as todo_2322; the 08-28 weekly brief that called the suggestion stale was wrong and has been corrected on the board. Why no test caught it: the sender-identity suite unit-tested the string builder, and every assertion was green; the one test that mattered — does the derived From reach the payload handed to fetch()? — did not exist. It does now, and the whole suite is anchored to the wire and to the response lines callers actually read.

📮 THE FIX IS NOT "HONOUR msg.from". That would put the address back in a caller's hands. sendEmail now takes the APP — the slug the caller's key proved — and derives the name and the address itself, in one place. There is no from it accepts from anyone, not even one inside our own verified domain (tested with in-suite forgeries). The route passes caller.slug, never who (which can fall back to the body's app for labels), so a legacy-secret caller lands on the apex identity verbatim and gets no surface label either — a label on the apex from a key we cannot name is a caller-typed word on the wire, and "Kosmos" is a word.

🏷 THE ADDRESS IS PER APP. Tyler set up kosmos. hermes. agora. phantasia.appolis.app as sending domains in Resend on 2026-09-01 — DKIM + SPF verified per subdomain (measured via Resend's API and public DNS), DMARC at _dmarc.appolis.app covering every subdomain. Mail now leaves as Kosmos <no-reply@kosmos.appolis.app>, Hermes · Delivery Performance <no-reply@hermes.appolis.app> and so on; the local part follows the apex From, so renaming no-reply once renames all. Appolis itself stays on the apex, and every mail body signs off with its app's own domain (Kosmos · kosmos.appolis.app). Phantasia sends nothing yet but will send sign-in codes; its subdomain is ready.

A SUBDOMAIN IS USED ONLY WHEN RESEND SAYS IT IS VERIFIED — AND THE FALLBACK IS LOUD. refreshDomains asks Resend's /domains endpoint and stores the answer in KV under its own key (id:email:domains — never a vars entry, never inside the credential document; see below); a sending route re-asks once the list is a day old, backs off for fifteen minutes after a failed check, and the 🔄 Check domains button asks now. An app whose subdomain is not verified sends from the apex address keeping its name — Tyler's call, because refusing the send turns a lapsed DNS record into a person who cannot sign in — and says so in the send result (fallback: {wanted, reason}), the per-app health record, the admin panel (amber, with the reason) and the new app-key-gated GET /id/mail/identity, which Kosmos's and Hermes's previews now ask instead of hard-coding an address. A failed check keeps the previous list and records the error; a failed KV write keeps the fresh list for that request and records that too. Nothing in this path can throw in front of a send.

🔍 WHAT THE ADVERSARIAL REVIEW CAUGHT BEFORE DEPLOY (four finders, three refuters per finding, mutation testing in a worktree): the first draft kept the domain list inside id:email and refreshed it by read-modify-write — a refresh from a stale KV read would have written an admin's just-rotated key back to the old one, and a swallowed read error followed by a successful put would have written {domains} alone, deleting the suite's only mail credential from an unattended daily refresh. Also: the refresh put was unguarded in front of every send (KV's one-write-per-second limit would have failed sign-in codes), a sending-only Resend key would have re-fired the failing check on every send forever, the routes discarded the freshened list and let sendEmail re-read KV, verified was computed on the address used rather than the one wanted, a legacy caller could still label the apex with sender_name, and five tests passed for the wrong reason. All fixed; the mutation battery (13 mutations, including in-suite forgeries and recomputed-From responses) now kills every one. ⚠️ The SECURITY lens of that review did not run — its agent hit a usage limit — so it was re-run separately against the DEPLOYED build (one attacker briefed on sender choice, domain-list poisoning, information exposure, gating, abuse and health-record injection; three refuters per finding). What it found is in the v0.22.1 entry above.

👀 THE PANEL SHOWS FACTS. A Sending identities table lists every app's From line as it would leave right now, computed by the same code a send uses, with ✅ verified / ⚠️ falling back and each app's last send. The test button reports sent.from — the header Resend received — refuses an app outside the roster, names the app under test in the subject, and writes the per-app health row. Health is recorded per app as well as suite-wide, so one app's Resend refusal can no longer be overwritten by another's success.

🧪 sender-identity.mjs rewritten (99 assertions, weighted to the wire and anchored to response lines); email-sender.mjs +23 (derivation, in-suite forgeries, separate-key storage, failed-write/failed-read behaviour, backoff, senders[] fallback); email-panel-renders.mjs +13; otp.mjs +8 (health record carries From/fallback/app; per-app rows). 458 assertions across 10 suites.

VERIFIED LIVE 2026-09-01 after deploy 71f08f6: served app.js v0.22.0 · /id/mail/identity 401 without a key, and with the legacy secret answers the apex identity with app:null · /id/admin/email and /id/admin/email/domains 401 without the master cookie · escalation 401 · login 401 · my-connector 401 · portal 200 · the rendered bar at /email signs Kosmos/Hermes off with their own domains · the first live request pulled Resend's domain list into KV. Real sends to tyler@spartanstudios.com, From lines quoted from the panel's wire report (sent.from, the payload Resend received — Tyler signed in and the panel was driven from this session, 2026-09-02 ~02:10Z): Kosmos · Lander Chooser <no-reply@kosmos.appolis.app> · Hermes · Delivery Performance <no-reply@hermes.appolis.app> · Agora <no-reply@agora.appolis.app> · Phantasia <no-reply@phantasia.appolis.app> · Appolis <no-reply@appolis.app> — and Tyler's own sign-in code at 01:21Z, through the OTP door, recorded as Appolis <no-reply@appolis.app>. The 🔄 Check domains button listed all five sending domains verified (plus flipmylifewellness.com, the fraud bot's, which nothing here uses). And they ARRIVED — read back from Tyler's inbox through the Gmail connector minutes later: five messages in INBOX (none in spam), senders no-reply@kosmos.appolis.app · no-reply@hermes.appolis.app · no-reply@agora.appolis.app · no-reply@phantasia.appolis.app · no-reply@appolis.app, subjects naming the app under test. Accepted → stamped → delivered, all three measured. (The same inbox still holds the 08-24 and 08-26 hub invitations from no-reply@appolis.app — the bug, preserved as evidence.)

📋 cmp_34f01cf47c goes to rev 3: the "address stays no-reply@appolis.app" paragraph is gone, criterion 1 reworded (one mailer, one key, the address is issued by the door), and a new criterion forbids hard-coded From lines in previews. Kosmos and Hermes each carried a literal <no-reply@appolis.app> in their owner-facing previews and Hermes's wrapper dropped the idempotency key — patches staged in both repos with tests (kosmos 36/36 · 29/29 · 183/183, hermes 45/45) for their lanes to verify, version and deploy (todo_2357, todo_2358). Phantasia's "N/A" assumption corrected on its board (todo_2359): it will send sign-in mail.)

On the three test mails a session had just sent him: "these are hideous. Text only is crazy. Come on now. I hope the other apps are not allowed to turn in such rubbish for their directive submissions."

THE FIRST HALF WAS TRUE: THEY WERE RUBBISH. They had an html part — three <p> tags with inline colours. Technically HTML, visually indistinguishable from plain text. Calling that "styled" was true and meaningless.

⚠️ THE SECOND HALF WAS THE SERIOUS ONE, AND IT WAS A FAULT IN THE RULE, NOT THE MAIL. cmp_34f01cf47c criterion 4 asked that the body carry "its name, its wording, its look"unfalsifiable. Any app could have submitted exactly what Appolis submitted and claimed compliance, and there was no ground to reject it. A criterion that cannot fail is not a criterion, which is the identical defect this house has caught twice in tests this month — now found in a rule I wrote, by the owner, on my own submission.

📐 lib/email-template.js — THE SHELL. Appolis owns the invariant, genuinely hard part; each app owns its identity. Same doctrine as /progress.html: the pattern travels, the pixels do not. Every odd decision in it is a client bug: tables with role="presentation" (Outlook’s Word engine collapses divs and drops padding) · every critical style INLINE, with the <style> block only ADDING dark mode and the mobile query because Gmail strips it in some contexts · dark mode pinned with color-scheme + supported-color-schemes, or a client inverts and puts dark text on a dark card · a real preheader with entity padding, since it is the second thing read after the From line and without it the preview leaks markup · buttons as table CELLS with mso-padding-alt, never styled anchors · bgcolor beside every background-color.

⚠️ THE PLAIN-TEXT PART IS DERIVED FROM THE SAME BLOCKS, never written twice. A hand-kept fallback is a second email nobody proofreads — precisely where a stale sender name survives after the HTML is fixed, in the half of the message a good share of clients actually display.

👀 appolis.app/email.html — the bar, RENDERED, by the real shell for three different apps, with each one’s plain-text part shown beside it. Nobody has to guess what good looks like, which is the same reason /progress.html exists.

📋 THE DIRECTIVE IS AMENDED — rev 1 → 2, MATERIAL, 7 criteria → 12. The unfalsifiable one is replaced by six structural criteria that can each be failed, plus an explicit FAIL CONDITION naming Appolis’s own rubbish as the worked example: a body of unstyled paragraphs is NOT compliant however correct the From line is — if your mail would look the same with the markup stripped out, it does not pass. Appolis went stale and re-attested against the new bar, which is exactly how a material bump should behave when the owner moves the standard.

⚠️ TWO DEFECTS FOUND BY THE TESTS I WROTE FOR IT. The plain-text builder stripped markup from every block but not from the TITLE, so a title containing tags arrived with them showing — in the half of the email nobody looks at twice. ⚠️ And one of my own new assertions was WRONG and the code right: it failed on onerror= appearing inside &lt;img src=x onerror=…&gt;, which is escaped text and therefore correct. A test that cannot tell escaped from live markup is checking the wrong thing; it now asserts on real tags.

🧪 9 files; email-template.mjs new (30 assertions), weighted to the client rules whose failures are invisible in a browser and obvious in an inbox. Three properly built mails re-sent to info@spartanstudios.com — Kosmos · Lander Chooser (43afbfb6), Hermes · Delivery Performance (cc1f7561), Appolis (2e4c2a09).)

The test now takes an app (and an optional surface), sends as that identity, and reports the From line it actually used so the confirmation is evidence rather than reassurance. ⚠️ Seven new assertions pin it, including that the panel offers every app in the roster — a picker that silently omits an app is a sender nobody ever checks.

📬 THREE REAL EMAILS WERE SENT to info@spartanstudios.com and ACCEPTED by Resend, so v0.20.0 is now proven by delivery rather than by a constructed string: Kosmos · Lander Chooser (id 5230c373), Hermes · Delivery Performance (de066351), and Appolis (541c0a22) as the baseline — all three at no-reply@appolis.app. Three identities, one address, visible side by side in one inbox.)

Tyler: "any email that comes out of our system should look like it is coming from the app, document hub or whatever that sent it… When I requested a sign-in code for the Landers chooser document hub, it sent it with an Appolis name and it just looked like it came from Appolis in my email."

I FOUND THE EXACT MAIL HE MEANS, IN PRODUCTION: the health record reads {"at":"2026-08-23T03:35:45","ok":true,"via":"kosmos:hub-review-code"} — Kosmos's hub reviewer sign-in code, sent through /id/mail/send, wearing Appolis's name.

⚠️ THE CAUSE WAS MINE, AND THE DISTINCTION IS THE WHOLE FIX. v0.19.0 made a caller-supplied from be IGNORED, at the Hermes lane's request — "an app should not get to choose a sender" — which was right and stays right. But I then hard-coded the ONE configured identity, so every app's mail left as Appolis. Those are two different things and I conflated them. The danger was a caller NAMING AN ARBITRARY SENDER FROM THE PAYLOAD; it was never all mail must look identical.

SO THE IDENTITY IS NOW DERIVED FROM THE AUTHENTICATED CALLER — its app key — and never from the body. Kosmos's key produces the Kosmos identity; Kosmos cannot ask to be Hermes; the payload still chooses nothing. Both properties hold at once, and test/sender-identity.mjs exists specifically to keep them apart permanently, because collapsing them again is exactly the mistake that produced this build.

WHAT SHIPS: Kosmos <no-reply@appolis.app>, Hermes <…>, Agora, Phantasia, Appolis. And because a hub or a docs site is not an app slug — Tyler named that case precisely — a caller may pass sender_name to say WHICH of its surfaces sent this: Kosmos · Lander Chooser <no-reply@appolis.app>. The app comes FIRST so an inbox groups by app; the surface second so a person knows which door it was.

⚠️ sender_name IS A LABEL, NEVER A HEADER. It is stripped of CRLF, angle brackets, quotes and @ before use, so a caller cannot smuggle Bcc: through it or introduce a second address — and the ADDRESS is untouched in every case. An unknown caller gets the configured From verbatim rather than a guessed name: inventing an identity for a caller we cannot name is how a mail ends up claiming to be something that does not exist.

📮 THE ADDRESS DELIBERATELY DID NOT CHANGE, and the reason matters for what comes next. A display name needs no DNS at all, which is why it is the half that shipped today — Tyler's own read, "the easier part that can be done first is the styling aspect and the names." Per-app SUBDOMAINS are a real option and a bigger job: Resend treats every sending domain as its own verified domain, subdomains included, so kosmos.appolis.app would need its own DKIM/SPF/DMARC and would build its deliverability reputation from zero, per app. Possible, not free, and a separate decision. A self-hosted mail server is the third and most expensive path — Cloudflare Workers cannot send outbound SMTP at all, so that would mean running a real mail service, not a Worker.

⚠️ TWO BUGS IN MY OWN CODE, CAUGHT BY THE TESTS I WROTE FOR IT, both shipped fixed: senderFor used addressOf (which only extracts) instead of validAddress (which judges), so a misconfigured From like not-an-address came back dressed in angle brackets as if it were real — sendEmail would still have refused it, but only after this function had legitimised it. And a surface name that merely repeated the app in different case (kosmos) won the fallback over the canonical Kosmos.

🧪 8 files, sender-identity.mjs new (19 assertions) — weighted to the injection and unknown-caller paths, which are the ones that would quietly forge or invent an identity.)

THE BLOCKER WAS MINE, AND IT WAS EXACTLY THE FAILURE THIS SUITE NAMES. Appolis holds the city's ONLY email sender (lib/email.js + the Resend credential) — measured: zero other repos contain a sender of any kind. But the only two things that could reach it were Appolis's own sign-in codes and a master-cookie test button. There was no app-callable route at all. note_1830 named POST /id/notify as the next step on 2026-08-21 and it was never built. Centralising the sender without opening the door is the door-before-the-destination mistake inverted: the destination existed, and every caller was locked out of it.

THE HERMES LANE WAS HARD-BLOCKED, BY NAME. Their commit b261de5: "NOT DEPLOYED. Shipping the gate before Appolis can send would lock out the fulfilment member already holding a link… Waiting for /id/mail/send rather than degrading the gate to a silent no-op." A finished feature — codes + 30-day confirmed devices, a verify door built to cmp_progress0001, a 22-field invite template, 468/468 tests — parked because of this repo. 👏 They refused to degrade the gate to a no-op and waited instead. That is the house rule working, and it is why nothing broke while I was slow.

BUILT TO THE CALLER'S PUBLISHED CONTRACT, NOT MINE — they had already written the seam, so the shape is theirs: {to, subject, html, text, from, tag, app}{id}. When the caller exists first, the destination adapts to it; the reverse is how you strand a caller.

FIVE PEER SESSIONS WERE ASKED BEFORE A LINE WAS WRITTEN, and four of the design decisions below came straight back from them — the value of asking, rather than shipping and discovering:

· ⚠️ from IS IGNORED ENTIRELY (Hermes: "an app should not get to choose a sender"). I had drafted a constrain-the-domain compromise; theirs is stronger and simpler. Every mail leaves as the one configured, verified identity. Letting a caller name its own From would let any app holding any key send as any other app — or as a person — inside our own verified domain, the one place Resend cannot see the difference. Branding lives in the subject and body, which the caller owns.

· ⚠️ THE PROVIDER'S REAL MESSAGE ID IS RETURNED, never a synthetic one (Hermes). Only Appolis can ever receive Resend's bounce webhooks — Hermes never talks to Resend — so the real id is the only thing that will let a future bounce be correlated to a specific invitation. A synthetic id closes that door quietly.

· ⏱ NO QUEUEING, and a warning left in the code for whoever optimises later (Hermes). tag: report-invite is awaited by an admin who is told whether it worked; tag: report-code carries a sign-in code with a ten-minute TTL, fired inside waitUntil to keep a timing oracle closed. A queue that holds a code even a few minutes makes the feature unusable.

· 🔁 IDEMPOTENCY, on the Kosmos lane's request — an optional caller-supplied key. Their reasoning was the deciding one: "a caller that times out mid-send has no safe way to retry, and the failure mode is a client getting the same invoice or invite twice. This house has already been bitten by exactly that shape." Three states, three different answers: DONE returns the original provider id and sends nothing · IN FLIGHT answers 409 and says nothing was sent · ABSENT sends. ⚠️ The marker is CLEARED on failure — a key that stayed wedged after a failed send would block the legitimate retry forever, which is the other half of the same historical bite (a wedged key blocking a payment, v7.60). ⚠️ Keys are hashed and scoped per app: two apps choosing invite-42 are not the same send.

⚠️ A FAILURE IS A FAILURE. lib/email.js throws rather than returning {skipped}, and this route does not soften that into a 200 — every caller shows an operator what we return, and a fake success is how an invitation that never arrived looks exactly like one that did. 400 for a caller mistake, 502 when the provider or our config is at fault, so a caller can tell fix your payload from not me. Per-app rate limit at 300/hour so one app's retry loop cannot burn the suite's quota or become everyone's deliverability problem, and every send is recorded with the app that made it, so the ✉️ panel names the culprit instead of just saying a send failed.

🧪 289 assertions across 8 files; test/mail-send.mjs is new (27). ⚠️ One of its assertions was WRONG on the first run and the code was right: it forbade any 200 carrying sent:false, which wrongly condemned the dedup reply — a dedup is a success (the mail really was sent, earlier), it simply did not send again. Scoped to the failure path, where a 200 genuinely must never appear.)

① 📥 SUGGESTIONS. Tyler: "the other apps don't really have a way of submitting ideas or things that we have done that could become compliance changes… right now it's just like a copy/paste situation from one chat to another." New connector tool appolis_compliance_suggest — any app session files an idea (title + a real WHY; a one-liner is refused at the door, same evidence bar as attesting, because a box full of noise is a box nobody reads). It lands in the same race-guarded document through mutate(). A suggestion binds nobody: directives flow DOWN from the owner, suggestions flow UP from the apps, and only the owner's hand turns one into the other.

THE OWNER'S 📥 HUB sits above the New-directive form: pending ideas with 📝 Draft a directive from this — which PREFILLS the form (title, body, targets) and leaves the owner to reshape it and write what DONE means; nothing is issued until he presses Issue, and issuing from a suggestion accepts and links it in the same atomic write, so the submitting app can follow its idea end to end. ⛔ Decline demands a reason — the submitting app is shown the outcome through appolis_compliance_list, because a silent decline teaches every app the box is a void and the copy/paste era quietly returns.

HOW THE NEXT APPOLIS SESSION FINDS OUT: the connector handshake now carries a 📥 banner for master sessions when the box has pending items — triage these before new work — which is exactly Tyler's "get read by whatever Appolis session is open before doing anything the next time it is active." ⚠️ The honest limit on the "watcher": KV cannot push an event into a session, so a true fire-on-submit trigger does not exist; what does exist is the handshake banner (every new Appolis session) plus a scheduled triage session that polls the box, builds the full actionable plan for anything new, and posts it as a ❓ decision form with a real push to Tyler's device. The approval gate is always his — the triage session plans, it never issues.

⚠️ THE ROUND-TRIP BUG THAT WAS CAUGHT BEFORE IT SHIPPED: save() lists its fields, and the version that predates suggestions silently dropped them — one directive edit after a suggestion landed would have deleted the suggestion with no error anywhere. The round-trip test now files a suggestion, performs an unrelated directive write, and asserts the suggestion survived; mutation M21 (deleting the carry line) kills the suite.

② 🔑 CODE-FIRST SIGN-IN. Tyler: "I thought we were just going straight to one time code from email… We have the email set up. Why are we not using that?" The modal now opens on the emailed-code pane. The password pane is demoted to the reachable fallback — kept for exactly one reason, stated in the code: a code-only door plus an email outage locks every person out at once, and the fallback turns that outage into an inconvenience. It retires when the authenticator lands as the offline factor — Tyler's own stated sequence, and the authenticator is the next Appolis-lane build.

③ 💾 SAVE THE DEVICE. "They also should be able to save the device so they don't have to continue to verify themselves for a good bit." The code pane gains Remember this device (default ON) → a 90-day session instead of the standard 30. The session cookie IS the device memory — no separate trusted-device token to leak, rotate, or forget to revoke — and it stays HttpOnly/Secure exactly as before. Untick it on a shared machine and you keep the 30-day life.

🧪 262 assertions across 7 files, compliance MUTATION-VERIFIED 23/23 (new: the dropped-box round-trip, the silent decline, the re-decided suggestion). test/signin-copy.mjs was updated for the contract change rather than left green by luck — it used to assert the code path was "offered, never forced", which is precisely the behaviour the owner overrode; it now pins code-first, the reachable fallback, and the remember checkbox travelling with the verify call.)

Tyler set the doctrine: "rather than me getting something written up by you here, copying it, putting it in the session for the respective app… why don't we just start using this compliance feature to facilitate these sort of changes… this is how I've always wanted Appolis to work anyway, it should be the main purveyor of these sort of things as it is the one door into everything."

NOTHING NEW WAS NEEDED TO START USING IT — targeting one app already worked and is covered by tests from rev 2. What changed is the BAR: a wall for one standing standard and the mechanism every cross-app task rides on are not the same thing, and the second one cannot afford to lose a write.

🏁 THE LOST-UPDATE RACE, FIXED BEFORE WE LEAN ON IT (todo_1893). Every mutation was read-modify-write over one document, and attestations arrive from APP SESSIONS, not just the owner. Two lanes filing in the same moment both read the old document and the second write erased the first app's evidence — with no error anywhere. The wall would show an app outstanding when it had attested, and the only symptom would be a session insisting it already filed. That is the "legitimate-looking write no safety check can catch" shape the house rules name as the most common way work is lost here. Found by READING the write path while investigating an unrelated Cloudflare 401, not by anything failing.

All four mutations (attest, review, editDirective, setStatus) now run through mutate(): load → apply → re-read → write only if the sequence has not moved, otherwise redo the whole change against fresh state, with jittered backoff and a bounded five attempts. ⚠️ It re-runs, it never MERGES — a merge reconciling two attestations field by field would invent a record nobody filed. ⚠️ Permanent contention throws, the route answers 409 with retryable:true, and the message says plainly that NOTHING WAS SAVED — a success answer for a write that did not land is the exact dishonesty this feature exists to police.

⚠️ AND THE CLAIM IS DELIBERATELY SMALLER THAN "FIXED". Cloudflare KV has no native compare-and-swap, so this narrows the window to KV's propagation lag and converts silent loss into a loud, retried failure. It does not close the race — the re-read can itself be stale. The real close is a Durable Object (Kosmos already runs one for hubs) and it is filed, not pretended. Saying otherwise would be the thing the wall is for.

🔒 /id/login RATE LIMITED (todo_1849). It had answered unlimited guesses since it was written, on the worker every app in the city depends on, and each attempt burns a PBKDF2 — a credential-guessing door AND a free CPU drain. 20 attempts per IP per 15 minutes, reusing otp.rateHit, the limiter already proven in production by the sign-in codes rather than a second one grown beside it. ⚠️ PER-IP ONLY, DELIBERATELY: a per-EMAIL limit on a PASSWORD door is itself a weapon — anyone who knows an address could lock that person out by failing their password on purpose. The OTP routes may limit per address because a code is single-use and self-expiring; a password is not, so this limit binds the CALLER, not the victim. ⚠️ The break-glass path is untouched — the master PASSWORD cookie comes from /api/login, a different route, and stays the one admin path that survives an ID-store problem; a limiter that could lock the owner out of his own city would be worse than the hole it closes.

📋 The panel was reframed from "changes every app must make" to "work handed down to apps", because the moment a directive names one app the old copy reads wrong.

🧪 92 assertions across two compliance files, MUTATION-VERIFIED 20/20. The new race tests drive an actual collision — a competing write injected between the read and the write — and assert the three things that matter: the collision is RETRIED, the other lane's change survives, and permanent contention throws with genuinely zero writes. A legacy document with no sequence field still mutates cleanly.)

THE NEW RULE, verbatim: "An indicator appears only when there is a genuine wait — never for work that is already done. A perceived wait is unexplained only if the person is actually kept waiting; an indicator on work that completes immediately is worse than none, because it tells them they are waiting when they are not. Quick actions answer on the control that started them. Background and polled work is invisible by construction: nobody asked it to run, so nobody is interrupted by it finishing."

WHERE IT CAME FROM, and this is the part worth keeping. The old criterion 1 prescribed a ~120ms delay and framed the goal as "no perceived wait is unexplained" — i.e. it optimised for COVERAGE. Kosmos built to it, and Tyler rejected two attestations in a row (v8.59.0 and v8.60.1) for the same reason each time: "the top bar's gotta go, it's just too much with all the other stuff loading… it just randomly keeps popping up with this finished pop-up… if something loads immediately it's still doing that." Their v8.61.0 post-mortem names the two lessons the new wording encodes — a bar that paints on nearly every call becomes furniture rather than information, and the phantom "finished" popup was a 45-second background poller announcing a result nobody had asked for. The rule now leads with RESTRAINT, and the reject-and-return loop is what found it. That is the compliance feature working exactly as intended, on its own first directive.

⚠️ MATERIAL — rev 3 → 4, and it cost Kosmos a re-file, which I want on the record as a deliberate call. An attestation against the old criterion 1 is NOT the same claim as one against the new one, so both settled apps went stale. Kosmos already SATISFIES the new wording — it came FROM its v8.61.0 implementation — so the revision note says exactly that, in plain words, so nobody burns a shift rebuilding finished work. Appolis genuinely did NOT satisfy it.

WHAT APPOLIS ACTUALLY HAD WRONG: the 120ms threshold. Under "never for work that is already done" it flashes on a 300ms call and tells the person they are waiting when they are not. Raised to 700ms — and that is not a number I invented: it is what the Kosmos lane landed on after two rejections, so it has already survived contact with a real user. ⚠️ I did NOT copy Kosmos's decision to delete its top bar — that was Tyler's judgement about Kosmos specifically ("too much with all the other stuff loading"), criterion 9 says style it to your own app, and a bar that now appears only past 700ms in a light portal is rare and quiet. Stated out loud so the difference is a decision rather than a drift.

api() GAINS opts.bg — background work is invisible by construction. ⚠️ Shipped even though Appolis has no background call that needs it today, which is the opposite of the usual no-dead-code rule and is deliberate: "invisible by construction" has to be a PROPERTY OF THE HELPER, not an accident of the two current background paths happening to use raw fetch. The next background feature will reach for api() like everything else, and the flag has to be there waiting. ⚠️ Silent means no PROGRESS UI, not no errors — a background call that fails still throws, because swallowing it is the degrade-quietly shape this house keeps meeting.

⚠️ I GOT SOMETHING WRONG IN MY OWN CLOCK-IN AND CORRECTED IT RATHER THAN QUIETLY DROPPING IT. I declared loadMyConnector() a live violation driving the bar. It is not — it uses a raw fetch(), as does the 5-minute version poller. I had grepped for background calls and ASSUMED it went through api() without reading it. Appolis had zero background calls touching the indicator; the threshold was the only real gap. The correction is filed on the board next to the claim.

📄 THE REFERENCE PAGE WAS UPDATED IN THE SAME BREATH, because a proof page that teaches the superseded rule is worse than none. Its own threshold moved to 700ms, the "should NOT flash" button became a 400ms "shows NOTHING" case with copy explaining that doing nothing is the correct answer, and a new pattern ①b — background and polled work, invisible by construction was added with two buttons that take exactly the same three seconds and only one of which is allowed to interrupt you. Every stale 120ms reference is gone (grep-verified, 0 remaining).)

WHY IT IS BETTER THAN WHAT WAS THERE. An attestation is WORDS. A convincing paragraph can be written about progress indicators that were half-built, and the only way to catch that is to go and read the app's code — which is exactly the review nobody has time for, which is how compliance walls rot into decoration. A clickable page cannot be faked without actually building the thing. It also turns review into a 30-second job and leaves whoever maintains that app next something to check against.

THE CRITERION: live at /progress.html on the app itself — the same path everywhere, so the wall is checkable at a glance rather than by hunting five different URLs out of five attestations · every pattern the app implemented running a genuinely slow fake operation a person can click and watch, not screenshots and not prose · in that app's own style · a plain statement of which patterns it did NOT build and why · and the URL linked in the attestation.

⚠️ AND THE HONESTY CLAUSE IS LOAD-BEARING. An app with nothing slow enough for a labelled work card, or nothing with a knowable total, must SAY SO rather than invent a fake job to look complete. A page claiming a pattern the app does not really use is worse than an honest short one — it is the precise failure this directive exists to prevent, just dressed nicely. Appolis now demonstrates the clause on itself: an amber note under pattern ② states plainly that Appolis does not use the work card, because nothing here takes longer than about a second and wrapping a 400ms save in a card announcing an 8-second job is theatre that criterion 4 already forbids.

MATERIAL, DELIBERATELY — rev 2 → 3. It adds real work to every app, which is the textbook case for a revision bump. Verified live: all four apps re-read as OUTSTANDING against rev 3 with criterion 10 in their list, and Appolis went STALE and then re-attested, which is exactly how a material bump should feel when the work is genuinely already done.

🔌 AND THE FIRST ATTESTATION THROUGH THE REAL DOOR. Tyler refreshed his connector, so appolis_compliance_list / _attest resolve over the wire at last. Every previous attestation went through the operator KV path — gated by the same validator, but not over HTTP — and that caveat was carried openly for three shifts. It is now closed: this one was posted by the MCP tool, and the response came back with the right warning attached ("this is a CLAIM until the owner reviews it").

ANSWERED, because it was asked and the answer belongs in the record: patterns ①–⑥ are a KIT, not a menu. Nobody picks one. ① thin bar = is the app doing something (generic, every network call) · ② work card = busy with WHAT, for HOW LONG (named slow work) · ③ busy control = did my click land · ④ determinate = only where a total genuinely exists · ⑤ failure = the indicator becomes the reason · ⑥ is not a sixth pattern at all — it is ①+②+③ firing together on one click, which is simply what a real screen does. They answer different questions, so an app uses whichever its own waits call for, and most will use several.)

HIS OBSERVATION IS THE PROBLEM, NOT A COMPLAINT. Appolis has nothing slow enough to show its own indicator, so the owner could not see what he had mandated — and neither can the four apps being told to build it. Hence a page where every pattern runs a genuinely slow fake operation you can click and watch: the thin bar (with a 0.12s button that proves it correctly does NOT flash), the work card, the busy control (with a deliberately failing twin), determinate N-of-M, the failure case, and all three at once.

THE WORK CARD IS LIFTED FROM THE ONE HE NAMED — Kosmos’s public/lander-builder.html, its startWork / workBar / endWork trio. It is not a bar at all: a spinning ring, a bold label naming the work, a live seconds counter, and a hint carrying an honest estimate, with role="status" aria-live="polite". A bar answers is it busy; this answers busy with WHAT, for HOW LONG — the two questions a waiting person actually has. Its own source comment is the doctrine worth stealing: the hint is an honest estimate or it is worse than nothing — that file once said "60-120 seconds" against a budget four times smaller, so the screen told the owner a run had failed while it worked perfectly, and the live timer beside it visibly disagreed.

⚠️ APPOLIS DELIBERATELY DID NOT ADOPT THE WORK CARD. Every operation in this portal finishes in well under a second; wrapping a 400ms email send in a card announcing an 8-second job is theatre, and criterion 4 already forbids indicators that do not track real work. The thin bar plus busy controls is the CORRECT answer at Appolis’s speeds. The card belongs where the slow work is — Kosmos’s studio generations, Phantasia, Hermes reports — and the reference page carries it in copy-pasteable form rather than this app carrying dead helpers it has no use for.

THE DIRECTIVE NOW POINTS AT THE PAGE, VIA minor:true — and that is the escape hatch from v0.13.0 doing its job in production: adding a pointer does not change what compliance MEANS, so the revision held at 2, Appolis stayed attested and Kosmos stayed outstanding. Marking it material would have recalled a compliant app for nothing and taught everyone the wall cries wolf. Verified live: material=false rev 2 -> 2, with both apps' states unchanged either side of the edit.

⚠️ The page is in Appolis’s night-city palette ON PURPOSE, with a panel at the top saying so — criterion 9 requires each app to build this in its own identity, and the surest way to get four copies of a mint-and-gold bar is to hand out a beautiful one with no warning attached.)

THE TRAP, WHICH IS THE ONLY INTERESTING PART OF THIS BUILD. Editing sounds like CRUD. It is not. If the owner changes what a directive REQUIRES and an app already attested, that app’s green tick now points at a requirement that no longer exists — a wall claiming compliance nobody ever proved against the current rule. That is worse than having no wall, because a human deliberately caused it and it looks authoritative. It is the same failure shape this suite keeps meeting (a step that cannot do its job reporting success), wearing an admin form.

SO DIRECTIVES HAVE REVISIONS. Every attestation records the rev it answered. Change the body or the acceptance criteria — the two things that define what DONE means — and the rev bumps; every attestation against an older rev becomes STALE, which is not settled, so those apps reappear as outstanding and are told, in the text handed to their sessions, that the requirement changed after they attested and not to blindly re-attest the old work. ⚠️ stale is checked BEFORE accepted on purpose: an owner-ACCEPTED attestation against a superseded requirement is precisely the tick that would otherwise hide, because it looks the safest.

⚠️ AND THE OPPOSITE ERROR IS TESTED TOO — being too eager. A title tweak, a due-date change or adding an app must NOT bump the revision. A naive "anything changed → bump" would recall four already-compliant apps because a fifth was added to the list, which would teach everyone to ignore the wall. minor:true lets a typo fix in the body skip the recall and material:true forces one; both are the owner’s explicit call, never a guess by the code. The API answers back in plain words which of the two happened, because an owner who is not told that four apps just went red will read the board as a regression.

APPS ARE CHIPS, NOT A COMMA LIST, because "remove one app" typed into a text field is how you remove two. ⚠️ Removing an app KEEPS its attestation — dropping the evidence would mean re-adding an app silently erased what it once proved, and the owner losing the record of a review they personally did. Re-adding restores its state exactly.

🎨 DIRECTIVE #1 AMENDED with a ninth criterion, at Tyler’s instruction: "with this particular directive they should be styling the progress bars feature or whatever to their app’s style." Each app implements progress in its own visual identity — Appolis’s bar is a reference implementation, not a component to paste. That amendment is material, so it bumped cmp_progress0001 to rev 2 and knocked Appolis’s own attestation stale in production — deliberately, to exercise the staleness path live rather than only in tests. Appolis then re-attested against rev 2.

🧪 79 assertions across two files, MUTATION-VERIFIED 17/17. test/compliance.mjs grew the edit cases; test/compliance-panel.mjs is new and guards the half that lives in CSS — if stale ever renders the same green as accepted, every protection above is undone by one line of styling, so the state map is asserted directly out of the shipped file, along with the tally counting only settled apps and the edit form actually warning before it recalls anybody.

⚠️ ONE MUTATION SURVIVED AND WAS A BAD MUTATION, NOT A GAP — worth recording because the instinct is to "fix" the code. M16 appended a space to an omitted body; T() trims, so it changed nothing and "survived" while proving nothing. A mutation the code neutralises is not evidence. Replaced with the real bug shape — the missing !== undefined guard, which overwrites a field the edit never mentioned — and that one dies against 9 assertions.)

Tyler: "I should have the ability as the Appolis owner to post… compliance rules… turn it into a sort of non-negotiable change that every app on the platform has to bring themselves up to date with… this would allow us to apply changes to multiple apps by making them comply to the changes and prove that they complied with them."

WHY IT IS NOT ANOTHER STANDING RULE, which is the whole design. 📜 Rules (id:rules) are ADVISORY TEXT: they shape how a session behaves from the moment it reads them, they apply to nothing that already exists, and nobody ever has to come back and say "done". That is exactly right for always scan first. It is exactly wrong for every app must now show progress on every wait, because that is not a habit — it is a body of work spread over five codebases that somebody has to actually do. So a directive has acceptance criteria, targets named apps, stays OUTSTANDING against each one until that app ATTESTS, and the attestation can be REJECTED.

⚠️ "PROVE IT" IS THE FEATURE, AND A CHECKBOX WOULD HAVE DESTROYED IT. A self-reported boolean proves nothing — it is the same shape as a step that cannot do its job returning success, except now there is a green wall claiming otherwise, which is worse than having no wall. So attest refuses at the door: no version, no attestation. "done" as what-changed, refused. "yep" as how-you-verified, refused — and the refusal explicitly asks for the assumed parts to be named, because this house separates VERIFIED from ASSUMED everywhere else too. The owner reviews, and a rejection is not a note: it puts the app straight back to outstanding, carrying the reason so whoever picks it up knows what to fix.

THREE DELIVERY CHANNELS, so a session cannot claim it was never told. ① The handshake — directives ride the one initialize every AI client enters through, rendered separately from the rules and harder-edged ("NOT advice and NOT background reading"). ② A native appolis_compliance_list tool for the full requirement and what DONE means. ③ A compact ⛔ nudge appended to fanned-out tool responses for any app with something outstanding — the same last-line-of-defence Kosmos uses for standing rules, so a session that connected before a directive existed still meets it on its next action. The nudge is a separate content item, never concatenated onto the app’s own text, which callers JSON.parse.

⚠️ THE HUB GREW ITS FIRST NATIVE TOOLS. Until now this connector had none — it purely fanned out to the four app workers. Compliance cannot live in an app: the point is that the platform OWNER sets it and the apps answer to it, and an app that could edit its own obligations would be marking its own homework. So appolis_compliance_list / _attest resolve on the hub, matched BEFORE the fan-out lookup so precedence is explicit rather than depending on no app ever having the slug appolis. Writes stay owner-only behind the master PASSWORD cookie, reachable by no app key and no connector token. An app may only attest for itself, and only for a directive that names it.

🚦 DIRECTIVE #1 — PROGRESS ON EVERY WAIT. Tyler: "Progress or loading bars need to be incorporated in literally everything we do. Nobody should ever sit waiting and not know that they are waiting." Issued as cmp_ against every app, and Appolis brought itself into compliance and attested first, because the control centre does not get to exempt itself from the first rule it issues — and because attesting once is the only way to prove the loop rather than shipping a mechanism nobody has run. Two pieces answering different questions: a global bar driven from inside api() (so a route added later is covered by construction, not by somebody remembering), and busy() on the control that started the work, which disables it and spins so a second click cannot fire. ⚠️ Reference-counted, not a boolean — two calls in flight and one finishing must not clear the bar. ⚠️ The 120ms show-delay is deliberate and is not a loophole: nobody experiences 40ms as waiting, and a bar that flashes on every fast call reads as a glitch. The rule is that no perceived* wait goes unexplained, and that is written into the acceptance criteria so it is settled once rather than re-argued per app. ⚠️ busy() restores the label on both paths — a button left spinning after a failure is the same lie as no indicator, just slower to notice.

🧪 48 assertions in test/compliance.mjs, MUTATION-VERIFIED 8/8 — every refusal is driven, and the load-bearing one is that a REJECTED attestation reads as outstanding again rather than as "attested, with a note". Mutations killed include making a rejection a mere note, letting a directive ship with no acceptance criteria, accepting "done" as evidence, dropping the version requirement, letting any app attest to anything, attesting to a closed directive, and an unreviewed claim reading as complied.

⚠️ KNOWN GAP, FILED NOT HIDDEN: directives reach sessions on the Appolis connector. A session connected DIRECTLY to Kosmos’s own connector does not see them yet — that is a second delivery leg in another repo’s lane.)

WHAT SHIPPED. lib/otp.js + POST /id/otp/request + POST /id/otp/verify, issuing exactly the session /id/login issues — same cookie, same body, same hubRole/entitlements/teams — so moving a door later means changing which form a person is shown, nothing more. The portal sign-in modal gained a second path, offered not forced: "✉️ Email me a code instead", and back again.

⚠️ WHAT DID NOT SHIP, DELIBERATELY. /id/login is byte-for-byte unchanged. /id/claim is untouched. The admin password-reset route is untouched. No app’s login is moved. Steps 3 (move the doors one at a time, browser-verified per app) and 4 (retire the password paths) are NOT in this build — build the destination, verify it live, THEN close, which is the mistake this suite has made three times and did not make again.

THE SIX NON-NEGOTIABLES note_1827 SET, EACH ONE HONOURED AND EACH ONE TESTED. ① Rate limits on both axes with real backoff — 5 per address per 15 min, 20 per IP per hour, an escalating 30/60/120/300s gap between requests, plus a separate cap on verify attempts per IP so one host cannot guess across many addresses. ② 10-minute expiry, single use, killed on use and on re-request. ③ Constant-time compare. ④ Three wrong guesses burn the code — not just that attempt. ⑤ The enumeration trap collapsed from day one. ⑥ Six digits, crypto.getRandomValues, rejection-sampled so the low codes are not slightly likelier (% 1000000 on a 32-bit draw is biased; removing it was free).

⚠️ THE ENUMERATION TRAP, AND THE HALF EVERYBODY FORGETS. Identical status and identical body are the easy part. Timing is the oracle: sending an email costs a few hundred ms and only happens for a real account, so an awaited send tells an attacker everything the JSON refused to. The send is therefore handed to ctx.waitUntil and never touches the response path, and the KV writes happen for a stranger too — so both cases do the same work before answering. test/otp.mjs asserts this as the same number of KV operations, not as "the bodies look similar". Every verify failure — no record, wrong, malformed, burned, expired — returns one identical sentence. This was finding F5’s shape behind a machine key; on a public login form it would be far worse.

⚠️ A FAILED SEND CAN NEVER BE SHOWN TO THE PERSON ASKING — saying "we couldn’t email you" confirms the account exists. So it goes to the operator instead: otp:health records the last real send outcome and the ✉️ Email panel renders it, red and loud, with Resend’s verbatim reason and the sentence "nobody can sign in by emailed code until this is fixed". Without that row a dead key looks exactly like a working one from every angle a human can see — the degrade-instead-of-fail shape in its most dangerous position.

⚠️ THE COPY IS PART OF THE SECURITY, so it has its own test. The confirmation reads "If that address has an Appolis account, a 6-digit code is on its way" — never "we sent you a code" or "check your inbox", which state the send as fact and hand back the exact answer the server just refused to give. test/signin-copy.mjs fails on five different friendlier phrasings, and also fails if the password pane is ever deleted from the modal (that would be step 4 happening by accident).

🧪 107 assertions across 4 files, and test/otp.mjs is MUTATION-VERIFIED 10 for 10. Two mutations survived the first draft and both were real. M7 — a distinct sentence for an expired code — survived because the identical-bodies test covered every failure mode except that one, which is precisely the mode a future "helpful message" would target. M5 — never burning the code — survived because the lockout had two burn paths and only one was mutated; the other was unreachable defensive code sitting where a security check appears to be, which is worse than no check, because a tidy-up deletes the working line and keeps the decorative one. There is now one burn path, and a new assertion pins the other side of the boundary: a typo short of the limit must NOT burn the code.

ALSO FIXED IN PASSING: api() threw new Error(url), so every caller rendering err.message displayed a path. The ✉️ Email panel’s entire job is relaying Resend’s verbatim reason and it was printing /id/admin/email/test. The server’s sentence now travels with the error, which is also what lets the sign-in modal show "wait 30 seconds before asking for another" instead of a generic shrug.

⚠️ VERIFIED LIVE / NOT VERIFIED — see the clock-out on the board for the split.)

① THE SENDER — lib/email.js + a master-admin panel. note_1827 measured the hard dependency and found it missing: nothing in this suite could send a single transactional email, and Workers cannot speak SMTP, so passwordless login codes had a prerequisite that did not exist. Tyler: "I already set up Resend with something else before, the fraud detector bot… can we just use resend again for our email for now? Maybe just create a place on the app for me to put in the information. Somewhere on the admin hub." Built once on Appolis rather than five times across five apps, because Appolis is the control centre and every app already reaches it. GET/PUT /id/admin/email + POST /id/admin/email/test, all inside the master-PASSWORD-cookie /id/admin/* tree — the same reasoning as the standing rules above, and stronger, because this holds a live third-party credential: no app key, shared header or connector token can read or change it.

WHERE THE KEY LIVES AND WHY. KV (id:email), never a vars entry — that is todo_1059’s live lesson about plaintext in git-tracked config. A Wrangler secret RESEND_API_KEY still works and is used when KV holds no key, so the same code serves both "Tyler pastes it into the admin hub" and "it is a deploy secret"; a pasted key wins. The key never comes back out to a browserpublicEmailConfig returns a masked hint (re_••••abcd) so the panel can show WHICH key is installed and never leak it, and the input is type=password, write-only, where blank means keep the installed key so editing a From address cannot silently wipe the credential and disable every login in the city.

⚠️ THIS MODULE THROWS. IT DOES NOT DEGRADE. The recharge-fraud-cleanup worker’s own sender returns {status:'skipped', reason:'not_configured'} when unconfigured — correct for a paper-trail email, catastrophic for a login code: the caller reports success and a person is locked out of every app in the city with no error anywhere. That is precisely the "catch that degrades instead of failing" shape this house keeps getting bitten by, so every undeliverable path throws, and a non-2xx from Resend throws carrying Resend’s verbatim complaint — "the domain is not verified" is the common one, and a friendly generic failure would send someone hunting in the wrong place.

⚠️ SAVED IS NOT WORKING, AND THE UI SAYS SO. note_1827 step 1 is explicit that nothing else starts until an email actually lands, because deliverability — not cryptography — is the real-world failure mode; a code in a spam folder is a locked-out user and a support ticket. The test-send button reports only that Resend accepted the message and tells you to go confirm it arrived. ⚠️ NOT YET PROVEN: no email has been sent from this deploy. SPF/DKIM/DMARC on the sending domain are still to be confirmed, and the key is not installed yet.

🧪 test/email-sender.mjs, 21 assertions, deliberately weighted to the negative paths: no key / no From / junk recipient / no subject / Resend refusing / network down all THROW; a blank key box keeps the installed key while an explicit clear removes it; a junk From is refused at save; publicEmailConfig never contains the key but does identify it; the secret-vs-KV precedence both ways. Mutation-verified — returning {skipped:true} instead of throwing, dropping the keep-the-key branch, putting apiKey into publicEmailConfig, and treating a non-2xx as success each fail the suite.

② THE HOUSE RULES GAIN THE TIME CARD AND ONE MORE. HOUSE_RULES is the ONLY thing a unified-connector session is told — the hub answers initialize itself and never forwards an app’s own instructions — so anything missing here is missing for every AI session on every app. It taught scanning and lane discipline but had no bookend: nothing told a session to announce itself before starting or to account for itself before reporting done. Added 🕐 the time card (clock in: name the lane, scan it, treat a dirty tree as someone else mid-shift, say what you intend to touch, log it — clock out: what changed with the commit, changelog written and the doc surface checked against what is actually SERVED, VERIFIED named separately from ASSUMED, what is still open and where it is filed; and if you cannot finish, clock out anyway) and 🛑 the owner’s call stands — do not re-litigate it, after a session spent a section of note_1827 arguing that an emailed one-time code "is not really two-factor". It permits ONE sentence, and only when being wrong would change the code, the security posture, the money or somebody else’s decision — a real warning still lands, definitional purity does not. Kosmos v8.57.0 ships the same two as built-in house rules on its own connector, so an account reached directly rather than through the hub gets them too.)

⚠️ IT SHIPPED AS TWO DEPLOYS WITH VERIFICATION BETWEEN, AND THAT ORDER IS THE POINT — v0.9.4 built the destination, v0.9.5 closed the door. Tyler chose "portal-only for local users"; going to implement it I found the portal did not exist: /id/connector is the internal read each app makes and /id/admin/connectors is master-only, so an ordinary person had no self-service way to see their own connector URL anywhere. Closing first would have stranded every LOCAL (non-SSO) user with nowhere to go — precisely the close-the-door-before-teaching-the-destination mistake this suite has now made three times (FLIPPER→Hermes froze Phantasia's build for 3 weeks; v0.8.0 closed a route and 502'd Kosmos signup for 3 weeks; agora's roster still falls back to a stub silently). So v0.9.4 added GET /id/my-connector — same shape as /id/whoami, gated on the person's OWN session cookie and deliberately not internal, so no machine key opens it — plus a 🔌 panel on the dashboard that renders the URL masked, reveals on request, and warns that it is a credential, not a link to share. That was deployed and verified live (401 with no cookie, 401 with a bogus one, portal 200, /id/login bad-creds still 401 not 503) before a line of phase C was written.

⚠️ AND PHASE B WAS RE-VERIFIED IN THE SAME SESSION, not assumed from the note that said it was done. All four callers were read: mission-control:2856, agora:999, phantasia api.js:578, chardizy:155 — each forwards x-id-token when the cookie is present, and each passes this response's status and body straight through. That second half matters: it is why the refusal reaches the person as a sentence instead of blanking a panel. The refusal names the destination ("get it from your dashboard at appolis.app") and carries reason: 'proof-required' so a caller can branch on it.

WHO THIS DELIBERATELY LOCKS OUT: someone signed into an app with a LOCAL password holds no appolis_id cookie there, so no app can prove who they are — an app cannot vouch for a session it did not issue. They use the portal, where they sign in to Appolis itself. That is a real, accepted UX cost, chosen over leaving a permanent credential collectable by an unsigned email.

⚠️ WHAT THIS DOES NOT DO: it does not invalidate any amt_ token already in the wild. Regenerating those is a separate, announced step (everyone re-pastes their connector URL) and it comes AFTER this — the audit had that backwards, and regenerating first is pointless because a holder simply reads the fresh one. Now that the route is closed, regeneration finally means something.

(v0.9.4 was deployed as its own verification step and is folded into this commit rather than committed separately — noted so the history is not read as a missing version.)

WHAT IT WAS. /id/claim takes its subject from an unsigned body email and WRITES that account's password, then returns a live session. It was the only write the legacy ID_SECRET could still reach — which flatly contradicted the promise this file makes about itself, that the legacy branch is read-only. It is bounded to pendingClaim accounts, i.e. exactly the population nobody is watching, and its own responses are the targeting oracle: 404 no account · 409 already claimed · 200 taken over. It was also a capability bypass — kosmos, agora and phantasia all carry grant: [], yet any of their keys could take over an invitee here.

WHAT THE FIX DOES AND DOES NOT DO. Requiring caller.slug does not make the subject proven — it is still an unsigned email. What it removes is the legacy secret, whose plaintext sits in three repos' git history, from the one door that can set a password. That is the whole of the claim being made here.

CALLERS MOVED FIRST, as always. Kosmos v8.36.0 switched all three of its /id/claim calls (lib/freewill.js, lib/hubs.js, worker.js login-time enrolment) to APPOLIS_APP_KEY; Hermes was already sending its app key; Phantasia and Agora never call it. Deployed and verified BEFORE this route tightened.

VERIFIED WITH A TEST THAT CAN FAIL, both directions. The legacy secret on /id/claim401 unknown app key (before: it would reach the account and answer 404/409/200). A real app-key caller, exercised end-to-end through the Free Will signup → still 401 "that email already has an account", proving the claim step is reached and answering. All five apps 200 after.) v0.9.2 (2026-08-18 — 🧹 ONE DEAD TRUST SLOT REMOVED, ONE NARROW CAPABILITY REGISTERED. Two small changes to the machine-trust registries, both in-lane and both deliberately shipped alone.

aws is gone from APP_KEYS. AWS Motor Club has no Appolis service binding, makes no call to this worker, and APP_KEY_AWS was never set (wrangler secret list confirms — the only secrets are the four real app keys plus PASSWORD). It was a registered trust slot with zero consumers: a key that could open a door with nothing on the other side wanting it. callerApp() skips unset bindings anyway, so removing it changes no behaviour — it removes a thing that could later be set by mistake and quietly become a valid caller. Re-add it the day that app actually binds here, not before.

kosmos gains grant: ['kosmos'] in APP_CAPS, and this one is the fix for a live outage. Kosmos's Free Will lander signup and Hub join form still enrol people through /id/admin/ensure — which has been master-cookie-only since v0.8.0 and answers their machine header with a 401. Both signup surfaces are therefore dead today, verified live, and have been since the trust rewrite; nobody can exploit it, it is pure availability, and it hid because all three call sites swallow their failures. The repair is to move that enrolment onto /id/provision/, the narrow door built for exactly this. That capability is registered here FIRST, on purpose: the Kosmos repo had another session commit to it minutes before this change, and the one-editor-per-repo rule means its half should ship from its own lane — pre-registering the capability means that can happen whenever, without needing an Appolis session open in the same window. Bounded by construction: kosmos may grant only kosmos, never '', and this door cannot reset a password, mint a connector, change a role, delete anything, or read the registry.

⚠️ The outage is NOT fixed by this build — this is one half of a two-repo change, and the Kosmos side (lib/freewill.js:356, lib/hubs.js:913, lib/hubs.js:1491, which must also send env.APPOLIS_APP_KEY instead of the legacy secret) is still open as todo_1691. Verify it by completing a real signup in a browser, not by watching for errors — those call sites hide them.) v0.9.1 (2026-08-18 — 🔌 F1 PHASES A + B — the connector route learns to want PROOF, and all four apps start offering it. The last open P0 from the machine-door audit (note_1689). Nothing is closed yet by design; this is the half of a staged cutover that cannot break anything, shipped across five repos in one pass.

WHAT /id/connector ACTUALLY HANDS BACK. Not information — a credential. The amt_ token in that URL needs no header of its own, acts fully as the named person across every app they are licensed for, and no key rotation invalidates it: v0.9.0 rotated the session-signing key and those tokens were untouched, exactly as the finding predicted. Until now, naming somebody in an unsigned ?email= under the shared machine key was enough to collect it — and the gate tested only the bare internal boolean, so all five APP_KEY_* opened it too.

PHASE A (Appolis). The route now accepts x-id-token — the person's own signed appolis_id cookie, which every suite app already holds and which Hermes was already forwarding to /id/resolve. When a valid one is present it wins, and ?email= is ignored entirely. The legacy path still answers, unchanged, so no caller breaks. The response carries proven: true|false so phase-B adoption can be measured per app without logging anything — a call that arrived with real proof says so. The same absent/disabled enumeration oracle collapsed on /id/apps in v0.8.2 was collapsed here too.

PHASE B (all four apps, same day). Kosmos, Agora, Phantasia and Hermes now forward the person's cookie on that call: mission-control/worker.js and agora/worker.js via their existing parseCookies(...).appolis_id, phantasia-engine/server/api.js via the headers.cookie regex it already uses, and Hermes by threading a second argument through its connector() helper in worker.js down from server/api.js. Each keeps sending the email as well — deliberately, because a local (non-SSO) session carries no such cookie, and that is the edge case phase C has to answer for.

WHY IT STOPS HERE. Phase C — refusing to answer a bare ?email= — is a one-line change and is being held back on purpose. All four callers read url from this response and two of them swallow a failure into a blank panel with nothing in any log, so C ships only once proven shows real sessions arriving with tokens. And the ordering the audit got backwards is worth repeating: regenerating the amt_ tokens BEFORE closing this route is pointless, because the holder simply reads the fresh one. Close, then regenerate.

Verified live: legacy call 200 (unchanged), a garbage x-id-token correctly falls back rather than failing, an unknown email 404s, proven:false reported on an unproven call, and all five apps 200 after the five deploys.) v0.9.0 (2026-08-18 — 🔑 THE FORGING KEY IS RETIRED. ID_SECRET no longer signs anybody's identity. The confirmed P0 from the machine-door audit (note_1689, finding F2), closed end to end across four repos and one secret. This is the single most consequential fix in Appolis's history, because until today the suite's shared machine secret was the key that signs every person's session.

WHAT WAS ACTUALLY TRUE, MEASURED NOT GUESSED. sessionKey() read env.ID_SESSION_KEY || env.ID_SECRET, and wrangler secret list returned only the four APP_KEY_ and PASSWORDID_SESSION_KEY had never been set. So the fallback was not a safety net, it was the live path: every appolis_id SSO cookie in the suite was signed with a value sitting in plaintext in three repos' git history. A holder could mint a valid session for any account — including a master — with no request to make and nothing to detect, then walk the entire /id/admin/ tree and hold a live SSO session at Kosmos, Agora and Phantasia. The previous STATE note claimed this key was set; it never was, and nobody had checked.

WHY IT COULD NOT JUST BE SWITCHED ON. Kosmos, Agora and Phantasia each pre-verified the shared cookie locally against their own env.ID_SECRET before calling /id/resolve, and returned null on mismatch. Setting a new signing key would therefore have killed SSO at three apps instantly — and because the request never reaches /id/resolve, the route appears in no log and the cause looks like anything but a key change. Each app was a second, silent authority on a signature only Appolis is entitled to judge.

THE CUTOVER, IN THE ONLY ORDER THAT WORKS. (1) The local pre-check was removed from mission-control/worker.js, agora/worker.js and phantasia-engine/server/api.js, and all three deployed first. Behaviour is unchanged by construction — a bad token used to be rejected locally and is now rejected by /id/resolve; the only cost is one subrequest for an invalid cookie. (2) ID_SESSION_KEY set to a fresh 256-bit value, with ID_SESSION_KEY_PREV left deliberately empty: the runbook supports an overlap window, but seeding it with the burned value would keep every existing forgery working, so the one-time forced sign-out is the fix. (3) Only then was || env.ID_SECRET removed from sessionKey(), so the two can never silently re-fuse. It now fails closed — an unset key 503s the whole /id/* tree rather than quietly signing identity with a burned value again, and the PASSWORD break-glass still reaches the portal in that state (confirmed working by Tyler before any of this was touched, per the trap note's precondition).

THE ENUMERATION CAUGHT WHAT A GREP WOULD HAVE MISSED. The carry-over rule says enumerate every consumer before shipping, so all four apps were checked rather than the three the audit named. Hermes needed no change — it calls /id/resolve directly and never pre-verified. Phantasia did need one, but its helper is called verifyIdSession, not verifySession like its twins: a grep for the sibling name misses that file completely, and the cutover would have shipped half-done with Phantasia's Studio SSO dying silently. That near-miss is commented at the call site.

VERIFIED AFTER EACH STEP, NOT AT THE END. The decisive smoke test is POST /id/login with bad credentials: 401 means the signing key resolves, 503 means sessionKey() came back empty and identity is down. It read 401 before the flip, 401 after the flip, and 401 after the fallback removal, with appolis · kosmos · agora · phantasia · hermes all 200 at every stage.

WHAT THIS DOES NOT DO: everyone is signed out once and signs back in — that is the point. It does not invalidate connector amt_ tokens, which are a separate credential plane no rotation touches; F1 (the route that hands those tokens out to any key holder) is still open, and regenerating them must follow that fix, not precede it.) v0.8.3 (2026-08-18 — 🔒 THE NEXT BATCH FROM THE MACHINE-DOOR AUDIT — the immortal cookie, the cross-origin gap, and a level a machine may no longer grant itself. Three more fixes from note_1689, all Appolis-repo and all verified before shipping. Deliberately still NOT the two P0s, which need work in other repos first.

(1) A SESSION TOKEN WITH NO EXPIRY IS NO LONGER VALID FOREVER. lib/accounts.js read if (p.e && Date.now() > p.e) — so a token that simply omitted the expiry field skipped the check entirely and was accepted indefinitely. Combined with the confirmed F2 finding (the session-signing key falls back to the plaintext ID_SECRET), that meant a forged master cookie no grace window could age out and no rotation could outlive. Now typeof p.e !== 'number' is rejected outright. issueSession has always set e, and the identity envelope carries it too, so nothing legitimate changes — this purely removes the immortal-token case. Verified before deploy: no-expiry rejected, valid accepted, expired rejected. This is half of F2 closed without touching a single key — the forged cookie now dies on its own.

(2) THE COOKIE-GATED TREES CHECK THE ORIGIN. SameSite=Lax (set explicitly on both cookies) stops classic third-party CSRF — but SameSite is site-scoped, not origin-scoped, and kosmos., agora., hermes., hub., docs. and appolis.app are all one site. A text/plain form POST is never preflighted and request.json() ignores Content-Type in Workers, so a page on any suite subdomain could ride the master admin's cookie into /id/admin/ — creating an insider account (which entsFor resolves to every app) or writing a wildcard entitlement. A new badOrigin() guard now sits ahead of the auth check on /id/admin/ and the /api/apps CRUD tree. A MISSING Origin deliberately PASSES — every machine caller reaches Appolis over a service binding and sends none, so rejecting that would take the suite's login and SSO down at once. Only a present and wrong origin is refused, and the guard is scoped to those two trees only.

(3) A MACHINE CALLER CAN NO LONGER GRANT AN ADMIN-LEVEL ENTITLEMENT. /id/provision/grant read role: b.role === 'admin' ? 'admin' : 'member' straight from the request body. The calling key already bounds which app may be granted; it should never have decided what level. Now hard-coded to member; promotion stays a master action at /id/admin/entitlements. Verified a genuine no-op for the only live caller — the audit flagged this as a possible product change, so Hermes was read directly: chardizy/worker.js:123 sends {email, name, app, teamId} and has never sent a role at all.

Also corrected in this build — ROTATION_RUNBOOK.md was actively misleading. Its "What is already true" section asserted that a holder of the burned value "can no longer reach /id/admin/ at all" — and that sentence was the stated basis for treating the whole rotation as deferrable. It is false: the header door is shut, but the cookie door is wide open because ID_SESSION_KEY is not set. The section now says so plainly, and Step 4 — which listed only the sibling apps' /internal/ routes — gains Appolis's own four, with the ordering correction that regenerating connector tokens before closing /id/connector is pointless because the holder just reads the fresh one.) v0.8.2 (2026-08-18 — 🔒 THE THREE CALLER-TRANSPARENT HARDENINGS from the five-mode machine-door audit (Kosmos note_1689; 44 agents, 12 claimed findings, 11 surviving three-lens adversarial refutation). These are the fixes that close real holes and break nothing — deliberately shipped ahead of the two P0s, which need sequencing across repos.

(1) The account oracle is collapsed. /id/apps and /id/teams take their subject from an unsigned ?email= behind the shared machine key, and answered 404 for "no such account" but 403 for "exists but disabled" — so any key holder could sweep an email list and learn who has an Appolis ID, who is disabled, and (from the 200s) their hubRole, i.e. precisely whose connector token is worth stealing. There is no rate limiting anywhere in the worker. Both routes now return an identical 404 {error:'no such account'} for absent and disabled, from a single shared return statement so the two cases cannot drift apart again. Verified caller-transparent before shipping: all four consumers (agora:946, chardizy:93, mission-control:2614, phantasia api.js:522) branch on r.ok alone and funnel anything else into {apps:[]} — none reads the status code.

(2) Nothing junk can be minted into the account registry. normEmail only trimmed and lowercased, so any string at all — no @ required — could become a durable record in id:accounts, the one document every authentication path reads, with an uncapped name beside it. One request with a 2 MB name, repeated, walks the registry toward KV's 25 MB per-value ceiling, past which no account can be created, claimed or password-reset. New acct.validEmail() (single @, non-empty both sides, ≤254 chars; deliberately no dot requirement so internal-style addresses still work) and acct.safeName() (80 chars, local-part fallback) now guard all three creation sites/id/provision/grant, /id/admin/ensure and /id/admin/accounts — because the audit's own lesson is that fixing one door and leaving its twin raw twenty lines away is the recurring mistake here. Validation runs only on the create path, after the lookup, so an account already on the books with an odd stored address can never be locked out.

(3) The wildcard write is closed. /id/admin/ensure took its app slug as a plain body field and wrote it unvalidated — making it the **only route in the worker that could grant {app:''}, the any-door wildcard, without naming an account id.* It now normalizes and shape-checks the slug (/^[a-z0-9][a-z0-9-]{0,39}$/), so '*' is rejected; granting a wildcard stays a deliberate, id-addressed master action at /id/admin/entitlements.

What this build deliberately does NOT do, and why: the two P0s are filed, not fixed. F1GET /id/connector?email= still returns a person's live connector URL to any machine-key holder, and that amt_ token is a permanent bearer credential that no rotation invalidates; narrowing it breaks the connector panel in four apps, so it gets scheduled, not surprised. F2ID_SESSION_KEY is confirmed not set (wrangler secret list shows only the four APP_KEY_* plus PASSWORD), so worker.js line 197 falls back to ID_SECRET and every SSO cookie in the suite is signed with a plaintext value that sits in three repos' git history; the fix is a three-repo change in a strict order where doing step 2 first kills SSO at Kosmos, Agora and Phantasia. ROTATION_RUNBOOK.md:26 currently asserts the opposite and is wrong. Post-deploy verification: live app.js serves v0.8.2 and a real connector round-trip through the hub into the Kosmos room still resolves — the shell-based gate re-checks were blocked by the sandbox this session and are noted as unverified rather than assumed.) v0.8.1 (2026-08-06 — 📜 THE MASTER ADMIN CAN SEE AND EDIT THE SUITE-WIDE STANDING RULES: v0.8.0 introduced rules that ride the ONE initialize every AI session enters the city through, but there was no way to READ or CHANGE them without editing KV by hand. The 🪪 admin district gains a 📜 Standing rules panel (master-admin only): numbered rows, add/remove/edit in place, one Save. Unsaved edits survive an add/remove re-render because the textareas are re-collected rather than re-rendered from state. Copy makes the reach explicit — these are handed to EVERY AI session on EVERY app the moment it connects, they read as coming from Tyler, and they outrank the assistant's own defaults; a session already running keeps the rules it connected with. Version hygiene fixed in the same breath (2026-08-18): v0.8.1 originally shipped with BOTH version strings still reading 0.8.0, so the running app misreported itself and the refresh banner compared against a stale number. public/app.js now reads v0.8.1, and the connector's copy — previously buried inline in the initialize response, which is why it drifted unnoticed — is hoisted to a named CONNECTOR_VERSION at the top of worker.js with a comment tying the two together. The weekly Appolis session now checks disk vs git vs live on every run, so this cannot drift silently again.) v0.8.0 (2026-07-26 — 🔐 THE TRUST REWRITE: two live privilege escalations closed (todo_1059 + todo_1060), Basic tier lands at the team level (todo_1067 + 1053 + 1054). Found in the suite AI-door audit; the fix touches appolis, kosmos, agora and hermes together.

(1) /id/admin/ is master-cookie-only. It used to accept the shared machine header instead of master admin, on a public domain — so every app worker, and anyone holding the plaintext secret, was a full city admin: reset any person's password, mint a connector token that acts fully as them, grant {app:''} (never validated against a slug list). Now: the PASSWORD cookie or a 👑 master Appolis ID session, and nothing else. Verified live — shared secret on every admin route → 401, master cookie → 200.

(2) Machine trust is PER APP. One ID_SECRET used to do three incompatible jobs (sign user sessions · authenticate machine calls · open the admin tree). Now ID_SESSION_KEY signs sessions and never leaves this worker, and each app carries only its own APP_KEY_<SLUG>, so who is calling — and therefore what it may do — derives from the key, never the request body.

(3) The narrow provisioning door /id/provision/. Hermes's shipped company-app-grants (a Hermes overall-admin giving their people the company's Agora/Phantasia) was the one legitimate use of the admin tree; it now runs here, bounded by APP_CAPS: hermes may touch only agora/phantasia/hermes, '' is unreachable by construction, and the door cannot reset a password, mint a connector, change a role, delete anything, or read the registry. Hermes's grants map also stopped pulling the ENTIRE city account registry to render a few chips.

(4) 🔐 THE IDENTITY ENVELOPE. The connector hub forwarded a person as a BARE x-appolis-email header and every app took it as proof — so a secret-holder could POST to any app's /internal/mcp as anyone and get their whole tool surface, no connector token, no hub. Now every forwarded call carries a 60-second envelope signed with the destination app's own key, audience-bound so an envelope for one room is useless in another, carrying the entitlement role/type and the person's teams. That last part fixes the second half of the bug: a downgrade to Basic used to cap someone's web session immediately while their AI kept writing past the cap until they next signed in. This is also the x-appolis-teams APPOLIS_MCP_DESIGN.md documented but never sent — it now lives inside the signature (§3.1 rewritten). Cutover is self-flipping: a room goes strict the moment its key is set. Verified in production: the impersonation POST that used to return a full tool list now 403s on both kosmos and agora, forged and expired envelopes 403, and the real connector still mounts all 99 tools across four rooms with calls resolving to the right boards.

(5) Session-key rotation is no longer a flag day. The token payload carries no key id, so rotating meant signing everyone out of every app at once. ID_SESSION_KEY_PREV now gives a grace window — sessions signed with the outgoing key keep resolving while new ones use the current key. Proven end-to-end locally.

(6) Account-takeover one-liner (kosmos + agora). provisionFromAppolis matched x.appolisId === aAcc.id with aAcc.id undefined whenever the caller omitted the account header — matching the first local account never linked to an Appolis ID and handing the caller someone else's board. Now guarded, and undefined is never stored.

(7) Hermes header relay. Its Studio proxy did new Headers(req.headers), relaying a caller's own x-studio-role: admin under a valid key; it now builds a fresh header set.

(8) 🎫 Basic tier at the TEAM level (note_1012): Agora removed its per-pipeline project cap in v0.9.2 because for a team app the limits that mean anything are how many teams you OWN and how many SEATS a team has — both minted here, where Agora can't see them. Enforced at team-create, member-add, ownership-transfer AND through the provisioning door: own 1 team, 5 seats (constants at the top of worker.js for Tyler to adjust); joining teams you're invited to stays unlimited. Friendly 403s. The type field is now normalized (trim+lowercase) at the producer — it is free text, and this very screen labels a hub role "Basic" with a capital B, so typing "Basic" wrote a grant that silently did nothing because every app compares === 'basic'; the grant modal now suggests basic and capped grants show a 🎫 badge.

STILL OPEN — see the rotation runbook in §5: the burned ID_SECRET value is still live in six workers' vars, and connector amt_ tokens are a parallel credential plane that no rotation touches. (⚠️ COUNT CORRECTED 2026-08-20 — and it was never six: aws moved to Pages secrets on 07-26, and kosmos's copy became a Wrangler secret on 08-20. Re-measured programmatically across all six configs, it is four — appolis, agora, phantasia, hermes — of which only appolis and agora are git-tracked. The amt_ half of this sentence stands unchanged, and with F2 now closed it is the main reason to retire the value at all. Measured detail lives in the runbook's corrected inventory.)) v0.7.11 (2026-07-24 — 📜 SCAN-FIRST RULE SERVED BY THE CONNECTOR (Tyler's rule, baked in so no client can miss it): the unified connector's initialize response now carries an instructions field — it previously sent NONE, and since the hub answers initialize itself (never forwards it), the per-app instructions never reached unified-connector clients, so no AI client was ever told the house rules. The served rule: SCAN FIRST — ALWAYS — multiple people/AI sessions work these apps concurrently; before creating or changing anything call <app>_overview + <app>_ai_worklist and read the FULL text of items; re-read before each new burst (another session may have moved things); out-of-lane work goes on its own app's board. Also names the app prefixes (kosmos/agora/hermes/phantasia). Connector serverInfo bumped 0.7.0→0.7.1. Repo now under git (baseline 86f3d78). Kosmos's per-app instructions gained the same rule (its v6.96) for direct per-app connector users.) v0.7.10 (2026-07-18 — 🔌 AUTO-MINT CONNECTOR (note_589 #49): every Appolis account now gets ONE connector auto-minted the moment it is created OR claimed — the Free Will crew signup (/id/claim), auto-enroll (/id/admin/ensure), and the admin create route all call a new idempotent ensureConnector(). It mounts entitlements LIVE, so adding apps later never needs a new connector; the same token just opens the new doors. Never a second token.)

1. WHAT IT IS

The public face + distribution layer of everything built here. One catalog (KV apps), three visibility tiers per app, districts by category. Public visitors get the marketplace view and (for PWA-flagged apps) an install hint; the master admin curates the city from inside the page.

2. DATA (KV apps — array)

{ id, name, icon, tagline, url, category, status: live|beta|soon|workshop, visibility: public|unlisted|private, pwa, shareKey (apk_…, rotatable via PATCH {rotateKey:true}), createdAt }

3. API

4. TODO / NEXT

Rendered from appolis/APP_BREAKDOWN.md · this page is generated — edit the markdown, not the HTML
print this page for a PDF