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.
public/, and no other app in the suite had this built — so by his 2026-09-09 rule (no solid design yet → Appolis is the theme everybody copies, and goes first) Appolis built the template before the directive went to the wall. ONE SHARED PLACE, THREE ENGINES: public/style.css gains {scrollbar-width:none;-ms-overflow-style:none} and ::-webkit-scrollbar{width:0;height:0;display:none} — the standards property, the WebKit pseudo-element and the legacy Edge property, because two of the three leave a bar on the third's engine. features.html links no stylesheet at all (it is a standalone deck with its own inline <style>), so it carries the identical block inline with a comment naming the shared place — the rule for the directive is therefore every served page either links the shared sheet or carries the block itself, never a per-component patch. ⚠️ THE INVARIANT THIS COULD HAVE BROKEN, AND THE ONLY ONE THAT MATTERS: the cheap way to satisfy "no scrollbar" is to turn an overflow to hidden — which removes the CONTENT, not the chrome, and is a far worse bug than the bar. Nothing was traded: .hub{overflow:auto} still scrolls, both overflow-x:auto wrappers (the app.js table, the email.html preview) still scroll, and html.locked,html.locked body{overflow:hidden} — the DERIVED SCROLL LOCK required by cmp_8c8134e18f — is untouched, because an overlay freezing the page behind it is not a scrollbar. scrollbar-gutter appears nowhere: the space the bar stole is the point of the ask. 🧪 test/no-scrollbars.mjs, written before the change and proven to FAIL on the old tree (A1 ✘, A2 ✘ naming features.html, A6 ✘ naming the version drift), pins all six as the suite's 16th file: the shared sheet covers every engine · every served page is covered · the bar is hidden but the scrolling is not · nothing reserves space · the overlay lock survives · A6 — app.js VERSION and worker.js CONNECTOR_VERSION are one number. A6 is the structural half of todo_3269: v0.34.0 shipped with the connector still answering /api/apps "0.33.0", and from here the suite fails whenever a session bumps one and forgets the other — a ritual nobody can forget beats a reminder nobody reads. → 685 ✅ across 16 suites. 📜 Also folded in: v0.34.0 never reached the features deck — its header, footer and changelog label still read v0.33.1 and it had no row at all. Both are written now, so the deck matches this doc again. ✅ VERIFIED LIVE: served /api/apps reports 0.35.0 · the served /style.css carries the block · looked at in the pane at 375px and at desktop width — a scrollable panel still scrolls and its bar takes zero width.)scott@flipmylifewellness.com — "as long as it doesn't mess up any current emails that are set up." The door was absolute until now: sendEmail ignored a message-level from outright and assertSuiteAddress refused every address outside appolis.app. That property is unchanged — a caller still cannot CHOOSE a sender. What is new is that the OWNER can pre-authorise one specific address for one specific app, and a caller may then ASK for exactly that. Three conditions, all required, on every send: the address is in that app's allowlist (the app comes from the KEY, never the body); its domain reads verified in the list Resend itself returned; and the display name comes from the allowlist entry, never the caller — a caller-typed name on someone else's address is the same lie in a smaller font. A request that fails any of them is ignored, not refused, and fallback now names which one failed, where the old code ignored a from in silence and a caller could believe its address had been used. assertSuiteAddress gains exactly one way out of the suite domain: a domain in the allowlist that Resend has verified — allowedDomains(cfg) demands the verified status, so the owner cannot open this door by typing a domain, only by proving it. HIS TWO CONTROLS, in admin → ✉️ Email: People who may send (address + display name per app; each row says ready, or the domain's real status and what to do about it) and 🌐 Add a sending domain, which registers it with Resend and prints the exact DNS records. ⚠️ Those records put SPF and the bounce MX on send.<domain> and DKIM on resend._domainkey.<domain> — none replaces a root MX or root SPF record, so a company already receiving mail through Microsoft 365 or Google keeps receiving it untouched, which was his condition. GET /id/mail/identity also returns may_send_as, so an app can show the true From on a composer instead of guessing. TESTS: the three source pins that asserted the OLD absolute form are replaced by sharper ones (the route forwards from as a request; sendEmail hands it to the resolver and never to the wire; the guard still runs on what the resolver produced), plus a new block proving the door at the WIRE — a pre-authorised person on a verified domain reaches it with the owner's name, an unverified domain does not and says why, another app's key cannot use it, a different address on the same verified domain is refused, the stored shape drops duplicates/invalid addresses/unknown apps, and the guard still refuses every other outside domain even with an allowlist present. 15 suites green.)rules-copy, rendered at /preview.html?p=rules-copy as NOW beside PROPOSED in the city's own styling, and he answered YES on the page itself at 06:57:28Z — the first real owner decision taken through that surface, and it landed cleanly on the same morning that two of his Inbox form answers were lost entirely to Kosmos's missing pick handler. The page was the surface that worked. ✅ VERIFIED LIVE: the proposal reads back decision: {yes: true} and refuses a second answer; the served app.js carries the new heading and paragraph; the Rules department renders them in the pane.)before as it is now, after as it would be — and /preview.html?p=<key> shows them side by side in the city's own styling with ✅ Yes / ⛔ No and a note box right there. Raising one (PUT /id/admin/preview) also posts him a form in his Kosmos Inbox whose question LINKS the page instead of describing it, and keeps that form's id on the proposal; answering on the page records the decision and CLEARS the twin form (and says so when it cannot); a tap on the form is the identical answer, applied by applyFormAnswers() on the next wall load or handshake. The whole route sits inside the master-password /id/admin/ tree — no app key, no connector token, nobody else. ⚠️ WHY THE PAGE CARRIES THE ANSWER AND NOT ONLY THE FORM: the same morning, two forms he answered came back with every item unchecked, picks: [], verdict waiting — his taps were not recorded (Kosmos's Inbox save path; filed on the Kosmos board with the earlier no-pick bug, note_589). A decision must not depend on one surface, so it lives on the page he is already looking at AND on the form. 🗄 proposals is registered in lib slim(doc) — the one stored shape both the Durable Object and the KV reader use — so the v0.30.0 lesson (a field the ops write but the store never learned is applied, answered 200 and DROPPED) cannot repeat; test/compliance-do.mjs round-trips a proposal through the object and the mirror, and pins the shape's key list. 🧪 test/compliance.mjs +6 (key/title/after all required · lowercase · duplicate refused · the form id on the record · his yes with his words · never answered twice) · compliance-do +2 · rooms +3 → 664 ✅ across 15 suites, measured in a throwaway worktree. ✅ VERIFIED LIVE (parent P-steps): served /api/apps 0.33.0 · ⚠️ THE PAGE WAS FIXED BY LOOKING AT IT, NOT BY QUERYING IT: the DOM said every panel was present and visible, but the screenshot showed the answer bar (position:sticky, bottom:0) floating OVER the very panels he is meant to read on first paint — un-stuck before commit. A page whose whole job is "look at this" has to be looked at. · the first proposal (rules-copy) raised through the route, its form posted with the link, the page rendering before beside after in the pane · a second throwaway proposal answered ON THE PAGE and its form cleared · the directive cmp_0f71c92a20 grew criterion F8 (a design or function ask carries a clickable example) — MATERIAL, so it is rev 2 and every attester including Appolis went back to outstanding, which is the system working.)applyFormAnswers() reads every open form and makes his tap the decision: ✅ accepts the attestation · ⛔ rejects it with his words as the reason (back to outstanding) · ⛔ declines the suggestion · ✅ APPROVES the suggestion (ownerApproved — not yet a directive, because its criteria still have to be written, but the triage session issues it without asking him again). Then it retitles the form ✅ ANSWERED so the Inbox clears. It runs in TWO places: the moment the wall loads (the wall answers with applied, and the Compliance department shows a mint strip saying what it just applied) and at every connector handshake, which prepends ✅ APPLIED FROM THE OWNER'S INBOX before telling the session what is pending. The hub calls the kosmos room AS HIM through callRoom() — the same signed envelope the connector mints — bounded to 12 forms a call, and a form with no pick stays waiting (the Inbox bug of the same morning, filed on the Kosmos board: a Send with nothing ticked must not read as answered). 2 · 🏷 EVERY DIRECTIVE NAMES ITS TEMPLATE — the app whose built implementation is the reference: the submitting app when it already has it, otherwise Appolis. It rides the handshake ("🏷 TEMPLATE: appolis — copy the PATTERN, never the pixels; if you ARE appolis, you go first"), shows as a mint chip on the wall, and is set on the new-directive form. Changing it is never material — a label cannot recall an app that already complied. 🧪 test/compliance.mjs +9 (form ids on both record kinds · a bad id and a missing record refused · ✅ approves without deciding · the summary carries form + approval · the template stored lowercase, carried to openFor/board/handshake, and not material) · test/rooms.mjs +2 (the review form and the kept ids; the apply step on both doors) → 654 ✅ across 15 suites, measured in a throwaway worktree. ✅ VERIFIED LIVE (parent P-steps): served /api/apps 0.32.0 · a real attestation posted a real review form to his Inbox and the id landed on the record · a ✅ tap on that form became accepted on the wall with the form retitled · a form with only ▶ stayed waiting · the handshake carried the applied line and the 🏷 TEMPLATE line.)🛠 HEPHAESTUS DOORS: LOCAL (open if the session's tool list has the hephaestus-local door), SERVER (HEPHAESTUS_SERVER_URL, "not yet" until the owner's server opens), CLOUDFLARE (the hub asks the room's roster through its own binding at connect, ≤1.5 s, fail-open: count · tiers · target · named gaps — and a room that does not answer is SAID as a shut door, never an empty roster — the tools/list lesson). The text is lib doorsText(), pure and tested. ❓ THE FORM BRIDGE — the moment appolis_compliance_suggest files a suggestion, the hub posts the owner a decision form through the kosmos room (post_decision_form on the Appolis board: accept as written · accept with changes · decline with a reason · not now) and answers with the form's note id; a form that could not be posted is said in the response (form: 'not posted: …'), never swallowed; the handshake's triage line now tells the Appolis session to read the answer form before triaging. 🧪 test/compliance.mjs +4 (open · shut · absent · server) · test/rooms.mjs +2 (the hub asks the room; the bridge posts and says) → 643 ✅ across 15 suites, measured in a throwaway worktree. ✅ VERIFIED LIVE (parent P-steps): served /api/apps 0.31.0 · a REAL initialize through the owner's connector carries the 🛠 HEPHAESTUS DOORS block with the live roster count · the two directives on the wall with every room's row · a real suggestion filed from this session posted a real form to the Inbox (note id in the response) and was then declined with a reason as the store check.)doc.surfaces — slug · name · parent room · kind · url — with addSurface / editSurface / removeSurface as owner ops through the admin door (PATCH /id/admin/compliance {addSurface:{…}} | {editSurface:{…}} | {removeSurface:slug}); the worker refuses a parent that is not a room and a slug that is one. THE ROSTER is now rooms ∪ the code seed (EXTENSION_APPS, still ['flip-hq'] as belt and braces) ∪ the surfaces — read as extraApps(doc) at every roster site (listable, suggest, attest, the one-directive rows, the summary, the wall) with the doc each site already loaded, so a surface added on the wall is live for every session on its next call. THE RULE from v0.29.0 holds for surfaces: a surface owes ONLY the directives that name it ( = every room), attests AS ITSELF, and criterion 9's own-palette clause is per surface, never inherited. THE DELIVERY GAP CLOSED: a surface has no door, so its open work is now DELIVERED through its parent's — surfaceNudge() rides the parent room's handshake instructions and its tool-call nudges ("🧩 FLIP HQ (flip-hq, a Shopify theme built inside hermes) owes 1 compliance directive of its own: … It attests AS ITSELF"). Removing a surface keeps every attestation it made. THE WALL: a 🧩 Surfaces card between the suggestion box and the new-directive form — list, add (slug · name · parent select · kind · url), remove with a styled confirm; the connector's no-arg summary lists the surfaces too. ⛔ CAUGHT AT P3, FIXED BEFORE COMMIT: the first deploy accepted addSurface with a 200 carrying the record — and DROPPED it. The Durable Object stored a whitelist of three fields (seq · directives · suggestions) and loadKV() read the same three, so a field the ops write and the store never learned was applied, answered and lost: a write that reported success and was not written, on the store built to end lost writes. The suite had passed because the store test never added a surface. Now ONE slim(doc) in the library is the stored shape for both the object and the KV reader, surfaces is in it, and test/compliance-do.mjs round-trips a surface through the object and the mirror. 🧪 test/compliance.mjs +9 (slug/name/parent validation · lowercase registration · duplicate refused · vs named on a doc-registered surface · openFor + board rows · the parent-door nudge text · edit · remove keeps attestations) · compliance-do +3 (the round trip, loadKV, the one shape) · rooms test rewritten → 637 ✅ across 15 suites, measured in a throwaway worktree. ✅ VERIFIED LIVE (parent P-steps): served /api/apps 0.30.0 · the wall shows the 🧩 card and rooms in the parent select · flip-hq REGISTERED as the first surface (parent hermes, kind Shopify theme) from the wall, no deploy · compliance_list app:flip-hq still exactly cmp_progress0001 · the no-arg summary lists the surface · a real initialize through the owner's connector carries the 🧩 line for hermes · a bad parent and a room slug are refused with a reason.)flip-hq, repo flip-my-life-theme) — a Shopify theme section, not a Worker, so it has no connector room and could never appear on the wall. THE MECHANISM: the roster extension built in v0.25.1 for hephaestus (and emptied in v0.28.0 when hephaestus became a room) — but with ONE rule changed, because hephaestus under had shown six rows for a directive meant as one: *an extension app is targeted ONLY by a directive that NAMES it; means every ROOM.* targetsApp() in lib/compliance.js carries the rule; the list EXTENSION_APPS = ['flip-hq'] now LIVES in the library (one truth for the hub's rows, the connector's listable/suggest/attest rosters, and the rule), and worker.js binds COMPLIANCE_EXTRA_APPS to it. Attesting an extension app against a directive that never named it is refused (does not apply), not recorded. THE OWNER'S DECISION on the wall (master cookie, P-steps): sug_26d2f566bf ACCEPTED with the directive it became, and cmp_progress0001's apps edited from to , flip-hq — adding an app is NOT material (the library's own rule since v0.21), so the rev stays 7 and no attested app is recalled. Flip HQ's row on the wall: exactly ONE open directive, progress rev 7, outstanding — until the Hermes/FLIP HQ lane attests through any licensed session (appolis_compliance_attest {app:'flip-hq'}). 🧪 test/compliance.mjs +4 (the rule: * reaches a room not an extension app · a naming directive reaches both · flip-hq's open list is exactly the naming directive · attesting outside it is refused) · rooms test rewritten → 625 ✅ across 15 suites, measured in a throwaway worktree. ✅ VERIFIED LIVE (parent P-steps): served /api/apps 0.29.0 · compliance_list app:flip-hq → the one directive · app:kosmos unchanged · progress0001 rev 7 unchanged · the suggestion shows accepted with its directive.)MCP_APPS.hephaestus = 'HEPHAESTUS' — the room; (2) APP_KEYS.hephaestus = 'APP_KEY_HEPHAESTUS' — the key that signs its envelope; (3) the service binding HEPHAESTUS → hephaestus in wrangler.jsonc; (4) ONE shared key value generated in a shell variable and piped into wrangler secret put on BOTH workers (APP_KEY_HEPHAESTUS here, APPOLIS_APP_KEY on the hephaestus worker) — never displayed, never in a note, never through chat; the hephaestus repo was not edited. HOW THE DOOR WORKS, and why it is stricter than the other four: hephaestus ignores the transport header x-id-internal (it never held the legacy secret) and admits a call only on a valid x-appolis-id envelope signed with APP_KEY_HEPHAESTUS (aud 'hephaestus', ≤60 s, hubRole 'owner') — mintEnvelope() returns null without the key, the room answers 403, nothing opens by accident. Its four tools reach a master session as hephaestus_roster · hephaestus_daimon · hephaestus_dispatch_plan · hephaestus_ledger (the hub prefixes every room's tools with its slug). Hephaestus's own /api/* gate resolves identities through its APPOLIS_ID binding with that same key — it works the moment APP_KEYS carries the slug. The roster extension empties: COMPLIANCE_EXTRA_APPS = [] — hephaestus graduated from a wall-only row (v0.25.1) to a real room that receives directives at connect; the mechanism stays for the next app that owes a directive before it has a door. 🔐 The new key has NO APP_CAPS entry: a stolen room key opens no provisioning door. ⚠️ FINDING, filed not fixed: the hub's tools/list DROPS a room that fails to answer without a word ((j && j.result && j.result.tools) || []) — a silent room reads as "no tools", so the only proof a room is open is the tool COUNT (130 → 134). 🧪 new test/rooms.mjs (+9: the three registries agree, key per room, binding per room, no stray binding, no caps for a room key, fail-closed minting, slug prefix) · compliance +0 (one assertion rewritten) → 621 ✅ across 15 suites, measured in a throwaway worktree. ✅ VERIFIED LIVE (parent P-steps): wrangler secret list on appolis now names APP_KEY_HEPHAESTUS; hephaestus.appolis.app /api/roster 503 → 401 (the gate is configured, and refuses a stranger); POST /internal/mcp with no envelope and with the LEGACY value → refused, not 503; deploy output lists env.HEPHAESTUS (hephaestus); /api/apps reports 0.28.0. The tool count 130 → 134 is confirmed once the owner refreshes his connector's tool list.)"display": "standalone", which on Android keeps the system back/home/recents bar on screen and shrinks the app above it; Kosmos (v8.88.0) and Hermes declare fullscreen. NOW: "display": "fullscreen", display_override: ["fullscreen","standalone","minimal-ui"], orientation: "any" — field-for-field what the two apps that already went full screen declare — plus the head flags they carry (mobile-web-app-capable, the legacy Android app-mode flag that was Kosmos's one missing difference; the three apple-mobile-web-app-* twins). THE TRAP THAT WOULD HAVE MADE THE FIX INVISIBLE: Appolis's service worker served manifest.webmanifest CACHE-FIRST (the v2 stale-shell fix had moved code to network-first and left the manifest behind) — a phone would have kept reading the old standalone manifest from its own cache. The manifest is network-first now and the cache name is bumped (appolis-shell-v3) so every client drops the old precache on activate. ⛶ THE TOGGLE (Hermes's pattern): a manifest reaches only the INSTALLED app, and only after Android re-mints it; in a browser tab nothing but the Fullscreen API can hide the system bar. The Hub footer now carries ⛶ Full screen / ⛶ Leave full screen (the label says what pressing it does; aria-pressed), the wish is remembered in localStorage['apx-fs'] and re-armed on the NEXT tap anywhere — never fired on load, which the browser would refuse — and the button hides when the app already runs in display-mode: fullscreen. ⚠️ SAID PLAINLY: an installed Android app is a WebAPK; Chrome re-reads its manifest about once a day and applies a changed display mode on the launch after that. Removing the Appolis icon and adding it to the home screen again picks the new mode up at once. 🧪 test/shell.mjs +4 (manifest fields · head flags · SW network-first + cache bump · the toggle's four properties) → 612 ✅ across 14 suites, measured in a throwaway worktree. ✅ VERIFIED LIVE (parent P-steps): served manifest reads "display":"fullscreen" with the override order; served sw.js is v3 with the manifest in the network-first set; served index carries the four flags; the Hub footer shows ⛶ Full screen in the pane at 375 and 1280. NOT verifiable from here: the system bar disappearing on Tyler's actual phone — that needs his re-add or Chrome's next manifest refresh.)<span class="hubword">Hub</span> on every width, the APPOLIS wordmark still beside it on desktop and hidden on phones, own palette (mint). todo_2834, told honestly: the finding was half wrong. appolis_compliance_list already returned ONE directive in full via id:; the session had used directive: — the attest tool's word for the same thing — and the unknown argument was ignored without a word, so it was handed the whole wall (≈50k chars for hephaestus). The half that was right is the silence. Fix: directive / directiveId are aliases of id; any other unknown argument is named back in a warning field on EVERY response of the tool (okw wraps all five return sites); the schema says so. A caller learns on the first call, not the fifth. 🧪 new test/connector-list.mjs +11 (lifts the real KNOWN/okw lines out of worker.js and runs them: an alias raises no warning, a typo is named, the payload survives) + shell +1 → 608 ✅ across 14 suites, measured in a throwaway worktree. ✅ VERIFIED LIVE (parent P-steps): served shell.js carries hubword; the button reads "Hub" at 375 and 1280 in the pane; the connector answers {directive:'cmp_…'} with ONE directive and no warning, and {directve:'cmp_…'} with the whole summary plus a warning naming directve; chrome re-attested for appolis with the A3 deviation removed.)closeHub() restored scrollY correctly and then called focus() on the sticky Hub button, and Chrome scrolls a focused sticky element to its static position (measured live: 400 → 0; with focus({ preventScroll: true }) 400 stays 400). Both Hub focus calls now pass preventScroll; the plain .overlay modals never had the bug (askConfirm at 300 released to 300). (2) A5 — the phone rail is signed-in only: railFor gave an anonymous visitor City + a dead "My connector"; it now returns nothing for a visitor, and rail() hides when there is nothing to show. (3) B4 — a start screen you can no longer open is now CLEARED, locally and on the Appolis ID (PATCH /id/me {startScreen:''} in the background — the worker stores undefined), instead of falling back with the same notice on every load. 🧪 test/shell.mjs +3 → 596 ✅ across 13 suites, measured in a throwaway worktree. The overlay audit for the scroll-lock attestation: six .overlay creators in app.js (askConfirm · askPrompt · the sign-in sheet · Grant an app · the master-key sheet · the iOS install note) — every one dismisses on backdrop click AND on its own Cancel/OK, none is released only after a line that can throw, and there is no boot veil.)public/shell.js + public/shell.css own the frame; app.js still owns every panel. Departments — 🏙 City · 🪪 Identity (Appolis IDs · Teams as sub-tabs) · 📋 Compliance · ✉️ Mail · 📜 Rules — hash-routed (#/identity/teams), one on screen at a time; the header is ONE sticky row with a measured --bandh and safe-area padding; on desktop the departments sit in the row, on phones they are the fixed bottom rail and the wordmark hides (A1/A2/A5–A7). The Hub opens from the Appolis mark as the control room: the lockup carries the name and tagline, seven LIVE instruments for the master admin (Version truth — app.js against the worker's version, which /api/apps now reports · Directives open/rejected/stale/outstanding from the wall · Mail health · Appolis IDs · Teams · Connectors · Standing rules), then the departments as tiles, My connector, Docs & proof, + Add an app, and "Open here first". Start screen (B1–B7) — saved on the person's Appolis ID (PATCH /id/me, carried in publicAccount.prefs), local for a master-key-only session; a deep link always wins; a start no longer open to you falls back to the city with a one-time notice; nobody who never chose is moved. ‹ Back on phones is screen-aware (Hub → modal → history → your start → "you are home"). Scroll-lock (cmp_8c8134e18f) — derived from what is open (Hub OR any .overlay) by a MutationObserver, never toggled; the open surface still scrolls itself on touch; the last close restores the scroll position; the four existing .overlay modals were audited — each releases on its own buttons, none behind a throw. 📱 No sideways scroll on phones — long ids/URLs/backticked code break (overflow-wrap:anywhere), fields stay inside their cards, and body{overflow-x:hidden} became clip because hidden silently defeats position:sticky (the trap on record). ⚠️ NO SEARCH was added: Appolis has none, so A8 is n/a and the shell says so rather than shipping a box that filters nothing. Z-order stated: content < rail 40 < header 50 < hub 70 < modals 80 < wait popup < version banner. 🧪 new test/shell.mjs +31 (routing · start screen B3–B5 · derived lock incl. two-up-one-closes · instruments · the frame pinned against served files · and the one below) — 593 ✅ across 13 suites, measured in a throwaway worktree. ⚠️ CAUGHT LIVE AT P3, ROLLED BACK, FIXED BEFORE COMMIT: the first deploy of this build broke the portal ("The city gate is stuck") because the shell read window.DATA, window.esc and window.VERSION — and app.js declares those with let/const, which are global LEXICAL bindings, not window properties, so the shell saw an empty DATA and the first render threw. The suite had passed because the pure half never touches them. Live was rolled back to v0.25.1 within minutes from a clean worktree, the shell now reads the bare names, and a source-pinned assertion forbids the window. form. The browser check that caught it is exactly why P3 reads the served page and not the source. ✅ VERIFIED LIVE (parent P-steps): served index.html carries id="apxbar", id="rail", id="hub" and no .hero · served shell.js/shell.css 200 · served app.js v0.26.0 · /api/apps reports version · a real PATCH /id/me round-trips the start screen · the Hub opens, locks the page, closes on Esc; the phone rail shows below 860px; nothing scrolls sideways at 390px — checked in the browser pane. Attested: cmp_eb66f53919 (chrome, with A8 declared n/a) and cmp_8c8134e18f (scroll-lock) for appolis.)['appolis', ...Object.keys(MCP_APPS)]), so a directive aimed at hephaestus — the agent factory, a Kosmos project with its own repo and sessions but no /internal/mcp room behind the hub — was stored with ZERO rows on the wall, could not be listed (appolis_compliance_list({app:'hephaestus'}) refused the slug), could not be suggested from, and could not be attested. Now COMPLIANCE_EXTRA_APPS = ['hephaestus'] sits beside MCP_APPS and is spread into the five places the roster is read: the admin wall's slugs, the connector's listable, canSuggest and attestable, the who-owes-what summary and the one-directive rows. ⚠️ What this does NOT do: deliver at connect — delivery rides the rooms, so a Hephaestus session still learns of a directive from its Kosmos board (todo_2803) until it has a room; and any licensed connector session may now attest FOR hephaestus, exactly as any may for appolis — the Appolis lane does so on its behalf, by design. 🧪 compliance.mjs +4 (source-pinned: the constant, the wall, the three connector doors, the summary + rows) — 561 ✅ across 12 suites. ✅ VERIFIED LIVE (parent P-steps): appolis_compliance_list({ app: 'hephaestus' }) returns cmp_ecd22804ea in full · the admin wall shows a hephaestus = outstanding row on it · the no-arg summary carries a hephaestus key · served app.js v0.25.1.)lib/compliance.js was read-modify-write over one KV document and KV has no compare-and-swap; the seq re-read only narrowed the race, and on 2026-09-02 three attestations from other colos each read a stale copy and silently reverted a rev-5 directive edit. Directives are how work travels between apps now, so a lost write is the whole system lying. WHAT: lib/compliance-do.mjs — ComplianceStore, one object (idFromName('compliance')), seeds ONCE from the KV document and never re-reads it, runs every op under blockConcurrencyWhile, mirrors every successful write back to KV id:compliance in exactly save()'s shape, refuses unknown ops with 400. lib/compliance.js — applyOp(doc, name, args), the ONE dispatcher (createDirective, optionally from a suggestion in the same write · attest · suggest · admin, which runs the PATCH route's branch), and op(env, name, args) / load(env) that route to the object when COMPLIANCE_DO is bound and to the old KV mutate() when it is not — so the tests, and a rollback, keep working unchanged. worker.js — the four write sites call ops BY NAME (a Durable Object cannot receive a closure) and re-export the class. wrangler.jsonc — COMPLIANCE_DO binding + migration v1 (new_sqlite_classes). ⚠️ ROLLBACK: revert code + config; the mirror holds the latest document so nothing is lost; the class stays registered until a deleted_classes migration. ⚠️ RETIRED: the ≥2-minute wait after a directive edit (todo_2379's mitigation) — the store serialises now. 🧪 test/compliance-do.mjs +13 (seed once · never re-read · create · mirror · TWO ATTESTS IN ONE MOMENT both land · mirror has both · thin refused · unknown op refused · admin edit bumps the rev · from-suggestion is atomic · op routes to the object · load reads the object · KV fallback) — 557 ✅ across 12 suites, the fake state SERIALISES like the real one so the concurrency test proves something. ✅ VERIFIED LIVE (parent P-steps): the admin board reads identically before and after the deploy (directive count, every rev, every row) · a suggestion filed through the connector and its decline both landed through the object · the KV mirror carries that suggestion and its decision · served app.js v0.25.0.)vars WITH ZERO DOWNTIME — it lives in the worker as the secret ID_SECRET_S now, and wrangler deploy stops printing it (todo_2405). The value was a git-tracked vars entry, and every deploy printed its prefix to the terminal; this session filtered that line twice before fixing it. The runbook's sequence (remove the var → deploy → secret put ID_SECRET → deploy) has a window in which env.ID_SECRET is undefined — and an inventory on 2026-09-04 showed that window is not free: Kosmos (lib/freewill.js:55,348 · lib/hubmail.js), Agora (worker.js:314,317,1000), Phantasia (worker.js:22 → server/api.js), Hermes (worker.js:241,256 · server/lib/comms-lane.js:171,369,371) and AWS Motor Club (server/worker.js:66,69) ALL still send this value as x-id-internal on their outbound calls, so the window would have 401'd every one of them. The zero-window path instead: (1) wrangler secret put ID_SECRET_S with the same value (a NEW name, so Cloudflare 10053 does not fire); (2) callerApp() and the hub's own outbound transport read env.ID_SECRET_S || env.ID_SECRET; deploy — both defined; (3) drop the vars block from wrangler.jsonc; deploy — the secret carries it; nothing anywhere notices. ⚠️ What did NOT change: the value itself (a rotation is todo_1093's separate carry-over — the value is in git history and stays burned until then); what it opens (transport only, slug:null, never /id/admin/); sessionKey() (ID_SESSION_KEY, no fallback, unchanged). ⚠️ The weekly checks 2/3/10/11 used to read the value from wrangler.jsonc; they now read it from history — git show debc9ae:wrangler.jsonc — which is no worse than the file was, and STATE + the task file say so. Once every app carries its own APP_KEY_ on outbound calls, ID_SECRET_S is deleted and the || env.ID_SECRET fallback with it (runbook Step 2). 🧪 no test changes — 543 assertions across 11 suites unchanged. ✅ VERIFIED LIVE (parent P-steps): after the first deploy (var still present) the legacy probes answered as before — /id/claim with the legacy header → 401 unknown app key · /id/mail/identity legacy → 200 app:null; after the var was dropped and the second deploy landed, the same probes answered the same, kosmos/agora bare-email → 403, the deploy output carried no ID_SECRET line, and wrangler secret list shows ID_SECRET_S beside ID_SESSION_KEY, PASSWORD and the four APP_KEY_*.) (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.)
cmp_34f01cf47c's fail condition is stated so it can actually be failed: "if your mail would look the same with the markup stripped out, it does not pass." Appolis's own Send a test email — the route this app hands every other app as the way to prove criterion 13 — was a text:-only send. Four sentences of plain text, no html at all. It is the same defect Tyler rejected on 2026-08-23 ("these are hideous. Text only is crazy"), quoted at the top of lib/email-template.js as the reason the shared shell exists, on the one route that shell was never applied to.🔍 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.)
cmp_progress0001 went to rev 6 with criterion 2 reworded accordingly (corner toasts and "waiting" still fail); /progress.html ② and the build-your-own bullet say the same; no behaviour change in the portal (its loads have no other specific indicator, so the popup stays). ⚠️ AND A LOST UPDATE IN PRODUCTION, found on the way (todo_2379): the 03:36Z rev-5 edit was silently reverted to rev 4 by three connector attestations at 03:37–03:38Z (Hermes ×2, Appolis ×1) that each read a KV copy which had not yet seen it — mutate()'s seq re-read compared stale against stale and passed. Exactly the residual race todo_1893 named. Re-applied at 04:06Z and read back; the pending suggestion sug_71d7d8c9ed (Durable Object) is the real fix and this is its evidence. ⚠️ This entry itself was first written with its backticked fragments stripped — an inline shell script expanded them as command substitutions — and re-written whole from a file; the served page was re-published.)top:50%; left:50%; translate(-50%,-50%)) over a soft scrim (rgba(5,8,15,.42) + 2px blur) that, like the popup itself, is pointer-events:none — a hung request can never trap the page. Every route's label now carries a specific second line in place of the generic one: "Fetching your apps, Appolis IDs, teams and settings.", "Asking Resend which of the suite's domains are verified.", "Emailing a 6-digit code to your address." — WAIT_LABELS became [label, hint] pairs, labelFor() returns {label, hint}, progressStart(label, hint), and the test now refuses any hint that says "wait" or "working". /progress.html follows: ① quotes the rev 6 ruling and its demo raises a centred popup with the same markup; ② is "the wait popup — CENTRE of the screen since rev 6" and its amber note records that v0.23.0 put it in the corner and Tyler's verdict took one look; the build-your-own bullet says centre popup + specific words, never a corner toast. 🧪 test/progress-driver.mjs +9 (centre CSS asserted and corner placement forbidden; scrim on with the popup and never blocking; the specific second line; no "still working" in the code; hints for every route with "wait/working" refused; rev 6 page copy). 512 assertions across 11 suites; four mutations killed (popup back in the corner · generic still-working line · scrim never hidden · hint dropped from api()). ✅ VERIFIED LIVE after bc44883: served v0.23.1 · the shipped script centres the popup and has no corner placement · /progress serves rev 6 · runtime proof on the LIVE portal through the browser pane, 900ms into a real progressStart(…): #apx-work on (display:flex, position:fixed, centred by top/left:50% + translate(-50%,-50%) in the served CSS, pointer-events:none), text "Loading the city… · 1s — Fetching your apps, Appolis IDs, teams and settings."; #apx-scrim on (inset 0, rgba(5,8,15,.42), pointer-events:none); bar on beside it. ⚠️ Still no screenshot on record: the browser pane was hidden (viewport 0×0), so the computed pixel positions were degenerate and the visual could not be captured — the DOM/CSS state was. 📋 cmp_progress0001 goes to rev 6 (criterion 2 rewritten: a centre-screen popup that says what the app is doing, in specific words — never a corner toast, never just "waiting"); Appolis re-attests with this build.)api() → progressStart(label)) now shows a WAIT CARD in the corner as the primary answer — the work named in words by route (Loading the city…, Saving email settings…, Asking Resend which domains are verified…), a live seconds counter, role=status / aria-live=polite — at the same 700ms restraint threshold, reference-counted with the bar, gone the moment the last piece of work ends; the bar runs beside it as a secondary cue. Labels live in the helper (WAIT_LABELS, longest prefix wins; a call site may pass opts.label), so a route added later gets a label by construction, and a test asserts every route the portal calls today has a SPECIFIC label rather than the generic fallback. /progress.html recast to rev 5: ① is "a SECONDARY cue, never alone" and its demo shows the card beside the bar; ② is "the PRIMARY answer since rev 5" and the amber note now says Appolis uses it on every wait; ⑥ says which of the three answers which question; the build-your-own list carries criterion 2. 🧪 new test/progress-driver.mjs (26 assertions) runs the REAL driver, lifted from the shipped app.js, against a fake DOM with real timers — card shown on a genuine wait, nothing on a 200ms action, reference counting, labels, api() wiring, and the page's copy. Three mutations killed (card removed · api() without a label · card never hidden). 503 assertions across 11 suites. ✅ VERIFIED LIVE after ad83aed: served v0.23.0 · app.js carries the card · /progress serves the rev 5 copy · runtime proof on the LIVE portal through the browser pane, against the shipped driver, 900ms into a real progressStart('Loading the city…'): #apx-work is on (display:flex), reads "Loading the city… · 1s — Still working — this goes away the moment it finishes.", role=status / aria-live=polite, and the bar is on beside it. ⚠️ No screenshot is on record: the browser pane was hidden at that moment (viewport 0×0), so the visual could not be captured — the DOM state was. 📋 cmp_progress0001 goes to rev 5 (new criterion 2: a top-edge bar is never the only indicator; a proof page must show the intuitive indicator for each kind of wait). Kosmos, Hermes and Appolis go back to outstanding by design; Appolis re-attests against the new bar with this build.)app, and that label keyed the per-app 300/hour rate limit AND the idempotency namespace — it could spend Kosmos's quota or collide with Kosmos's idempotency keys. It is now legacy, full stop. (2) sender_name admitted bidi overrides, zero-width joiners, NUL and fullwidth look-alikes ("Kosmos") and RFC 5322 specials into an UNQUOTED display name; it is now NFKC-folded and stripped of every control/format character and every special before use — a fullwidth app name even dedupes against the real one. (3) The roster check was a truthiness test on APP_SENDERS[slug], so the admin test could send "as constructor" (a Function is truthy); it is an own-property check now (isRosterApp). (4) Any key holder could paint the suite-wide "LAST SEND FAILED — nobody can sign in" row red with one malformed request; a caller's mistake (400) now lands on that caller's own row only, and only OUR faults (502) touch the suite-wide row. (5) Reply-To was caller-chosen for every caller; it stays a NAMED app's choice (Kosmos points reviewers at the hub owner) and a keyless caller gets none. (6) The Resend domain list was capped at 50 unsorted entries, so a suite subdomain past position 50 would silently have fallen back; the list now keeps only the apex and its subdomains (the account also holds other businesses' domains) with a cap of 200. Also: the admin panel refetches per-app health after a test send, so the identities table updates without a reload (todo_2362). Nothing the lens tried could make any caller send as another app or from another address — that property held. 🧪 sender-identity.mjs +16, otp.mjs +3 — 477 assertions; seven mutations killed. ✅ VERIFIED LIVE after 6b2d1b6: served v0.22.1 · a legacy-secret call to /id/mail/send answers app:"legacy" · /id/mail/identity, escalation and admin gates unchanged (401/401/401) · portal 200.) ⚠️ 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 <img src=x onerror=…>, 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).)
Appolis <no-reply@appolis.app>. See v0.22.0. ✉️ THE TEST BUTTON CAN NOW SEND AS ANY APP — closing a gap v0.20.0 should not have shipped with. Tyler asked for a test mail to prove the new per-app sender. Going to send it, I found the ✉️ panel could only ever send as the CONFIGURED identity — the one control built for checking mail could not check the thing that had just changed. Proving it took a session hand-rolling a send against the Resend API, which is not a thing the owner should need. A feature the owner cannot verify from his own panel is one he has to take on trust, and this house does not do trust.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.)
POST /id/mail/send — THE DOOR EVERY OTHER APP WAS BLOCKED ON. A tested Hermes feature has been sitting undeployed because of this repo. 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.)
/id/login finally gets a rate limit.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 browser — publicEmailConfig 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.)
GET /id/connector?email= used to answer any holder of the shared machine key with that person's live /mcp/<token> URL — an amt_ bearer credential that needs no header, acts fully as them across every app they are licensed for, and which no key rotation invalidates, so it survived every step of the rotation runbook. It was the single worst thing the burned ID_SECRET could still reach. Proof is now REQUIRED: the only accepted proof is x-id-token, the person's own signed appolis_id cookie. ⚠️ 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.)
/id/claim now requires a real per-app key. 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/claim → 401 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 PASSWORD — ID_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. F1 — GET /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. F2 — ID_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.)
mine for the signed-in ID (entitlements ∩ catalog; * = all). This is the installed-PWA home the v0.7.8 service worker earns; admins still see every building uncut. Also fixed the SW to network-first for app code (v2) so deploys are never stale — cache-first had frozen app.js, the classic PWA stale-shell trap, caught in browser verification.)flipper entitlements open the hermes room). One URL, every licensed app.) isAdmin() accepts a valid appolis_id session whose account is role master (not disabled), alongside the PASSWORD-key cookie which stays as break-glass only (works even if the ID store is down; also still the fallback door on suite apps). The separate "master key" is no longer the identity — Tyler's one login (tyler@spartanstudios.com) IS the master everywhere: portal admin here, ROOT on Kosmos (master-email map, v6.28), ROOT on Agora (master-entitlement map, its v0.6.1 same day), Hermes master admin (same email). Doctrine recorded: per-app master keys = break-glass, never the daily door.) v0.7.0 (2026-07-16 — 🔌 APPOLIS CONNECTOR P1 — one MCP door for the city (todo_578, APPOLIS_MCP_DESIGN.md): ONE connector URL per person — POST /mcp/<amt_token> on the apex worker, a pure JSON-RPC ROUTER that owns no app data: initialize/ping answered locally ("Appolis Connector"), tools/list merges every ENTITLED app's tools slug-prefixed (kosmos_overview, …) by forwarding raw JSON-RPC over service bindings to each app's new internal POST /internal/mcp (ID_SECRET-gated pass-through — the app's OWN MCP brain, its own permission rules, identity by x-appolis-email), tools/call strips the slug and fans out (unknown/unentitled slug → -32602 at the hub). Licensing == visibility: entitlements mount toolsets ( = every registered room), a token's optional app filter narrows further; revoke = regenerate token, revoke entitlement, or disable the ID — all instant. Handshake lessons carried: invalid token 404 never 401, /register + /.well-known/ clean JSON 404, notification = message with NO id member (id:0 is a real call). Tokens live in KV id:connectors ({token: amt_…, accountId, apps, createdAt}), minted/regenerated/revoked from the 🪪 district (🔌 buttons per account row, URL auto-copies) or POST /id/admin/accounts/:id/connector; account delete scrubs its connector; internal GET /id/connector?email= lets every suite app show the person's one URL (todo_583's P2 surface, shipped early). Registered rooms: MCP_APPS = {kosmos} + the KOSMOS service binding — Kosmos is the first room (its v6.46 ships /internal/mcp reusing lib/mcp.js verbatim; 23 tools mounted). Live-verified e2e on prod with a throwaway ID (create→grant→mint→initialize(id:0)→notification 202→tools/list 23 kosmos_ tools→tools/call ran on the test account's EMPTY isolated board→unknown-slug -32602→bad token 404→GET 405→delete ID→token 404), then every artifact cleaned incl. the lazily-provisioned Kosmos member + its db: key. P2 = agora/hermes/phantasia rooms + the in-app 🔌 connector block.) v0.6.0 (2026-07-16 — 🏛 TEAMS FOUNDATION + the Agora↔ID contract (todo_650 + todo_654, P1 of the Teams layer per note_649): the ID store gains a teams registry — KV id:teams {teams:[{id, name, ownerAccountId, members:[{accountId, role}], createdAt}]}, roles EXACTLY Owner | Admin | Lead | Basic (Tyler's locked vocabulary; one 👑 Owner per team, changed only by transfer — promoting a member to Owner steps the previous Owner down to Admin). Teams reference EXISTING Appolis IDs only, never mint (the v0.5.0 one-way identity rule; add-member 404s on unknown ids). Every identity response now carries teams:[{id,name,role}] — /id/login, /id/whoami, internal /id/check + /id/resolve — so an app answers "which team boards, as what role" in one call. New internal reads (the exact contract Agora v0.4.0 already stubs against): GET /id/teams?email= (memberships for local/master sessions holding no token; 404 unknown, 403 disabled) + GET /id/teams/:id/members (the member directory driving Agora's 👤 assign pickers — id/name/email/role/disabled per member). Admin CRUD /id/admin/teams[...]: create (optional custom id, e.g. team_spartan, so the first real team ADOPTS Agora's stub board id — data carries over), rename, delete (unbinds — not revokes — entitlements pointing at the dead teamId), add/remove member, set role w/ ownership-transfer semantics; Owner can't step down or be removed (transfer first); deleting an ACCOUNT that owns a team → 400 until transferred, plain memberships get scrubbed. Entitlement grants now validate teamId against real teams. Portal UI: a 🏛 Teams district in the master-admin view (found team w/ owner picker + optional id, per-member role dropdowns w/ transfer confirm, add-member picker of existing IDs, rename/delete) + the grant dialog gains a 🏛 team binding select + entitlement chips show their team. Slug vocabulary upgraded: picker = suite BASELINE (kosmos·hermes·phantasia·agora — todo_654 \"register the agora slug\"; apps that verify entitlements but lack a catalog card) merged with live catalog slugs. Verified e2e on isolated local KV (15-step flow: create/guards/contract-carriage/transfer/blocks/unbind — all pass) + browser console clean; deployed; live-verified (v0.6.0 live, routes gated 401/404-no-header). PROD SEEDED: team team_spartan \"Spartan Studios\" created with Tyler's master ID as Owner — /id/teams?email=tyler@spartanstudios.com returns it live, so Agora flips off stub seamlessly (same id + role its stub used). NEXT (other lanes): Agora swaps its /api/team/members stub for GET /id/teams/:id/members; P3 pipeline locks read the role field.) v0.5.0 (2026-07-16 — 🪪 Appolis ID admin surface FINISHED + provisioning-bridge decided (Kosmos board todo_646): closes the last gap the v0.4.2 pass left open — disable ≠ delete. A reversible disabled flag on the account: new route POST /id/admin/accounts/:id/disabled, enforced at EVERY door — /id/login + internal /id/check → 403, /id/resolve + /id/whoami → 401, and the marketplace /api/apps cookie-resolve skips a disabled ID's entitled apps — so one flip closes the whole suite at once while keeping the account + its grants on the books. Row gains a ⏸ disabled badge (dimmed) + a ▶ enable / ⏸ disable toggle (confirm on disable); /id/admin/accounts now returns disabled per account. Live-verified end-to-end against an isolated local KV (create → login 200 → disable → login/check 403 → admin list shows the flag → enable → login 200 → delete); prod untouched. DECISION — provisioning→Appolis-ID bridge (the todo's open Q, resolved here): identity flows ONE way, ID→app (pull), never app→ID (silent auto-mint). Apps do NOT mint an Appolis ID when they provision a local user. Why: the master-admin ID registry stays the single vettable source of truth (auto-mint scatters account creation past the admin district); an app-minted ID has no password the person set, so "day-1 SSO" would be a dormant, un-loginnable record; and it would invert Hermes's deliberate 403-for-unknown-email strictness. The sanctioned bridge is invite-based & admin-mediated — v1 (today, keep as-is): master-admin creates the ID + grants the slug, the app-side email matches. Future, only on Tyler's green light: an opt-in internal /id/admin/ensure that mints a PENDING ID (passwordless, disabled:true until claimed) + auto-grants the app slug, which the person claims by setting a password via a suite invite — the disabled primitive shipped today is exactly what that needs. Any Hermes-side call is a Hermes-lane build; the "no auto-mint" default queues nothing there. Left on the Kosmos board only as a note for Tyler to override if he disagrees.) v0.4.2 (2026-07-16 — 🪪 Appolis ID admin surface completed (Kosmos board todo_646): the grant flow no longer takes a free-text slug that could silently mismatch what an app verifies — it's now a dropdown built from the live catalog (appSlugs()/slugOf(), identical to the worker's slugOf, so a grant always equals the value the app checks: kosmos, hermes, phantasia, …). Existing grants whose slug matches NO catalog app render ⚠ gold with a "opens nothing" tooltip so stale/typo'd grants are visible at a glance. Added a 🔑 reset pw button per account (the POST /id/admin/accounts/:id/password route existed with no UI) via a new styled askPrompt twin (styled-input rule). Accounts + entitlements are now fully managed from the portal — no more hand-editing id:accounts/id:entitlements in KV. OPEN for Tyler (design decision, not built): the provisioning-to-Appolis-ID bridge — when an app mints a passwordless user (e.g. Hermes /api/businesses business admin), should it also mint/link an Appolis ID for day-1 SSO? Left as a to-do on the Kosmos board.) v0.4.1 (2026-07-16 — 👑 master role: a master Appolis ID is entitled to EVERYTHING (entsFor synthesizes the wildcard; apps honor it — no self-granting); create-ID form gains the 👑 checkbox, admin rows show 👑 master + "every door" instead of grant chips, POST /id/admin/accounts/:id/role promotes/demotes (Tyler's ID promoted to master); header links disambiguated (🪪 Appolis ID sign-in vs 🔑 master key).) v0.4.0 (2026-07-16 — 🪪 APPOLIS ID, P1 of the one-login design (APPOLIS_ID_DESIGN.md): one account per person for the whole city. New /id/ routes on this worker (login/logout/whoami; internal check/resolve for app workers over service bindings guarded by the ID_SECRET header — the STUDIO_KEY machine-trust pattern; admin accounts/entitlements CRUD behind the master key or the internal header). Identity crypto = Kosmos's lib/accounts.js reused verbatim (PBKDF2 + HMAC). One session cookie on Domain=.appolis.app = signed in at every suite app; entitlements (id:entitlements — app, role, type, teamId) drive both app access AND the portal marketplace view (an entitled account sees its private apps; catalog gains optional slug). Portal UI: 🪪 sign-in in the header + a master-admin "Appolis ID" district (create IDs, grant/revoke apps, delete). Kosmos adopted same-day (its v6.28): dual-auth login + shared-cookie SSO + lazy member provisioning — live-verified end-to-end, then test artifacts fully cleaned. NEXT: Tyler creates his own master ID (tyler@spartanstudios.com) from the 🪪 district and grants himself the suite.) v0.3.0 (2026-07-14 — 🎟 named grants: per-app, per-PERSON keys (grants:[{name,key}], apg_…) added/revoked in the edit box; a grant link greets the visitor by name; keys (share + grant) are stripped from every non-admin response.) v0.2.0 (2026-07-14 — the library is a layer, not a building (Tyler): the standalone "Alexandria/Docs" catalog card is gone; instead every app card carries its own 📋 Blueprint (breakdown) + 🎞 Showcase (deck) links with a per-app "docs visible to visitors" toggle — master admin always sees them (🔒 marks admin-only), the public/share-link view strips the URLs entirely until flipped (verified: no leak). Catalog gains docsUrl/deckUrl/docsPublic; edit box gains the fields + toggle. Docs wired for FLIPPER, Quantum Flip, Phantasia, AWS (docs-hub) and Kosmos (its own gated /breakdown + deck). docs.appolis.app keeps hosting the files — it's the shelf, not a storefront card.) v0.1.0 (2026-07-14 — birth: apex Worker + KV catalog; master-key gate (PASSWORD secret, HMAC cookie, fail-closed); per-app visibility public/unlisted/private + rotating share keys (/?k=… reveals an unlisted app, key never leaks in public API); in-place admin editing (add/edit/delete, status live/beta/soon/workshop, category districts, PWA flag); night-city UI (navy sky, gold lights, mint life); styled-input rule (no bare prompts); one version in one place (footer) + ETag refresh banner; /.well-known/ → clean 404 (the Kosmos connector lesson); seeded with the real fleet — Kosmos, FLIPPER, Phantasia, Quantum Flip, AWS Motor Club, TipLift, Docs — all private until Tyler flips them.)APPOLIS = b1fd91dfe64041c68bda06e1df068faf), static assets binding (run_worker_first: true), zero dependencies, no build step.F:\Claude Code\appolis\npx wrangler deploy · Set the master key: npx wrangler secret put PASSWORD (fail-closed until set — admin and login are 401 everywhere).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.
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 }
GET /api/apps — public → public apps only (share keys stripped); ?k=<shareKey> also reveals that one app; admin cookie → everything.POST /api/login / POST /api/logout — master key session.POST /api/apps · PATCH /api/apps/:id · DELETE /api/apps/:id.npx wrangler secret put PASSWORD (the master key), then flip the first apps public/unlisted. — DONE (verified 2026-07-16: secret set, Quantum Flip public)/internal/mcp + the in-app "your Appolis connector" block (GET /id/connector already live).