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.
/id/me/data-key, session cookie only); it's checked against GovInfo, sealed (lib/datakeys.js, bound to the provider and the account) and never returned. A live Claude seat reads through GET /id/data/datagov/<host>/<path> with x-appolis-shift, using its own person's key: GET only, four named hosts (FEC, Congress.gov, GovInfo, Regulations.gov), the key travels in a header, a caller's api_key is dropped, a redirect is followed only to a named host, and the key is cut from anything that comes back. No app key opens it, and other AIs get SANDBOX_ONLY. Test: test/data-key-9429.mjs.)appolis_clock_in accepts a shared board's own id: acc_<owner>~<slug> as a project or inside a lane becomes the plain slug's lane, which is what already worked, so nothing new collides (two boards with one slug already shared a lane). appolis_whoami answers connector.version. Test: test/lane-shared-id-9409.mjs, whoami W6b.)shifts claim now carries chat (the Kosmos chat the shift declared at appolis_clock_in {chat}, else null — present, never absent) beside id, lanes, intent, state, who, opened_at and heartbeat_at. Kosmos v8.209.3 reads ctx.shifts[i].chat to bind a post that names no shift to the ONE open shift clocked in on that chat, so the context gauge and who-posted-what stay right without every seat passing its shift; two shifts on one chat is still no guess. Only the account’s own shifts (mineFor) carry it. Pinned: test/envelope-chat-9293.mjs E1–E5, which runs the map lifted from the source over shifts opened through lib/shifts.js.)kosmetor: {kp, form, decided_by, action, item, restore} on a call the Kosmetor grant let through — set by the gate only after the live Daedalus row read, so no client can forge it — and null (never absent) on every other call, like sandbox_env. Inert until Kosmos reads it: its half treats that one item's lane as covered and still refuses LANE_HELD. Pinned: test/kosmetor-grant-9030.mjs K17–K18.)shift and writes the clock-out record into every chat bound to it by kosmos_chat_listen (200 {ok, chats, written}; a repeat answers written:[] — not {already:true} as the STATE text said). The ping runs in ctx.waitUntil and the answer waits ≤ 4 s to NAME it: kosmos_ping = 'sent · chat_N · clock-out record written' | 'sent · … already recorded (repeat)' | 'sent · no chat is bound…' | 'not sent: <status> <error>' | 'queued: …'. It never blocks or fails the clock-out; every failure is console.error'd. lib/shifts.js closedPingBody + pingVerdict + PING_WAIT_MS (pure); worker.js pingShiftClosed + the two close paths + the tool description. Pinned: test/closed-ping-8700.mjs P1–P9 (red 0/9 at dddc614).)card and stores it when it is an object; worker.js passes the clock-out card into the op. No card → unchanged. Pinned: test/force-card-8904.mjs F1–F3 (red 2/3 at b8fa8ac). Handshake compact 6808 B (test/rails.mjs harness).)to to a live Appolis ID (email or account id) or answers NO_RECEIVER and moves nothing, and refuses another account's shift (NOT_YOURS); (3) the receiver is shown — appolis_shifts rows in state handed carry handoff {to, at, note, releases_at}, and the LANE_HELD refusal on a handed row says who handed it to whom, when it releases itself, and the three roads out (the receiver's clock-in · wait · force clock-out) and never offers appolis_handoff; (4) appolis_clock_out {id, force:true, card:{could_not_finish:true, note}} from the owner, or from the account that handed it, force-closes a handed row; (5) slim() never evicts a handed row. force_close drops a handed row from closed[] before re-filing it (found at J3). SHIFT_RULE is unchanged; the words are on the appolis_handoff tool and in the refusals. GAP 2 — "daedalus_step answered a Codex seat with step 1 of 9": NOT a leak. The probe ran after v0.73.4 flipped DAEDALUS_SANDBOX_ROUTE on (16:13Z commit; the probing session clocked in after 17:02Z), so hephaestus-sandbox answered, never live Daedalus; owner-only is a test of the Appolis ID (daedalus door.js isOwner reads hubRole), and the Codex seat runs on the owner's ID — exactly what form note_8526 Q2 asked for, inside law 8. What was missing: the answer did not say which copy spoke. NOW every routed room answer carries a separate content item "🧪 answered by the SANDBOX copy of daedalus (binding HEPHAESTUS_SBX) for this codex seat — live daedalus never saw this call". Whether the sandbox copy should render the owner's Claude-shaped routines for a Codex seat is the Daedalus lane's call — filed as a to-do on the hephaestus board. Tests: test/handed-expiry-8842.mjs (12 pins; T1-T3, T6, T9-T12 red at 721d175). Deploy: hub only (the shift tools are never rebound, SBX_SHIFT_TOOLS; akademeia Workers untouched). DRILLED on served: appolis_shifts names sh_6e3eee4e7a's receiver and releases_at; the owner's first clock-in after the deploy expires it and sandbox/codex-phase2 opens; appolis_handoff {to:'nobody'} answers NO_RECEIVER. ASSUMED: the sandbox stamp on a routed answer (a Claude seat is never routed; the next Codex session's handoff file is the check).)builders as <app>--<owner>.akademeia.app; cap 5 live per person; tags owner:/app:/cand:; 30-day sleep sweep (cron 17 4 + POST /api/akademeia/sweep); owner kill switch per app and per person (appolis_app_kill + the Workshop panel). New Worker akademeia-dispatch (folder akademeia/) routes .akademeia.app by hostname with limits cpuMs 50 / subRequests 2 and refuses asleep (503), killed (410), unknown (404). The CF token is read per call from Secrets Store (binding CF_BUILDERS_TOKEN); KV AKADEMEIA is the registry. Tests: akademeia-publish-0740 16/0, akademeia-dispatch-0740 7/0. OUT: builder server code + outbound Worker (P1.5), marketplace (P2), PSL submission.)"DAEDALUS_SANDBOX_ROUTE": "on" — built and tested OFF at v0.60.0, switched on here. A SANDBOX seat (a platform-bound token, or a non-Claude client) now has its daedalus_ tools/list, reads AND writes (daedalus_plan · daedalus_step · daedalus_routines · daedalus_go …) forwarded by fwd() to the service binding HEPHAESTUS_SBX = the Worker hephaestus-sandbox (worker.js sbxRouteFor; the wall's wrouted lets a routed write pass instead of storing a proposal). The sandbox Worker is Daedalus's own copy with its own D1 (hephaestus-sandbox), APPOLIS_ID bound for the identity read only, NO KOSMOS binding — so a routed write cannot touch live Daedalus, live Kosmos or the hub store (law 8). A Claude seat is never routed; the seat's kosmos_ writes stay proposals; appolis_* writes stay SANDBOX_ONLY. A routed road that cannot be taken (no binding · HTTP ≠ 2xx · a throw) answers SANDBOX_ROUTE_FAILED by name and NEVER falls back to live. PRECONDITIONS met by the Daedalus lane today (sh_86ec0ddc10): hephaestus-sandbox re-uploaded at daedalus 9f11c86 (worker 0.51.0, D1 0011–0018, hephaestus todo_8557 / appolis todo_8811) and the shared app key ROTATED on all three Workers (appolis APP_KEY_HEPHAESTUS · hephaestus + hephaestus-sandbox APPOLIS_APP_KEY; Tyler's pick on form note_8812; the old value was never saved) — the sandbox door now answers 403 "envelope required" to a bare POST instead of 503 (hephaestus todo_8556 / appolis todo_8814). + 🪪 todo_8554 fold-in: on /id/claim and /id/otp/verify ensureConnector now runs BEFORE saveIdReg, so a failed connector mint leaves the ID pending (retryable) instead of claimed-without-a-connector; /mcp refuses a disabled ID, so the early connector opens nothing. + grant chip: SLUG_ALIASES is read with Object.hasOwn, so a stored "constructor"/"toString" slug is unknown, not a prototype hit (note_8613 J-last advice). Tests: test/sandbox-route-on-0734.mjs 13/0 (red 3/10 at f67c4ee) · test/daedalus-sandbox-route.mjs 14/0 (R13 follows the var). Hub only; the appolis-sandbox Worker untouched; no migration. OUT: the Workshop UI · onboarding copy · pending-id TTL · Hermes enrollment · a hands runner on the sandbox URL (a queued daedalus_go job waits without one).)inbox: 'closed' or 'not closed: … archive it by hand' (said, never swallowed). The Inbox's own decline passes {fromInbox:true} and Appolis does not write the row (Kosmos archives its own; a second writer would race it). NOT IN THIS SHIP: an APPLIED candidate approved on the Workshop page (not the Inbox) still leaves its row — rare; and a proposal whose record expires untouched (30 days) leaves its row until someone clicks — the click now works. TESTS: new test/inbox-decline-8723.mjs (13 checks through worker.fetch with a Kosmos stub; red 9 on v0.73.2, 13/0 after). Hub only; no migration. Shift sh_cdb5b99fc9.)appolis_shifts marked EVERY row yours: true by the session-id test, and naming a shift only added a true without clearing the others. Now, when a call passes shift and it checks out (namedShiftFor: open, this account, this client), yours is true for that row ONLY; without one, the session-id test stands as before. The stamp half of v0.73.1 was verified live first: todo_8779 filed with shift sh_6abc2d5229 while three shifts were open came back filed_by: … shift sh_6abc2d5229. TESTS: test/shift-named-8760.mjs W7 pins the exclusive rule (18 checks). Hub only; no migration. Shift sh_6abc2d5229.)yours: false on both, and a board write that NAMED its shift (kosmos_post_decision_form with shift:'sh_9eb6044d0b') was still stamped "shift undecided — 2 open shifts": the envelope's caller_shift stayed null. THE FIX: NEW shifts.namedShiftFor(doc, account, client, id) → the id only when it is an OPEN shift on THIS account opened by THIS client (a Codex seat never names a Claude session's shift; stale, foreign, malformed or an unreadable registry → null). The request path reads a tools/call's shift argument before THE GRANT and, when it checks out, sets seat.callerShift — mintEnvelope already signs that as caller_shift on every forward of the request, so Kosmos stamps the write with that shift (Kosmos code unchanged). A name that does not check out is ignored and the v0.51.0 "one, or none" rule decides as before — never a guess. appolis_shifts takes shift too: that row reads yours:true. appolis_proposal_apply still proves its shift with id + token and overrides. TODAY the board tools whose schemas carry shift are kosmos_chat_post and kosmos_post_decision_form; other writes stay "undecided" under two open shifts until theirs do. TESTS: new test/shift-named-8760.mjs (18 checks). Hub only; the sandbox Worker untouched; no migration. Unblocks Kosmos todo_8749 (same-account sessions waking each other). Shift sh_6abc2d5229.)lib/aikeys.js (pure): the shape check (sk-ant-…), AES-GCM sealing with a key derived by HKDF from ID_SESSION_KEY (no new secret; the account id is the additional data, so a record copied onto another account does not open; a session-key rotation keeps records readable while ID_SESSION_KEY_PREV is set and re-seals them on first use; once PREV is gone a record says unreadable, never pretends), statusOf (the only shape any reader gets: set · status · set_at · verified_at · last_used_at · fallback_allowed — never the value, never a fragment), decide (the precedence), classify (Anthropic's answer → ok · invalid · no_credit · rate_limited · rejected · down) and messageBody (an ordinary Messages request only — mcp_servers, stream and anything unknown are dropped, so the door adds no capability). THE PERSON'S ROUTES /id/me/ai-key — their own session cookie only (no app key, connector token or bearer opens them, so no AI can reach them): GET status · PUT {key} checks it with Anthropic (GET /v1/models) and stores it ONLY when Anthropic accepts it (refused → 400 KEY_REJECTED; the check cannot run → 502 COULD_NOT_VERIFY; either way nothing is stored) · DELETE. Stored at KV id:aikey:<accountId>. THE APP DOOR /id/ai/anthropic/{status,messages} — an app key (the legacy secret → 401 NO_APP) plus x-appolis-account naming a person who holds the calling app (403 NOT_ENTITLED otherwise); Appolis makes the call, so the key never leaves it: 200 {paid_by:'personal', response} · 409 NO_PERSONAL_KEY · 402 PERSONAL_KEY_FAILED (invalid · no_credit · rate_limited · unreadable) · 400 UPSTREAM_REJECTED · 502 UPSTREAM_DOWN, each failure carrying fallback_allowed so the CALLING app knows whether it may use its own company key. THE OWNER'S SWITCH: POST /id/admin/accounts/:id/ai-fallback {on} (master only; the field is removed when off); the People list adds ai_key (whether one is held — names only, no record read) and ai_fallback. THE PORTAL: a dashboard panel under the connector (password field cleared before the request leaves, status only) and on each People row a 🔑 own key: set/none chip and a 🏢 company backup: on/off switch. NOT IN THIS SHIP: the Kosmos side (chat AI turns calling this door) — filed on the mission-control board; Phantasia keys. TESTS: new test/ai-key-8684.mjs (39 checks; red 28 before the routes, 39/0 after); progress-driver labels for the two new portal calls. Hub only; the sandbox Worker untouched; no migration. Shift sh_77f2106857.)lib/skills.js. platform: windows (one value, a comma list or [a, b]; none = any system) — validatePackage returns platforms, the index entry carries it only when set (every other entry byte-identical), the compact handshake line marks the skill [windows only], appolis_skill answers platforms with a ⚠ "do NOT install it" note before any file is written, and appolis_skills' note says to skip such a skill elsewhere. personal: config.json — files that are the seat's OWN never ship, through either publish door or the packer (which prints each one it held back); SKILL.md can never be personal. The full (legacy) skills line is unchanged. THE SKILL: speak-summary republished with platform: windows and personal: config.json, a voice-free config.example.json, and every hard-coded C:\Users\tsbwi path replaced by %USERPROFILE%; with no config.json the scripts already fall back to the free voice, and picking a voice in the panel writes the seat's own. A macOS player is NOT built (it could not be verified from this PC). TESTS: new test/skill-platform-8680.mjs (red 14 of 17 before, 17/0 after). Hub only; the sandbox Worker untouched; no migration. Shift sh_77f2106857.)expire() only ever examined state 'open', so a presence that went stale under the old rule was never looked at again. FIX: lib/shifts.js expire() closes a watch-only shift past its TTL from 'open' OR 'stale' ("implicit · expired"); a stale EDIT claim still stays stale (never auto-closed) and is no longer re-counted on every sweep. The sweep is doc-wide and runs inside every clock-in, so every account is covered; measured on the live registry before shipping, no stale watch-only shift remained (Raven had force-closed his). BUG 2 — force-closing A then B put each "🕔 clocked out" line in the NEXT shift's journal. CAUSE: the clock-out's log_work line reaches Kosmos after its shift closed, and Kosmos journals every write under the account's FIRST open shift. FIX: the registry writes the bookend itself — open writes the 🕐 line, close and force_close the 🕔 line, each into its own shift — and the journal op skips a forwarded 🕐/🕔 log_work copy (the Kosmos board still stores the line as its changelog). shifts.clockOutDigest(card) is now the one formula for both lines. NOT FIXED HERE (the Kosmos lane): Kosmos still journals ordinary writes under the account's first open shift rather than the one whose lane covers the project — filed to the mission-control board. TESTS: new test/shift-journal-8679.mjs (red at 42aeeab on A1 · B1–B4 · B6, green after, 10/0). Hub only; the sandbox Worker untouched; no migration. Shift sh_77f2106857.)compliance key inside its JSON answers (that is the room's text, Kosmos lane); appolis_laws {section:'cmp_…'} still answers a directive by id to anyone who names one. Hub only; the sandbox Worker untouched; no migration. Shift sh_5e5c06d4dc.)public/app.js BASE_SLUGS gains ['daedalus', 'Daedalus'], so + grant app offers "Daedalus · daedalus" — the slug lib/grant.js GRANT.member.daedalus names (an explicit grant lists the room; the room's own gate keeps daedalus_step and the admin tools owner-only, todo_4388). A new SLUG_ALIASES table + grantChipInfo() paint a 'hephaestus' entitlement MINT as "Daedalus (old name)" with a title saying the server treats both names as one room (lib/grant.js explicitFor · worker.js :407-408 / :1622-1623) — the gold "opens nothing — revoke it" chip was WRONG for that slug: following it would have cut Raven's Daedalus access. 'hephaestus' is never a list entry — re-grant by the new name whenever convenient; the revoke ✕ keeps the stored slug so it still removes that entitlement. SERVER UNCHANGED: POST /id/admin/entitlements already accepts any slug of the catalog's shape; grant.js + worker.js already open the room on 'daedalus'; /id/apps' BASELINE popup and the KV catalog (/api/apps, the portal's tiles) are untouched — no Daedalus tile appears, no launch link changes. TEST: test/daedalus-grant-8571.mjs (pinned A1–A7; calibrated red at 74b3327), suite 81 → 82 files. Hub only; sandbox 0.67.0 untouched; no migration; the ship writes NO grant — Tyler's drill on a test ID from the People list is the live proof. Shift sh_56911356d5.)lib/grant.js GRANT.member.kosmos goes from ['board', 'hub'] to ['board', 'hub', 'project', 'lander'], so a non-owner with a Kosmos grant (insider · dev · basic) now lists kosmos_create/update/delete/restore_project, kosmos_set_ and the 13 kosmos_lander_ tools as well: 79 → 101 listed tools for a basic or insider seat in the test fixture. WHY THIS IS NOT THE BACKEND: the grant only trims a LIST; the Kosmos room keeps every gate of its own — project tools act on the caller's OWN board, a lander address is claimed per account, a document lander (raw HTML) stays master-only (v8.135.1), and set_laws / set_standing_rules answer LAWS_AT_APPOLIS. The owner stays '*'; the appolis admin family (compliance, skills_publish, claude_md_publish, sandbox_snapshot/env) stays owner-only; Daedalus, Hermes and Phantasia still list only on an explicit per-app grant. SUPERSEDES form note_6246 Q2's narrower row (10-15). OUTSIDE THE CODE, same shift: Raven's Appolis ID (insider) gained the two grants he lacked — hermes member (team_fml, the shape every FML member has) and phantasia member — so his connector lists the Hermes and Phantasia rooms and each room decides by its own roles. TESTS: test/grant-token.mjs G0e · G2a/b · G3 · G5a re-pinned to the owner's newer call (43/0). Hub only; the sandbox Worker untouched; no migration. A person sees the new tools after refreshing the connector in the desktop app. Shift sh_6ba1b7a6e3.).blueprints/workshop-binary-20261029/DESIGN.md. THE ROAD: appolis_sandbox_write {job, path, base64, mode: put | extend} writes a binary file into the overlay in base64 chunks of at most 256 KiB a call (BIN_B64_CALL_MAX; a model is told to send 64 KiB); the type is an ALLOWLIST by extension — .glb model/gltf-binary · .png .jpg/.jpeg .webp .gif · .ktx2 .hdr (a three.js scene's textures and environment) · .bin (the buffer half of a .gltf pair, the one type with no magic; .gltf and .svg stay TEXT) — anything else BINARY_TYPE_REFUSED; a MAGIC-BYTE check (BAD_MAGIC: a .png must be a PNG, a .glb must say glTF version 2, and at submit a GLB's header length must equal its bytes — GLB_LENGTH_MISMATCH); strict base64 (BAD_BASE64); the secret scanner is NOT skipped but re-aimed — scanBinary runs every SECRET_PATTERN over the bytes as Latin-1 text (a token in a PNG tEXt chunk or a GLB JSON chunk → BINARY_REFUSED at write, CANDIDATE_REFUSED at submit; what a compressed payload hides is accepted because the bytes are preview-only). CAPS: 4 MiB a file, 16 MiB a candidate (BINARY_TOO_LARGE · BINARY_CAND_TOO_LARGE name the number); a hard CEILING no approval passes: 16 MiB a file (a Cloudflare KV value is at most 25 MiB and a candidate binary is ONE value) · 48 MiB a candidate; the text caps (512 KiB · 2 MiB · 1 MiB diff) are untouched and separate. STORED AS BYTES: overlay chunks content-addressed job:<id>:ovb:<path>:<sha256> + an index job:<id>:ovbidx (an extend never reads a prior body, so KV lag can refuse — BINARY_CHUNK_MISSING — but never corrupt); a submit assembles the file into ONE value cand:<id>:bin:<path> and the candidate carries binaries {path: {type, bytes, sha256}} + bin_bytes, the path in files_changed and its sha256 in sums (NOTHING_TO_SUBMIT still holds), NO hunk in the diff. SERVED ON THE PREVIEW ROAD ONLY: appolis-sandbox 0.67.0's GET /p/<cand>/<path> answers the bytes with the record's Content-Type + X-Content-Type-Options: nosniff, the unchanged no-store/no-transform/noindex headers and the page's own unchanged CSP (cspCode / CSP_LANDER — a page's GLTFLoader fetch of its own /p/<cand>/ file passes since v0.66.1); the RAW road answers a binary 409 BINARY_FILE {type, bytes, sha256, preview} under CSP_RAW — a text consumer never gets bytes; SbxAssets serves a public/ binary for the live road's own page; the live road's bundle still admits js/mjs/cjs/json only. THE TEXT CONSUMERS: appolis_sandbox_read {path} and appolis_sandbox_candidate {id, path} answer a binary by its FACTS (binary true · type · bytes · sha256 · the /p/ url) and never text; appolis_proposals rows say binaries: n; appolis_proposal_apply opens the static preview, lists binaries {type, bytes, sha256, url} and tells the Claude seat to fetch each url into the stage copy and check sha256 BEFORE the gate — nothing deployed, as ever. THE CAP RAISE: one new tool appolis_sandbox_cap {job, verb: status | request, file_max_bytes?, cand_max_bytes?, reason} — request (REASON_REQUIRED · CAP_OVER_CEILING · CAP_NOT_A_RAISE · CAP_REQUEST_PENDING) stores ONE proposal row of kind cap_raise, state open, on the live store (the review door) and changes NOTHING (status shows the effective caps unchanged and the pending id); the OWNER approves it on the Workshop page (✓ Approve beside ✕ Decline on a new "📏 cap raise" row read from GET /api/sandbox/envs → cap_requests) or the Kosmos inbox decide door — both the one proposalApprove, which now writes job:<id>:bincap {file_max_bytes, cand_max_bytes, approved, request} for THAT JOB ONLY, each value clamped to the ceiling, and marks the row applied (nothing waits on a Claude seat, no approved-waiting key); a decline changes no cap; appolis_proposal_apply on a cap row → CAP_OWNER_ONLY for every seat, the owner's Claude seat included (a sandbox seat meets SANDBOX_ONLY first). DOCTRINE (todo_7230) whole: a sandbox seat gains bytes in APPOLIS_SBX under its own job, a preview and a request — never a kit, a bundle, a repo, a live binding or a deploy. OUT (to-dos): kit binaries over the freight road (v0.67.1 — the real outfit-01.glb from the PC), a Kosmos inbox card for a cap raise (law 7), the handshake's waiting line naming pending requests. test/workshop-binary-8183.mjs 24/0 new (4/21 on v0.66.1 — X0 · X2 · X10 · X12 the controls); suite 75/75; deploys hub + sandbox, no migration, no new binding.)warnings): a description over 1,024 chars (the skill format's cap — a warning, not an error, because the house blueprint skill is 1,436 today and must not be locked out), a description with no "use when" sentence, SKILL.md over 500 lines, a stray frontmatter line. validatePackage is unchanged. ROUTING EVAL: lib/skill-routing.js, zero tokens — every local skill's name + full description becomes a TF-IDF vector, each case in scripts/skill-routing-cases.json (11 everyday prompts across the 8 house skills) must rank its skill FIRST, a case for a skill the machine lacks is skipped, near-twin descriptions (cosine ≥ 0.75) are named; a failed case stops the publish run before the first PUT (exit 2), --no-route is the one escape and says it was used. Calibrated on the 8 house skills: 0 errors, 2 warnings (basecamp 908 lines, blueprint description 1,436 chars), 11/11 cases, 0 collisions. Tests: test/skill-lint.mjs new (L1–L6), red on v0.65.2; hub-only, no migration.)test/rails.mjs harness measures harness: compact=6841 B legacy=68022 B (up from 6671 B — the longer NO_SHIFT rail, measured). Hub-only, no migration.)hub (export default hub) so the door can re-enter it. TESTS: new test/workshop-kosmos.mjs K0–K14 (15/0) pinned; lander-polish P0 re-pinned at-or-past 0.62.1; suite 68. Hub-only (the sandbox Worker is untouched at 0.62.0), no migration, no new binding or secret (KOSMOS + APP_KEY_KOSMOS already bound). Shift sh_0b356e5430.).blueprints/lander-job-20261027/REVIEW.md. WHAT (1) scripts/lander-check.mjs (and so every kit's check/lander-check.mjs): <!-- … --> notes in COPY.md and a LEADING # Copy… title line are not page copy; a real headline (# <anything else>) still counts. The drill page now reads copy=18/18, not 18/19. (2) appolis_proposal_apply on a LANDER candidate: promote is the lander's own line (LANDER_PROMOTE: follow next; no diff, no stage copy, no ship.sh; the store is the owner's click), and an owner's go reads "promote it without asking again". A code candidate keeps the STAGE … ship by ship.sh line unchanged. (3) lander-kit's default brief/RULES.md gains "- Compliance: no numbers, statistics, reviews, testimonials, star ratings, guarantees, doctor or expert endorsements, before/after results or health claims that are not in COPY.md — and no image that implies one…". (4) APPROVED BUT UNAPPLIED: POST /api/sandbox/proposals/approve also writes id:approved-waiting:<prp> {id, candidate, kind, at, summary ≤140} (TTL = an open proposal's) and bumps the handshake. The owner's handshake (legacy text and the compact instructions both) carries one line, "🧰 N OWNER-APPROVED WORKSHOP CANDIDATE(S) WAIT FOR A CLAUDE SEAT: <prp> (<cand>[ · lander], approved <at>Z — <summary>) … appolis_proposal_apply {id}, then stage, gate and ship it (a lander: follow next) without asking again" (first 5, then "and N more"). An open approved row in appolis_proposals carries waiting. Apply and decline (the tool and the owner's ✕) delete the key and bump the handshake. The Workshop page's "waiting for a Claude seat" label was already there (v0.61.0). Found because Raven's approved cand_1c3d13f35237d375 sat open ~2 h on 2026-10-27. TESTS: new test/lander-polish.mjs P0–P9 pinned (10/0), suite 67. Hub-only (the sandbox Worker is untouched at 0.62.0), no migration. Shift sh_9327433977.).blueprints/lander-job-20261027/DESIGN.md. WHAT (1) appolis_sandbox_hand (hand · open) takes kind = code (the default) or lander (anything else → BAD_ARGS): a lander's kit.main defaults to index.html and must be an .html page (KIT_REFUSED), the kit must carry it (KIT_INCOMPLETE with missing: [main] — on a one-call hand nothing is stored, at seal the job stays loading), the record gets kind: 'lander' (a code record gets no field, so old records stay byte-equal) and the job gate keeps it through job.seal. kind is echoed on the hand · open · seal answers, the appolis_sandbox_jobs rows, the read manifest, the submit answer, appolis_sandbox_candidate and the appolis_proposals candidate row (job_kind); a lander's answers to a seat carry road (LANDER_ROAD: brief/COPY.md verbatim, BRAND.md, REFERENCES.md, MEDIA.md hosted URLs only, RULES.md, inline CSS/JS only, the static preview, check/lander-check.mjs, submit with claims, a Claude seat promotes, the store is the owner's click) and the candidate read and appolis_proposal_apply carry next (LANDER_NEXT: read the page, scripts/lander-check.mjs, kosmos_hub_put_doc kind html · tile_type lander · request_upload fed from the raw road, kosmos_lander_save, the owner's store click). appolis_sandbox_candidate gains path: the candidate's own copy of one file (source candidate), a deleted one (text null), else the kit's (source kit), else NO_FILE. A live preview of a lander is refused (LIVE_PREVIEW_NO_WORKER). (2) KIT_INCOMPLETE for CODE (todo_7567 b): importClosureMissing(files) in lib/sandbox.js reads the relative imports (import … from, import 'x', export … from, import('x')) of every .js/.mjs/.cjs file in the kit; a hand or a seal whose kit leaves one out is refused whole, naming <file> imports <path>, missing[] and the two ways out — scripts/freight-send.mjs --closure or kit.partial: true (the record then keeps kit.partial and, on a one-call hand, kit.missing; the manifest and the jobs list show both). (3) scripts/freight-send.mjs --closure (todo_7567 a) sends the whole relative-import closure of the JS paths, read at the commit (closeImports(read, paths) is exported; an import the repo lacks stops it, exit 2). (4) appolis-sandbox (SBX_VERSION 0.62.0, redeploys): CSP_LANDER = the static CSP plus img/media/font/style from landers.appolis.app, cdn.shopify.com and Google Fonts — still connect-src 'none' — used when the job is a lander; CSP_STATIC is byte-identical; and the RAW road GET|HEAD sbx.appolis.app/raw/<cand>/<path> (empty path → index.html) behind the same live-static-preview gate as /p/, serving the file's exact bytes as text/plain with nosniff and default-src 'none'; sandbox (no banner, no leave guard) so a Claude seat can fetch a page byte-for-byte; expired or unknown → 404 PREVIEW_EXPIRED. (5) New scripts/lander-kit.mjs --in <intake> --out <dir> [--spec] turns COPY.md (+ BRAND · REFERENCES · MEDIA · RULES · index.html) into a one-commit git repo with brief/*, check/lander-check.mjs and a skeleton page, refusing a MEDIA.md URL off the pool, a token shape, a file over 512 KB or a kit over 2 MB; scripts/lander-check.mjs <page> [--copy] [--media] fails DOCTYPE · TITLE · VIEWPORT · SCRIPT_SRC · HOSTS · IFRAME · COPY (the copy kept, paragraph by paragraph). HUB: tool descriptions say all of it; test/workshop-followups.mjs G10's sv === '0.57.2' is now at-or-past. PINNED: test/lander-job.mjs (26/0); suite 66/66. DRILL after the ship: a real FLIP intake through lander-kit → an open lander job → freight-send → seal → a Claude seat works it as the worker would → candidate path read → lander-check → hub doc via /raw/ → a Lander Builder draft; the store is not touched. Hub + appolis-sandbox, no migration.)for raven.sage21@gmail.com on form note_7616 Q1 = A): adminWho(request, env) after isAdmin — a label, never a permission — stamps the owner page's ✓ Approve and ✕ Decline with the signed-in master's email and name (via: 'appolis-id'), or email null, name null, via 'admin-key' for the PASSWORD cookie; only email, name and via are ever recorded, and a session that will not verify falls back to the key label instead of a 500. GET /api/sandbox/envs rows carry approved.by (a publish key reading that list now sees the approver's email too — it already saw the submitter's); the Workshop row reads "✓ Approved by <name / email / the admin key>". Old rows are not backfilled. A8 pinned (18/0). (2) todo_7597, ChatGPT's second change (sbj_d7af2d8727c67cd6, cand_f7b32c802c9129a5, prp_e4138f58619fe11b): the blueprint skill's fence.mjs changelogRail counts a JSON "version" bump only in a file named package.json, so a data file (the Claude Motion hub's 23 project versions) no longer trips the rail ahead of the real bump; pinned by tests/rails-data.test.js; published to the skills registry. Hub-only; no migration; the sandbox Worker untouched.)appolis_sandbox_hand (hand and open) takes for = a collaborator's email: the owner's Claude seat only (FOR_OWNER_ONLY), a live Appolis ID (FOR_UNKNOWN), not the owner (FOR_SELF), sharing an Appolis team with the owner (FOR_NOT_SHARED), never with an env (FOR_NO_ENV). The job record keeps account = the owner and gains for {account, email}; an index row is written under both accounts (sbxJobRecs names it once); it never counts toward his 20-job cap. jobVisible/candVisible let the named person's seats in — his Claude seat, and his sandbox seat when the platform fits; among sandbox seats a for job is ONLY his (the owner's own chatgpt seat no longer sees it). He lists, reads, writes, submits and previews; append/seal stay the hander's (NO_JOB); snapshot/env stay admin; his candidate and its proposal row carry for and land in the owner's queue; apply or decline on a for proposal by anyone but the owner → OWNER_REVIEWS. (2) POST /api/sandbox/proposals/approve {id, note} (admin cookie only; 405 · 401 · 503 · 400 BAD_ID · 404 NO_PROPOSAL · 409 PROPOSAL_NOT_OPEN · 409 NOT_A_CANDIDATE · 409 ALREADY_APPROVED · 410 CANDIDATE_GONE) records approved {at, by: owner-page, note} on the row and the candidate and leaves the row OPEN: approve never applies or deploys — a Claude seat still stages, gates, commits and ships — and appolis_proposal_apply now answers owner_go plus a promote line saying to ship without asking again. Each Workshop row gets ✓ Approve (askPrompt for the optional note; Cancel stops) beside ✕ Decline, shows who sent it, and an approved row says so. appolis_sandbox_candidate now answers approved and declined (the decline's reason really reaches the AI that made it). test/collaborator-hand.mjs C0–C9 + A1–A7 pinned (calibrated 2/15 on v0.60.2). Hub-only; no migration; the sandbox Worker untouched.)GET /api/version is public and answers {version: CONNECTOR_VERSION, date: BUILD_DATE} with Cache-Control: public, max-age=60; any other method is 405 with Allow: GET. BUILD_DATE is a new constant beside CONNECTOR_VERSION (bumped with it). HOW IT WAS MADE: todo_5381 was handed to the ChatGPT seat as Workshop job sbj_c2c638318846a436; it read the big worker by line range and find, wrote the 7 lines and the new test/api-version.mjs (11 checks: 200 JSON, version and date, the cache header, POST 405, app.js VERSION = 'v' + CONNECTOR_VERSION) by exact edits, and submitted candidate cand_d12365bda3a16462 (proposal prp_cb2a910962bb5b71). A Claude seat reviewed it: the candidate's old side is byte-identical to v0.60.1, the test passes on it and fails 4/7 on v0.60.1. ChatGPT could not run the test itself — the kit carried worker.js without the lib/* files it imports (todo_7567). Hub-only; no migration; the sandbox Worker untouched.).blueprints/bigfile-road-20261027/DESIGN.md. WHY: a ChatGPT seat works a Workshop job only through appolis_sandbox_read (whole file) and appolis_sandbox_write (whole file), and every app here is one big worker (appolis worker.js ~382 KB, public/app.js ~155 KB) — past what ChatGPT can take in one tool answer or retype in one call. WHAT: (1) READ: appolis_sandbox_read {job, path} takes start_line (+ max_lines, default 400, at most 2000 → start_line/end_line/more/lines_total; BAD_RANGE outside 1–lines_total) or find (a literal ≤ 200 chars matched per line → at most 50 {line, text ≤ 300} hits, hits_total, truncated, no file text; BAD_FIND otherwise); find wins over start_line. A whole read of a file over 96 KB (SBX_READ_WHOLE_MAX) now answers lines 1–400 with truncated:true and a note naming start_line and find; whole:true returns all of it; a small file reads exactly as before plus lines_total. (2) WRITE: appolis_sandbox_write {job, path, edits:[{find, replace}]} (1–50 edits, or a JSON string of them) changes a file without retyping it — the base is the overlay text else the kit text; each find must occur EXACTLY ONCE in the running text (EDIT_NOT_FOUND / EDIT_NOT_UNIQUE with the edit's index), edits apply in order and LITERALLY ($& survives), text together with edits is BAD_TEXT, a bad list is BAD_EDITS, a path in neither the kit nor the overlay is NO_FILE, and any refusal stores NOTHING; the same TOO_LARGE / TOO_MANY_FILES checks and overlay write follow, so submit's diff is the same. No new tool (tools/list counts unchanged). (3) todo_7526: a sandbox seat's append/seal on another platform's loading job answers NO_JOB BEFORE SCRATCH_ONLY (the platform test now also lets an any job through, jobVisible's rule), so a codex-only repo copy no longer tells a chatgpt seat it is "a repo copy". (4) THE OWNER'S DECLINE: POST /api/sandbox/proposals/decline {id, reason} (the admin cookie only — a publish key is 401; 405 for other methods; 503 SANDBOX_STORE_MISSING; 400 REASON_REQUIRED / BAD_ID; 404 NO_PROPOSAL; 409 PROPOSAL_NOT_OPEN) runs the SAME body as the Claude tool — now one helper, proposalDecline, used by both — as by {seat:'owner-page', email:null, platform:'owner'}; each candidate row on the owner's Workshop list carries a ✕ Decline button that asks why through the styled prompt, posts to the route, shows any refusal as a toast and reloads the list. PINNED: test/big-file-road.mjs B0–B19 (a real /mcp seat and the real /api route over the real SandboxGate class). Hub-only; the sandbox Worker is untouched; no migration; CONNECTOR_VERSION and app.js VERSION 0.60.1.).blueprints/chatgpt-road-20261026/DESIGN.md. (A) SCRATCH BUILDS (todo_7291 remainder): appolis_sandbox_hand now passes the sandbox wall (SANDBOX_PASS), and from a SANDBOX seat it is a SCRATCH job — platform = the seat's own (BAD_PLATFORM names it when another is asked for), kit.repo = kit.commit = 'scratch' and kit.scratch true on the record, the jobs row and every answer (a given repo or commit is KIT_REFUSED: nothing pretends to be a repo commit), never an environment (SCRATCH_NO_ENV), the same scanKit → KIT_REFUSED, the same 20-job cap and TTLs, the same SandboxGate writer; one call with kit.files, or open (empty, loading) → append → seal; a seat's append/seal touch only kit.scratch jobs (SCRATCH_ONLY — a Claude-handed loading kit on the same account never takes a seat's file). The road after is unchanged: write → submit (a kind:candidate proposal on the live namespace) → preview (static on sbx.appolis.app/p/<cand>/ or live on the walled pool); promotion stays a Claude seat's act. snapshot and env stay Claude-seat acts. (B) THE DAEDALUS SANDBOX ROUTE (todo_5078 d): wrangler.jsonc binds HEPHAESTUS_SBX → the script hephaestus-sandbox and a var DAEDALUS_SANDBOX_ROUTE = "off"; in worker.js SBX_ROUTED_BINDING { HEPHAESTUS: 'HEPHAESTUS_SBX' } and sbxRouteFor(env, seat, slug) → the sandbox binding only for a SANDBOX seat while the var is on (trimmed, lowercased 'on'; anything else is off = today's bytes). fwd forwards a routed call to that binding with the same envelope plus x-appolis-route: sandbox; a routed road that cannot be taken (no binding, HTTP ≠ 2xx, a throw) answers the JSON-RPC error SANDBOX_ROUTE_FAILED (-32010) and tools/list mounts daedalus_unavailable naming it — never a fallback to live, never a proposal; the wall lets a routed room's writes through so they run in the sandbox Worker. A Claude seat is never routed. The switch SHIPS OFF: the sandbox Worker must first be redeployed and given APPOLIS_APP_KEY by the Daedalus lane (a to-do on the hephaestus board). PINNED: test/scratch-build.mjs A0–A13 (a real /mcp seat over the real SandboxGate class) and test/daedalus-sandbox-route.mjs R0–R13 (two recording rooms); workshop-jobs K1 and rooms.mjs re-pinned. Hub-only; appolis-sandbox stays 0.57.2; no DO migration.).blueprints/checker-key-20261026/DESIGN.md). WHY: Daedalus's /api/university and /api/wing take only an Appolis ID sign-in, its PC drill had none, a person token lives ~7 days and a master token would open ALL of Daedalus's /api (hephaestus note_6285, Tyler's pick A on form note_7391: "but NARROW"). (1) THE IDENTITY: daedalus-checker is a machine key amk_<48 hex>, stored HASHED at a new KV doc id:machinekeys — never in id:skillkeys, so every existing bearer door (publish, skills read, freight mint, fence read, proposals read) rejects it by its own regex, and the /mcp/ door answers -32001. Its whole power is a table in CODE, MACHINE_IDENTITIES = { 'daedalus-checker': { app:'daedalus', allow:['GET /api/university','GET /api/wing'] } } — no door can widen it, app '' never appears. Owner-only doors in the /id/admin/ tree: POST /id/admin/machine/key mints (the plaintext ONLY in the answer's env field, once; Laws → Keys turns it into a DOWNLOAD appolis-checker.env and never renders it), GET /id/admin/machine/keys lists ids never hashes, DELETE /id/admin/machine/keys/<id> revokes at once (the next resolve is 401). The one resolve door, GET /id/machine/resolve?method=&path=, takes an APP KEY (x-id-internal; the legacy secret has no slug → 401 NO_APP) and x-id-machine, and answers 200 {identity, kind:'machine', app, allow, allowed} only when the identity's app is the caller's app AND <METHOD> <path> is in the allow-list (403 NOT_ALLOWED otherwise) — never an account, entitlement or team shape, so an app cannot mistake a machine for a person. The row stamps last_used_at + last_used_by at most once a minute; the Keys tab shows "last used never" until the first check. At most 4 keys per identity (KEYS_FULL). The Daedalus half (accept the identity on the two GETs, send it from the drill) is a to-do on the hephaestus board (law 7). (2) THE HAND ROAD: a one-call appolis_sandbox_hand now has the seal road's shape — its files go to blobs job:<id>:blob:<sha> BEFORE the record is born, the record commits mirror:pending with one chunk per file, and the :file: puts + the job:<id> mirror + job.mirrored sit in ONE try; a KV throw there answers the hander ok · state open · mirror pending (never SANDBOX_FAILED for a job the gate holds; no re-hand, no duplicate seat), the DO alarm writes the bytes within a minute, and the seat reads MIRROR_PENDING meanwhile. The index row is written BEFORE job.open on both roads, so a job the gate holds is in every list at once. (3) THE ALARM re-reads the record after its KV awaits (a job.* commit that interleaves — job.mirrored, a candidate — is never overwritten by the stale snapshot) and KEEPS a pending record until expires_at + 1 day like a done one. (4) ship-0590.sh: the PREFLIGHT account name is sanitised to [A-Za-z0-9._-] (an apostrophe no longer breaks the step door's regex), ship-0590.head holds the FULL sha, and C-last3 proves the machine door is served (401 NO_APP without an app key). Tests: workshop-seal-writer S21–S24 (+S-v1) and the new test/checker-key.mjs K1–K10, red at 9c1553a, green after (N = 57). Hub-only; no migration; the sandbox Worker byte-identical at 0.57.2.).blueprints/six-stitches-20261025/DESIGN.md; the findings at .blueprints/seal-writer-20261025/j3-verdict.md. (1) The SandboxGate Durable Object compares its gate key in constant time — on every job.* op AND on destroy — with the same four lines worker.js's safeEqual uses; the !== compare is gone. (2) A seal that commits in the DO but whose post-commit KV puts fail (the kit's job:<id>:file:<path> keys and the job:<id> mirror) no longer strands the job: the record carries mirror: pending with its chunk lists, the sealer still hears state open (the job IS sealed) with mirror: 'pending', the DO's alarm re-tries the same bytes from the content-addressed blobs every minute until they land (then job.mirrored / the alarm marks it done and the alarm returns to the record's expiry), and a seat's read or submit of a sealed kit meanwhile answers MIRROR_PENDING — never NO_FILE, never SANDBOX_FAILED for a job that is sealed. (3) job.append caps the kit at 512 chunks across its files (CHUNKS_OVER, the record unchanged): 512 shas plus the largest hand record stay under the 128 KiB DO value limit, and a 2 MiB kit still fits in 32 full-size appends. (4) The freight-key refusal at /mcp says the real road — a sealed job voids its key; scripts/freight-send.mjs mints a fresh key at the start of every run against the live job record — instead of "may need a retry". (5) job.open refuses a record whose at, expires_at or (while loading) load_deadline does not parse, or whose ttl_h is not a number: 400 BAD_EXPIRY, nothing stored, and commit throws before any put on a NaN expiry — a record can no longer be stored without its expiry alarm. (6) public/style.css gains the .st.sky rule, so the Keys page's freight pill renders in its own sky tint beside mint (read-only) and gold (publish). Pinned by S15–S20 of test/workshop-seal-writer.mjs (one case per minor; the seal-with-a-failing-put case drives the DO's alarm against the test's own store and reads the text back). Hub only; appolis-sandbox stays 0.57.2; wrangler.jsonc unchanged; suite 59 files green.)scripts/freight-send.mjs sealed three real files from this PC's colo, and ~30 s later a connector append from the Anthropic-hosted colo — still holding the job as loading with 0 files from its KV cache — answered ok state:loading files:1 and its put overwrote the sealed record everywhere; the sandbox seat then read JOB_NOT_SEALED and the jobs list showed the job absent. Lost work and a misleading ok, never a forged kit (a resurrected job must re-seal, which re-hashes). WHY: append · seal · extend · the freight mint were KV get→put on job:<id>, and KV serves a colo's cached copy for up to ~60 s. FIX, DESIGN at .blueprints/seal-writer-20261025/DESIGN.md: the job record (state · account · the file manifest with per-file sha and bytes · kit_sha · expiry · the upload key's kid) lives in the existing SandboxGate Durable Object — ONE INSTANCE PER JOB (idFromName('job:' + id); no new class, no migration; the gate instance keeps the env tombstones untouched) — and every state change (job.open · job.append · job.seal · job.mint · job.candidate) is one DO request whose read → check → write runs under the object's input gate; every reader that decides on a job (the sandbox seat's read/write/submit, the jobs list, whoami's active job, JOBS_FULL, the freight mint door, the freight key check) asks the DO, never a KV copy. Every job.* op carries the hub's ID_SECRET_S in x-sbx-gate-key (403 GATE_DENIED without it), so appolis-sandbox — which binds the same class by script_name and holds no secret — can only ever check. KV keeps bytes only: during loading a chunk is stored content-addressed as job:<id>:blob:<sha256> (immutable, written before the DO commits; an orphan from a refused append is harmless and expires) and extend reads no prior body; at the seal the hub assembles each file from its chunks, re-scans the whole kit as before, commits, and only then writes once job:<id>:file:<path> and the job:<id> mirror the sandbox Worker's preview reads — keys never read before they are written, so never stale. The seal is guarded against a racing append (SEAL_STALE: seal again); records expire by DO alarm. Jobs opened before this deploy answer NO_JOB (no KV fallback — that fallback is the shape retired here); the ones live at the ship are named in the shift's clock-out. Also: Laws → Keys mints a freight key with a third button 🚚 and labels the row freight (the pill said "publish"); NO_JOB echoes an id, not the caller's string whole; the freight gate answers initialize itself (no handshake rebuild for a freight seat — noted since v0.58.2); scripts/freight-send.mjs --help says the key is sent as a bearer to --hub only; the freight key's reads of /id/shifts?lane= and GET /id/skills stay as they are — which doors a machine key may open is todo_7397's question. Pinned by test/workshop-seal-writer.mjs (a second "colo" with a stale KV view appends after the seal → JOB_NOT_LOADING, the sealed record intact; the jobs list and the read see the sealed job at once). Hub only; appolis-sandbox stays 0.57.2; suite 59 files green.).blueprints/freight-key-20261025/DESIGN.md, note_7409). WHY: v0.58.0 built open · append · seal, but a real kit still had to be retyped by a model as tool-call arguments (about 165k output tokens for the appolis kit), which the blueprint rule forbids. WHAT: (1) a THIRD key kind in the existing id:skillkeys store, kind: 'freight' — minted on the same route as the fence and publish keys (POST /id/admin/skills/key {kind:'freight'} answers APPOLIS_FREIGHT_KEY= for appolis-freight.env), listed and revoked on the same Laws → Keys rows; its ONE power is the new mint door. A freight key publishes no skill (PUT /id/skills → 403) and reads no sandbox envs (401); read and publish keys cannot mint upload keys (403 WRONG_KIND). (2) POST /id/freight/key {job, ttl_m?} (Bearer, checked before the session-key gate) mints sbk_<job>.<exp>.<kid>.<mac> — HMAC-SHA256 with the hub's ID secret over freight·job·exp·kid — for ONE loading job of the owner account, at most FREIGHT_KEY_MAX_MIN = 15 minutes, and records the kid on the job; a re-mint voids the previous key; a member's or missing job → 404 NO_JOB, a sealed job → 409 JOB_NOT_LOADING. (3) /mcp/<sbk_…> is a connector seat forced to Claude/live and narrowed by THE FREIGHT GATE (before the grant, the wall, compliance and the shift tools): tools/list shows ONE tool (appolis_sandbox_hand, verbs append · seal), a call passes only for append or seal on the key's own job and then runs the ONE existing hand handler unchanged; anything else is FREIGHT_ONLY and stores nothing; the seal deletes rec.freight, so the key is void; a bad, expired or voided key → HTTP 404 -32001. (4) scripts/freight-send.mjs reads the key from a file (never printed — every line is redacted), reads each path with git show <commit>:<path>, packs appends of at most 65,536 bytes (a big file in ordered put + extend chunks on code-point boundaries), seals and compares the seal's kit_sha with its own (the kit-manifest formula): FREIGHT OK … kit_sha=… exit 0, or kit-manifest names the first differing path and FREIGHT MISMATCH exit 1. Pinned by test/workshop-freight-key.mjs (27 checks); the suite is 58 files. sbx.appolis.app (appolis-sandbox 0.57.2) is untouched.)files MAY ARRIVE AS JSON TEXT. The v0.58.0 J6 drill (open sbj_3d206cb921ec3090 through the owner's Claude seat, served 0.58.0) found every append refused BAD_ARGS: the client still held the pre-0.58 tool list — no files property in its schema — and sent files as the string {"a.txt":"alpha\n"}. Any client with a cached schema, or a model that stringifies objects (the Kosmos array-params trap), hits the same wall. FIX: appolis_sandbox_hand append parses files when it is a string; JSON text that is not an object (an array, a number) or text that is not JSON is refused BAD_ARGS, nothing stored; the parsed files get the SAME kit scan, caps and extend rule. PINNED: test/workshop-freight-wire.mjs X0–X5 (new) — red on d5459aa (X1–X4b), green here. Hub-only ship; appolis-sandbox stays 0.57.2. IN PLAIN WORDS: an AI app with an older copy of the tool's instructions can now send code in pieces too.).blueprints/freight-road-20261025/DESIGN.md): a real-sized kit (the appolis kit is about 583 KB) does not fit one appolis_sandbox_hand call. The server is not the bottleneck (the chat measured writes of 256 KB to 4 MB all reaching the tool); the CALLER is — a model must emit every byte of a kit as tool-call arguments, about 165k output tokens of retyped code. WHAT: appolis_sandbox_hand gains a verb (tool count stays 26 native; the one-call hand is the default and unchanged except that its answer now carries kit.kit_sha). open {task, platform?, ttl_h?, env?, notes?, contract?, kit:{repo, commit, main?}} makes a job in state loading (files present → BAD_ARGS; the same checks and refusal codes as hand, so the v0.57.2 env checks run at open) and answers append_max_bytes 65536. append {job, files:{path:text}, mode?} adds files to a loading job: at most KIT_APPEND_MAX_BYTES = 65536 bytes of text per call (APPEND_TOO_BIG names the cap — 64 KiB is what a model emits reliably in one turn), mode put adds or replaces a path, mode extend appends to a path already in the kit so a file larger than one call arrives as ordered chunks; the call's files get the hand's scan (bad path · not text · token shape → KIT_REFUSED naming the path) and the MERGED kit is checked against the 200-file, 2 MiB and 512 KiB-per-file caps — ANY refusal stores nothing. Every accepted append renews the job's 2-hour load_deadline (past it: LOAD_EXPIRED on append and seal, not listed, not counted). seal {job} reads each file by a direct get from the record's own file list (never a KV list, which lags a put by about 60 s), re-scans the ASSEMBLED text of every file (a token shape split across two extend chunks is caught here → SEAL_REFUSED, the job stays loading), recomputes each sha256 from the stored bytes, flips the job and its index row to open, and answers the manifest: path · bytes · sha256 per file (sorted by plain string comparison), total_bytes and kit_sha, the sha256 of the lines path TAB bytes TAB sha256 NL joined (kitSha / manifestLines in lib/sandbox.js). The new scripts/kit-manifest.mjs <repoDir> <commit> <path...> [--expect <hex>] rebuilds the same lines from git show <commit>:<path> (never the working tree, so a CRLF copy cannot pass) and prints KIT_SHA <hex>; --expect exits 1 with MISMATCH first differing path on a difference. THE WALL: a job still loading is invisible to every sandbox seat — appolis_sandbox_jobs (new state loading; Claude seats only) never returns a loading row to a sandbox seat under ANY state (the all branch is cut by name), and read · write · submit on it answer JOB_NOT_SEALED (another account's loading job stays NO_JOB); a loading job counts toward the 20-job cap while live; only the job's own account appends or seals (NO_JOB otherwise); a sandbox seat gets SANDBOX_ONLY on every verb. One cap on a long load: a sealed job never outlives the files behind it, so expires_at is the seal plus ttl_h but at most the open plus ttl_h plus the 2-hour load window. Tests: test/workshop-freight.mjs, 15 checks, pinned (red at 60fe69b: 2/13; green here: 15/0); no existing test changed; suite 56 files. NOT built (the owner's fork): the script road, a local uploader with its own credential. sandbox/worker.js is unchanged and is not redeployed (hub-only ship; appolis-sandbox stays 0.57.2). IN PLAIN WORDS: a real-sized copy can now be handed to ChatGPT or Codex in small batches instead of one impossible message; it stays hidden from them until it is sealed, and the seal hands back a fingerprint that proves the copy equals the real code.)appolis_sandbox_env destroy refused GATE_UNREACHABLE (the gate answered 403 GATE_DENIED): the hub sent, and the gate checked, ID_SECRET, but the production hub holds that value only as the secret ID_SECRET_S (every other reader in worker.js already reads ID_SECRET_S || ID_SECRET). It failed loud and changed nothing — no wipe, the record still live. FIX: the hub sends ID_SECRET_S || ID_SECRET and the gate (lib/sandbox-gate-do.mjs) checks the same order. The tests had passed only because their env carried ID_SECRET (toy data): test/workshop-followups.mjs now builds a PRODUCTION-SHAPED env (ID_SECRET_S, no ID_SECRET) for the hub and its gate — red on 6b29f24 (G2b · G2c · G2d · G4b), green here, 27/0. Hub-only ship; appolis-sandbox stays 0.57.2 (it never sends the key). IN PLAIN WORDS: the first real try of v0.57.2 showed that deleting a sandbox was refused; it refused safely and changed nothing, and now it works.)appolis_sandbox_env destroy a seat's hub request, a hand, a reset or a live preview could still read env:<id> as live and use the wiped env. New Durable Object class SandboxGate (lib/sandbox-gate-do.mjs, hub binding SBX_GATE, migration v3 new_sqlite_classes) holds a tombstone per destroyed env, strongly consistent; destroy marks it FIRST (a gate that cannot be marked → GATE_UNREACHABLE, nothing wiped) and stays repeatable; the seat rebind, hand and reset ask it whenever KV says live (a gate that cannot be asked → SANDBOX_ENV_UNREADABLE / ENV_NOT_LIVE, never a quiet fall back); appolis-sandbox binds the same class by script_name: appolis, only ever asks check, and on a destroyed env gives the candidate its own pv:<cand>:k: storage — a gate it cannot ask answers 503 PREVIEW_GATE_UNREADABLE and the candidate is not started. SBX_GATE is never handed to a candidate, and the gate takes a destroy only with the hub's own ID_SECRET (x-sbx-gate-key, else 403 GATE_DENIED), which the preview host never holds — the Worker that runs untrusted code can only ever check. Minor 4: appolis_sandbox_hand {env} refuses ENV_NOT_YOURS unless the env was made by the caller's own account. Minor 3: a snapshot builds the people of the live suite once (sandbox.peopleOf — every account name — letters of any script, a whole one-word name of 2+ — and each 3+-letter word of it → the same Person <n> as the id:accounts copy, every team name and each 4+-letter word of it → Team <n>, whole words, case-insensitive, a stoplist for ordinary words) and replaces them in EVERY copied key's free text; id:teams names become Team <n>; a live id:accounts or id:teams that will not parse refuses the snapshot (SNAPSHOT_UNSCRUBBABLE), never a copy with the names left in. Minor 2: every preview page (static and live) gets the LEAVING WARNING injected FIRST in the document (right after a leading doctype, so no page drops into quirks mode, and never by searching for a <head> a comment could hold) — a capture click/auxclick listener that confirms before a link leaves the preview host; a warning, not a wall (a script can still set location; the pages stay GET-only). Minor 1: a hand into an env answers rooms + rooms_note (every room still READS its LIVE data for that seat until it honours sandbox_env; ROOM_SANDBOX_READY is empty) and the deck row says so. Minor 5 (re-read Dynamic Workers at GA) stays open on todo_7359. Both Workers ship: appolis 0.57.2 first (the migration creates the class), then appolis-sandbox 0.57.2. CHECKED by one independent security review before the ship (GO; its three should-fixes — names in other scripts and short names, the snapshot's quiet fall back on an unreadable accounts doc, the guard landing before a doctype — and two minors — the destroy key, a <head> in a comment — fixed and pinned). PINNED: test/workshop-followups.mjs G0–G10 (new, 27 checks); test/workshop-env.mjs E7f re-pinned (the sandbox Worker binds exactly one Durable Object, SBX_GATE by script_name appolis) and its env gains a fake gate (test/fixtures/fake-gate.mjs — the real class over a Map). IN PLAIN WORDS: deleting a sandbox now takes effect everywhere at once; snapshots hide everyone's names, not just their email addresses; a preview warns you before a link takes you off it; only the sandbox's maker can hand work into it; and the Workshop says plainly that the other apps still show live data to a sandbox seat.).blueprints/workshop-20261024/REVIEW.md). WHY: on the live hub, appolis_sandbox_submit answered NOTHING_TO_SUBMIT twice right after an appolis_sandbox_write that a read {job, path} already showed as overlay:true; a retry 65 s later froze the candidate. Cause: overlayOf found the overlay's paths only by a KV list of job:<id>:ov:*, and a Cloudflare KV list lags a fresh put by up to ~60 s, while a direct get does not. WHAT: every write and every delete also records its path in the job's own index key job:<id>:ovidx (a JSON array, the job's TTL, read by a direct get); overlayOf reads the union of that index and the listing, so submit, the manifest's overlay block and the TOO_MANY_FILES cap all see a write at once, and a path the index missed is still found once the list catches up. The index key sits outside the job:<id>:ov: prefix, so nothing that counts overlay keys changes. The preview host appolis-sandbox is unchanged and stays at 0.57.0 (it reads cand: keys only). PINNED: test/workshop-jobs.mjs W13 (the KV list made blind to every overlay key; write + delete + submit at once → a candidate with README.md + public/index.html; red on 7491e28, green here) — 31/0. IN PLAIN WORDS: when ChatGPT handed in its work right after saving, the Workshop sometimes said nothing had changed for up to a minute; now it always sees the save.).blueprints/workshop-20261024/DESIGN.md. IN PLAIN WORDS: ChatGPT and Codex get throwaway copies to work on in a sandbox; the owner (or Claude) snapshots the system with names, emails and keys scrubbed, and resets or wipes the sandbox environment made from it; what the sandbox AI puts forward lands in the review queue as a candidate; a candidate is previewed at once — its pages at sbx.appolis.app, or the app itself running on a pool host, walled off from the internet; only Claude makes anything real. WHAT: (1) a NEW KV namespace APPOLIS_SBX (binding APPOLIS_SBX in wrangler.jsonc, id filled in from ship.env at J4) holds every Workshop key with a TTL the store enforces — job:<sbj_id> (+ :file:<path> · :note:<id> · :ov:<path>, the overlay; a delete is the tombstone __SBX_DELETED__), cand:<cand_id> (+ :file:<path>, 30 d), snap:<snap_id> (+ :k:<key>, 90 d), env:<sbx_id> (+ :k:<key>, the environment's storage), prev:<cand>:static|live, slot:<n> (n 1–10), pv:<cand>:k:<key>, and two index rows beside the design's table (jobs:<account>:<sbj_id>, envs:<sbx_id>) so no request lists a whole kit; the LIVE namespace gets only a candidate's proposal row; a hub without the binding answers every Workshop tool SANDBOX_STORE_MISSING and stores nothing (never a fallback). (2) lib/sandbox.js (new, pure): SECRET_PATTERNS/scanText, DENY_PATHS/badPath (.., absolute, drive, \, .env, .dev.vars, .pem, .key, id_rsa, .git/, node_modules/, .wrangler/, .sbx/, the reserved __sbx*), scanKit, SNAPSHOT_ALLOW/snapshotAllowed, scrub (accounts pseudonymised acct<n>@sandbox.invalid / owner@sandbox.invalid · Person <n>, token shapes → [scrubbed], other emails → someone@sandbox.invalid, re-scanned — SNAPSHOT_UNSCRUBBABLE refuses the whole snapshot), envFacade + markLive/isLive (a module WeakSet: the hub marks env.APPOLIS on every request and the facade THROWS on it), unifiedDiff, scanBundle, buildModules, shimSource → {shim, shell}, contentTypeFor. (3) NINE native tools (17 → 26): appolis_sandbox_hand · _snapshot · _env (create · reset · destroy · list) · _jobs · _read · _write · _submit · _candidate · _preview (static · live); grant families sandbox_(snapshot|env) → admin, the rest → sandbox (every member row; a basic member lists 19 natives); READ_PREFIXES += sandbox_jobs · sandbox_read · sandbox_candidate; SANDBOX_PASS += write · submit · preview; hand · snapshot · env answer SANDBOX_ONLY to a sandbox seat. (4) THE REBIND: a sandbox seat whose active job lives in a live environment has its request's env.APPOLIS swapped for that environment's facade (after live authentication; never the proposal rows, never the shift registry, never a Claude seat); appolis_whoami answers sandbox {job, env, expires_at} (env null + env_state when destroyed/expired); mintEnvelope signs sandbox_env (null never absent) and fwd() sends x-appolis-env; ROOM_SANDBOX_READY = new Set([]) — EMPTY, so a sandbox seat's room writes stay proposals. (5) THE REVIEW DOOR: appolis_proposals rows gain kind (room · candidate) with candidate + previews; appolis_proposal_apply on a candidate forwards nothing and deploys nothing — it marks the row and the candidate applied and returns the diff + the STAGE words; decline marks the candidate declined. (6) GET /api/sandbox/envs (admin cookie or publish key): live environments + candidates in review — the list the owner's page shows (rows prp-cand-<cand>); /id/my-connector names each bound token's active job/env. (7) A SECOND Worker in this repo, sandbox/worker.js + sandbox/wrangler.jsonc → appolis-sandbox on ELEVEN custom domains: sbx.appolis.app (static: GET /p/<cand>/<path> — the candidate's file, else the kit's; CSP connect-src 'none'; the SANDBOX PREVIEW banner; PREVIEW_EXPIRED 404) and the live pool sbx-1 … sbx-10.appolis.app (the candidate as a Dynamic Worker: env.LOADER.get(<cand>:<hash>, cb), globalOutbound: null, limits {cpuMs 300, subRequests 500}, an ALLOWLIST env — APPOLIS = SbxKv over env:<sbx>:k: or pv:<cand>:k:, ASSETS = SbxAssets, the preview's throwaway PASSWORD/ID_SESSION_KEY/ID_SECRET, SBX_PREVIEW — the bundle re-scanned before the loader is called, 422 BUNDLE_REFUSED otherwise); it binds APPOLIS_SBX and LOADER only — the live wrangler.jsonc gets no loader. TESTS: test/workshop-jobs.mjs (30) + test/workshop-env.mjs (39) pinned; re-pins in grant-token (G1a · G1c · G2a · G3 · G4), one-law-book T6c, publisher P6f, rails T14, whoami W12 · W13. NOT VERIFIED BY THE BUILD (the J6 drill is the proof): that workerd accepts the shim's defineProperty on fetch, that an ESM module may import a {cjs} module in a Dynamic Worker, and that an appolis-sized candidate stays inside limits.subRequests.).blueprints/sandbox-lanes-20261024/DESIGN.md. WHY: the v0.56.0 J6 live drill (07:37Z, the chatgpt sandbox token) showed repo/<folder> refused SANDBOX_ONLY and sandbox/appolis opening — but kosmos/<project> OPENED too, and a board lane a sandbox seat holds turns a Claude seat's writes there away (LANE_HELD) although the sandbox seat's own writes are only proposals. WHAT: appolis_clock_in refuses a sandbox seat's WHOLE claim when any lane is not under sandbox/ — nothing opened, nothing logged, no handshake bump — with the v0.56.0 words (CLOCK-IN REFUSED: platform <p> is sandbox-only — clock in on sandbox/<folder>, code SANDBOX_ONLY), refused_lanes = every non-sandbox lane, and a hint that keeps the path (kosmos/appolis/notes → sandbox/appolis/notes). The tool description now says sandbox/<folder> lanes ONLY. A Claude seat is untouched; a sandbox/<folder> shift still logs to the board its folder maps to (v0.56.0 B14). TESTS: test/sandbox-lanes.mjs (L1–L4 red at 2d93210, K1–K4 controls) · suite 52/0. ALSO RECORDED: v0.56.0 J6 — C19 + C20 passed live through the chatgpt sandbox token, and a real ChatGPT-app write arrived as proposal prp_42d722efb08ad30e, never on the board (note_7279).).blueprints/sandbox-wall-20261024/DESIGN.md, owner picks form note_7272: Q1 = A the token is the ceiling · Q2 = A an unknown client is sandbox · Q3 = A no Daedalus sandbox-Worker routing this version (v0.57.0). WHAT: other AIs never leave the sandbox. A connector token can be minted FOR a platform (POST /id/admin/accounts/:id/connector {platform:"chatgpt"|"codex"} — an ADDITIONAL token, regenerate-to-revoke per platform, {revoke:true, platform} revokes that one; platform:"claude" is refused 400 "a Claude seat is the ordinary connector — a platform-bound token is sandbox-only by construction"); such a seat — or any seat whose client is not Claude — is sandbox-only with no switch: lib/platform.js ACCESS = { claude: live, chatgpt: sandbox, codex: sandbox } (frozen, no setter), DEFAULT_ACCESS = sandbox; resolve({tokenPlatform, clientTag}) — a bound token is sandbox whatever the UA says, an unbound token falls back to the UA tag, which can only lower. THE WALL (worker.js, the second gate right after THE GRANT): a sandbox seat may READ (tool-hints.isReadTool, which now also names whoami · laws · proposals), use the shift tools and appolis_proposals; any other hub tool is refused SANDBOX_ONLY by name; a ROOM write is NOT sent but stored as a proposal — KV id:proposal:<prp_id>, one key each (no read-modify-write), prp_id from the exactly-once id when the call carried a session (a re-send answers already_stored), args ≤ 64 kB else PROPOSAL_TOO_LARGE, 30 d open / 7 d once decided; the answer is a normal result status:"PROPOSED", code:"SANDBOX_ONLY". Three new native tools (14 → 17; grant family proposal, on the member row): appolis_proposals (read; a sandbox seat sees its own, a live seat the accounts it may see), appolis_proposal_apply (live Claude only — the GRANT for the reviewer's role, the READ_FIRST rail for the reviewer's session, then forwarded ONCE under the reviewer's envelope with params._meta {requestId, proposal, proposed_by}; a room error leaves it OPEN), appolis_proposal_decline (reason required, nothing forwarded). THE CLOCK-IN WALL: a sandbox seat claiming repo/<folder> is answered "CLOCK-IN REFUSED: platform <p> is sandbox-only — clock in on sandbox/<folder>" (code SANDBOX_ONLY, nothing opened); sandbox/<folder> is its lane and logs to the board repo/<folder> would. THE ENVELOPE: two additive signed claims platform and access before client (null off the request path); client, x-appolis-client, the shift who, whoami (+ platform · access · access_source) and the skill packages read the SEAT's client — byte-identical to the UA tag on an unbound token. /id/my-connector answers {url, apps, createdAt, sandbox:[{platform, url, createdAt}]}; /id/admin/connectors rows carry platform; ensureConnector and the internal /id/connector stay on the unbound token. UI: 🧱 sandbox connector… beside each account's 🔌 button (and 🧱✕ to revoke one), and the profile card lists each sandbox URL with "sandbox-only: this AI's writes are stored as proposals for a Claude seat to review; it cannot clock in on a repo lane, publish, or deploy". FILES: lib/platform.js (new) · worker.js · lib/tool-hints.js · lib/grant.js · public/app.js · public/features.html · APP_BREAKDOWN.md · test/sandbox-wall.mjs (new, 20 lines: S1–S6 controls, B1–B14 biters with real-request drills) · re-pins named in the build record (BUILT.md): whoami W12/W13 (17 / 12 natives), the native and family counts in grant-token · one-law-book · publisher · rails, the mintEnvelope arity in sid-in-envelope T5 and chatgpt-door T3b, the version rows in exactly-once X14b/X14c, and a Claude User-Agent on every harness that drives the real worker (Q2 = A: "drill scripts identify as Claude"). CALIBRATION (C2, measured on the bare git archive 7946b63 tree + the new test alone): EXIT=1 · 14 ❌ · 6 ✅. RESIDUAL: KV is eventually consistent per location (a list or apply right after a store may miss it — the tools say "try again in a minute", never an empty list without the note); on an UNBOUND token the UA is still self-declared and can only lower; a proposal is the raw tools/call — the reviewer reads the arguments, there is no rendered diff; two concurrent applies of one proposal are not locked (the room sees one stable _meta.requestId per proposal to dedupe by). NOT CHANGED: the rooms, /id/hubs/, /id/pm/, exactly-once, the GRANT table rows other than +proposal, the shift-tools array literal, wrangler.jsonc (Q3 = A).)daedalus_step reached the door twice: the first copy PASSED at 17:57:47.428Z and served SEED, its answer never reached the chat, the chat received the second copy's "mismatch at SEED"), DESIGN.md at .blueprints/double-delivery-20261023/DESIGN.md. FINDING (measured at 89bc262): the connector forwards a tools/call ONCE — fwd() worker.js:928-950 is one svc.fetch, no loop, no timeout re-send; callRoom, the handshake's fwd, forwardPmPush/Hermes likewise — so the second copy was a re-POST of the same JSON-RPC message by the transport above this worker (which transport cannot be proven from this repo; the defence holds either way). WHAT: (1) the fan-out (:1512) derives a request id rid = sha256-32 of seat · session id · JSON-RPC id · tool · arguments — only when the client sent Mcp-Session-Id (real MCP clients echo one and increment ids; a script with fixed ids and no sid is NOT deduped); a KV marker fwd:once:<rid> (lib/email.js's idem shape: {state:1} in flight 120 s · {state:2, answer} kept 120 s) is looked up → reserved → the call forwarded ONCE → completed with the final answer; a re-send gets the FIRST answer with a separate content item 🔁 replayed, not re-sent …; a re-send during the first copy's flight waits (250 ms polls, ≤ 20 s) then answers IN_FLIGHT in words ("NOTHING WAS SENT by this copy"); a room error (j.error — e.g. Kosmos's Durable Object reset after its deploy), a no-answer or a thrown fetch RELEASES the marker so a real retry goes through; a KV put that throws forwards once anyway and says ⚠ exactly-once guard not held in a tail. (2) the forwarded message carries params._meta.requestId = rid (no room reads _meta today — measured; the Daedalus / Kosmos halves dedupe by it in their own lanes, filed as to-dos). (3) FOLD-IN: appolis_whoami answers shifts_error (string | null) and shifts: null when the registry could not be read — never a silent []. RESIDUAL, said plainly: KV is consistent per Cloudflare location; a re-send that lands elsewhere inside the propagation window, or two copies inside the same few ms, can still be forwarded twice as before — the product-side dedupe closes that. NOT CHANGED: fwd()'s text (lift-pinned by test/chatgpt-door.mjs), hub-native tools, tools/list, initialize, /id/hubs/, /id/pm/, lib/*. TESTS: new test/exactly-once.mjs (18 lines X1–X14c; calibrated at 952cecf: 12 bite, the six controls X1 X6 X7 X8 X9 X10 green before and after); two old test harnesses (test/echo-once.mjs, test/grant-token.mjs) now send a fresh JSON-RPC id per call, as a real MCP client does — they re-sent one sid + a fixed id + identical arguments and expected the second copy to reach the room, which v0.55.0 replays by design; test/whoami.mjs untouched (v0.54.1 already relaxed W15/W16); whole suite 50 files, RED=0.)PM_TIMEOUT_MS = 10000 (worker.js:477); Kosmos loads AND saves each recipient's board, one load measured 5,230 ms wall. WHAT: PM_TIMEOUT_MS 10000 → 25000 for /id/pm/push only; the Hermes-bound door keeps PM_HERMES_TIMEOUT_MS = 7000 (under Kosmos's 8 s race). Hermes sets no timeout of its own, so Appolis's is the only limit; Workers bill CPU, not wall. The forward is still EXACTLY ONCE (no retry). PIN: new test/pm-timeout.mjs (T1–T3; red at 89bc262 on T1). Consumers: forwardPmPush is the only reader (worker.js:477, :504). DRILL OWED: Hermes D1 pm_tasks.kosmos_error NULL and kosmos_pushed_at moving after the next tick. Kosmos's own speed-up is its lane's to-do.).blueprints/whoami-20261023/DESIGN.md. WHAT: one new read-only hub tool, appolis_whoami (the 14th NATIVE_TOOLS entry, annotations.readOnlyHint:true, dispatched on its own right after the appolis_laws block — never inside the pinned shift-tools array). It answers two ids in SEPARATE fields: seat = the Appolis ID the connector token belongs to (a.id — what the shift registry's who.account and every signed envelope carry; acc_8b23f06c8186 for the owner) and account = the PERSON the boards record as the author — Kosmos's own account for that seat (acc_root for the owner), asked of the kosmos room per call through fwd('kosmos', tools/call whoami), never cached and never guessed from the hub's registry (whose acc_root is a seed row, lib/accounts.js:14). Beside them: person {account, email, name, role, source}, connector {account, email, name, role, hub_role, kind:'ai', seat:'connector', client, sid8}, the seat's open shifts (≤ 12: id · lanes · label) and a one-line note. WHY: a lane writes OWNER.txt from a live answer instead of a saved form; forms are ticked by acc_root while the session seat is acc_8b23f06c8186, and no tool said which was which. THE KOSMOS HALF (law 7, another lane): Kosmos has no whoami tool at v8.144.0 — until mission-control ships kosmos_whoami ({account: ctx.accountId, email, name, role, tier, seat: <the envelope's a>}), account is null and account_source says kosmos door missing — … in words (the v0.53.0 rule: nothing faked); filed as a to-do on the mission-control board. GRANT: lib/grant.js gains the identity family (whoami) on every member row — a basic connector now lists 9 hub tools, the owner 14. NEVER in the answer: the connector token, a shift token, the connector URL, an envelope. MOVING PINS re-pinned 13→14 natives (test/rails.mjs T14, test/one-law-book.mjs T6c, test/publisher.mjs P6f) and 8→9 / 68→69 / 10→11 (test/grant-token.mjs G1–G4). PINNED: test/whoami.mjs — W1–W16.).blueprints/pm-forwarders-20261023/DESIGN.md. WHAT: two forwarders in the v0.52.0 hub-door shape (forwardPmPush / forwardPmHermes, worker.js, right after forwardHubDoor). (1) POST /id/pm/push — Hermes's five-minute tick (chardizy server/pm.js pushToKosmos) calls it with its OWN key (the legacy secret is 401) and a batch {tenant, business, items:[{ref, project, title, state, done, target_date, notes, waiting_on, assignee, url, updates, recipients[emails], updated_at} | {ref, deleted:true, recipients}]} (≤ 100 items, ≤ 512 KB, every item an object with a ref; app in the body is overwritten with the KEY's slug). Appolis forwards the WHOLE batch ONCE to Kosmos /internal/pm/push under an envelope minted for the OWNER (aud kosmos, client = the calling app, 60 s — Kosmos v8.144.0's door admits only client 'hermes', maps item.recipients to its own accounts itself and writes every recipient's board through a plain store) and passes Kosmos's answer back, status and body unchanged: {ok:true, results:[{ref, ok, error?}], applied} — Hermes stamps the ok refs and retries the refused ones next tick. Kosmos without the door — a 404, or the SPA's HTML 200 for a route that does not exist — is 503 {ok:false, error:'kosmos door missing …'}, never a pass-through 200 Hermes would stamp as pushed; a dead Kosmos (thrown fetch, 10 s timeout) is 502 "nothing is known to have landed"; a 2xx without ok:true is 502. At this build Kosmos v8.143.0 has NO /internal/pm/push (measured: grep -rn 'internal/pm' mission-control → nothing committed; todo_7018's build sits in a worktree), so until v8.144.0 ships Hermes sees 503 "kosmos door missing" on every tick, logs it once and keeps retrying — nothing is lost, and nothing is faked. (2) /id/pm/hermes/<path> (GET · POST · PATCH · DELETE) — Kosmos works a person's Hermes tasks: it calls with ITS key (only kosmos may name a person — PM_PERSON_CALLERS; another app's key is 403) and names the acting person in x-appolis-email (a live Appolis ID or 404; an x-appolis-account that is not that email's id is 409); Appolis mints a hermes-audience envelope for that person (client kosmos) and forwards to Hermes /internal/pm/<path> + the query with the shared x-id-internal, body unchanged (≤ 512 KB); Hermes re-enters its own /api/pm/<path> AS that person (chardizy server/api.js /internal/pm/), so every Projects rule and the author hold; status and body pass back verbatim. Exactly once, never a retry (todo_6696's double-delivery lesson): a thrown fetch is 502 "Hermes did not answer — nothing is known to have changed; read the task back before retrying"; a caller that sends x-idempotency-key on a write gets the mail door's guard (lib/email.js idemLookup/Reserve/Complete/Release under the namespace pm:<slug>): the same key again is 409 {deduped:true, hermes_status} and Hermes is NOT called a second time; a thrown fetch or a Hermes 5xx releases the key so a real retry can go; GET never dedupes (Kosmos v8.144.0 sends no key yet — the offer stands for its create hook). (3) The hub verdict (todo_6917) is door (2) with path hub-verdict and the hub OWNER as the acting person: Hermes v2.179.0 named the route POST /internal/pm/hub-verdict {link_id, verdict:'approved'|'changes', by, note} (server/pm.js:1853-1878 · :1924-1929 — reached only through the internal door; a Projects manager behind the envelope), which supersedes todo_6917's /id/hubs/review → /internal/pm/links/:id/review names; Kosmos's hook (the caller: hubs.js markAction / the shell's resolve / /api/approve) is not in v8.144.0's build either — it stays filed on todo_6917. (4) Raven (raven.sage21@gmail.com) → team_fml: team seats live in KV id:teams ({teams:[{id, name, ownerAccountId, members:[{accountId, role}]}]}, worker.js:142-143); Hermes's own user-add reaches /id/provision/grant (worker.js:2011-2066) and seats the person only as Basic and only when Hermes sends teamId — so the ship run checks GET /id/teams/team_fml/members and, with the owner's go, adds or promotes him to Dev (POST /id/admin/teams/team_fml/members {accountId, role:'Dev'} / …/members/<acc>/role {role:'Dev'}, worker.js:2685-2736) and sets his hub role dev (POST /id/admin/accounts/<acc>/role {role:'dev'}); "creative" is not an Appolis role (TEAM_ROLES = Owner · Admin · Dev · Basic). TESTS: new test/pm-forward.mjs (P1–P7, 17 lines; RED at ff07fff on every line but the P1 control, GREEN here); the whole suite → suites 47 run / 0 RED = none. ⚠️ DOORS: /id/pm/push and /id/pm/hermes/ are APP-KEY doors — x-id-internal = the calling app's own key; the legacy shared secret opens neither. ⚠️ The Hermes door passes Hermes's answer back VERBATIM, so a Hermes 404 {"error":"not found"} means Hermes's own /internal/pm/ gate refused the shared secret (its deps.idSecret), while a Hermes 404 task not found is the Projects route itself — read the body. NOT CHANGED: the connector fan-out, /id/hubs/, every other /id/* route; no Kosmos or Hermes file.)/id/hubs/ensure and /id/hubs/:hub/docs (POST + GET): an app calling with its OWN key (never the legacy secret — 401) is forwarded to Kosmos's /internal/hubs/* under an envelope minted for the owner with client = the caller's slug; source.app in a doc body is overwritten with the key's app; status and body pass through unchanged; no APP_KEY_KOSMOS → 503, said in words. appolis todo_6532; the Kosmos half is v8.141.0. PINNED: test/hub-forward.mjs 8.).blueprints/grant-token-20261015/DESIGN.md. WHAT: (1) the grant — new lib/grant.js, a data table hub role → room → tool FAMILIES (appolis: admin · shift · laws · skills; kosmos: hub · lander · project · board, by name pattern; any other room: one family). tools/list lists only the families a person's role holds and tools/call answers an unlisted name NOT_GRANTED naming the family, never forwarding it; the owner is * everywhere (his list byte-identical, pinned); the insider wildcard opens no room whole — only an explicit per-app grant does, and an explicit per-app admin keeps the whole room; Agora [] for every non-owner (form note_6246 Q1 = A); Daedalus lists its room only on an EXPLICIT per-person grant — the insider wildcard opens nothing there and the room's own gate keeps daedalus_step and its admin tools owner-only (form Q4 pending; the default rides). The room keeps its own gate — the grant only ever removes a name or refuses a call. (2) the token — appolis_clock_in returns a per-shift token (tk_ + 32 hex) beside the id; the store keeps only its SHA-256 (token_hash, stripped from every reply and listing); appolis_clock_out {id, token} closes that shift from ANY session — after the connector's session id rotated — with no force; a wrong or malformed token answers BAD_TOKEN and closes nothing, never falling back to the session-id road; no token = every v0.49.1 rule unchanged. (3) the envelope — the shifts a room receives carry who {name, kind, seat, label}, opened_at, heartbeat_at (so Kosmos's shifts.mine[0].since is no longer null) and caller_shift: the caller's shift when the account has exactly ONE open shift, else null — never a guess by lane coverage. (4) the seed follows the store — DEFAULT_LAWS gains law 7 ONE LANE PER APP (seven lines, sha16 3a63a102a8ed082c, compose 1812 / 70f22cd539af), test/one-law-book.mjs re-pinned to seven. WHY: Raven's connector listed owner-only tools he could not call plus whole rooms he does not use (noise and a refusal surface); a rotated session id forced "force:true" closes with an apology in the card; a create from one chat was stamped with another chat's shift; an empty law store would have silently dropped law 7. MEASURED: pinned test/grant-token.mjs 43/0 (a real worker.fetch() harness with five fake rooms; at HEAD 14/25 — exactly the defects; J1b re-pinned after form note_6246 Q2 = B); the owner's served tool list counted by family before and after (measure-tools.mjs), unchanged; a basic account with a Kosmos grant goes from 95 listed tools to 68 (the hub's 8 + Kosmos board 39 + hub 21 — Q2 = B, Raven is Creative on the hub). NOT CHANGED: the handshake text, SHIFT_RULE and the RULEBOOK (byte-identical); daedalus_step stays owner-only at the room; the admin take op mints no token. THE DOCS PAGE: rebuilt inside this ship (form note_6215 Q1 = A). TO REFRESH: a tool-list change is invisible to a person until they refresh the connector in the desktop app.).blueprints/todo2905-silent-room-20260919/DESIGN.md. WHAT: worker.js's fwd(slug, body) (the one function every MCP room call goes through) gains fwdFail, a per-request Map of slug → reason — !svc → 'no service binding', !r.ok → 'HTTP ' + r.status + ' from /internal/mcp', a thrown fetch → 'threw: ' + message — while its RETURN CONTRACT to every other caller is UNCHANGED (still a bare null on any failure). tools/list no longer silently skips a slug whose forwarded tools/list came back empty or malformed: it pushes ONE zero-arg synthetic tool, <slug>_unavailable, whose description names the reason and says to refresh once it's fixed. tools/call on that synthetic name is never forwarded to the room (it has no such tool) — it re-checks tools/list once, right then, and answers in words either way, never a JSON-RPC error (no -32603). WHY: a room with no binding, an HTTP error, or a thrown fetch all collapsed to the same invisible nothing — a client entitled to an app that happened to be down (or never wired) saw no trace it existed at all, and had no way to tell "not entitled" from "briefly broken" apart from asking a person. MEASURED: pinned test/silent-room.mjs (A1–A3, a real worker.fetch() harness with three fake service bindings — one answering tools/list normally, one answering with an HTTP error, one with no binding at all — calibrated RED at HEAD b224aba, exit 1; GREEN after this build, exit 0); full suite test/*.mjs, 43 files, 0 RED. WHAT DID NOT CHANGE: fwd()'s return value to every existing caller (initialize, tools/call on a real forwarded tool) — still null on failure, nothing downstream had to change; the initialize handshake (the KV snapshot / FAST DOOR) carries no per-room status — the notice rides tools/list only, never a live per-call fan-out; tool count for every ALREADY-answering room; the READ_FIRST rail, the compliance nudge tail, the heartbeat throttle. Carried forward, unchanged since their own ships: v0.45.0's own READ_FIRST rail (todo_4547) and its served before-number of 79,246 B stay exactly as recorded, this build's own test/rails.mjs harness still measures harness: compact=6671 B legacy=68022 B (unchanged — this build touches worker.js's fwd/tools/list/tools/call only, never lib/rulebook.js or lib/laws.js); tool count stays 13 native / 151 total (unchanged — no new native tool; _unavailable is a per-room synthetic, not a native one); the publisher (todo_4467, v0.46.0, 📤) and its "unchanged" rule; the 💬 comments-form rail (todo_4605, v0.48.0) and its idempotent box; v0.49.1's clock-out ambiguity guard.).blueprints/clock-out-ambiguous-shift-20260918/DESIGN.md. WHAT: lib/shifts.js gains a pure closeTargetFor(doc, account) — the account's open/stale shifts: exactly one → {shift}, several → {ambiguous}, none → {none} — and appolis_clock_out picks through it instead of defaultShiftFor(doc0, a.id, sid): with no id and more than one open shift the call answers WHICH_SHIFT with ambiguous:true, closed:null and every open shift (id · label · lanes · intent · opened_at · sid · yours) and closes NOTHING; with exactly one open (or stale) shift it defaults to it, unchanged; an explicit id is unchanged (the v0.37.1 sibling NOT_YOURS and v0.38.1 cross-account guards untouched); zero open shifts still fall to the close op's NOT_FOUND. WHY: measured 2026-09-18 23:3xZ — every chat on one connector shares one Mcp-Session-Id (this run: two chats' shifts under ms_65be433b2c…) and the id rotates on reconnect, so defaultShiftFor's "the one shift under my sid" picked a SIBLING chat's shift, sh_5fcd23b292 (lander-builder), and overwrote its card; that session re-clocked as sh_a2d9213eaa. Tyler: "hard-refuse (name the ambiguity) … instead of guessing." The rails row THIN_CARD / NOT_YOURS widens to name WHICH_SHIFT (RULEBOOK 2,698 B of 3,000, TRAP #13); the tool description says id is REQUIRED when several are open. appolis_heartbeat keeps defaultShiftFor. HOW VERIFIED: pinned test/clock-out-no-guess.mjs (G1–G10, a real worker.fetch() harness over the KV road with explicit Mcp-Session-Id headers — the rotated-sid shape G2 calibrated RED at HEAD 11dd442, closing the sibling's shift; G5–G7 GREEN at HEAD, unchanged behavior); re-pins test/heartbeat-caller.mjs and test/shifts-wire.mjs (their defaultShiftFor(doc0…) anchors move to closeTargetFor(doc0, a.id)); full suite test/*.mjs, 43 files, 0 RED; served drill D0–D4 on the real connector (two drill shifts under two forged sids, an id-less clock-out under one of them → WHICH_SHIFT, both still open, then each closed by id). WHAT DID NOT CHANGE: defaultShiftFor itself and every heartbeat path; the NOT_YOURS / ALREADY_CLOSED / THIN_CARD guards; clock_in, handoff, shifts; tool count 13 native / 151 total (no new tool). Carried forward, unchanged since their own ships: v0.45.0's own READ_FIRST rail (todo_4547) and its served before-number of 79,246 B stay exactly as recorded, this build's own test/rails.mjs harness still measures harness: compact=6671 B legacy=68022 B (the widened row moves compact by ≈ +116 B — measured, not guessed); tool count stays 13 native / 151 total (unchanged — no new tool); the publisher (todo_4467, v0.46.0, 📤) and its "unchanged" rule; the 💬 comments-form rail (todo_4605, v0.48.0) and its idempotent box; v0.49.0's client-aware skill homes (todo_4713).).blueprints/teach-job-graph-codex-20260918/DESIGN.md (v2, revised against a real Codex/Astra review of the v1 build). WHAT: lib/skills.js gains homeFor(client)/writeToFor(client, name) — a Codex session's skill home is ~/.agents/skills/<name>/ (measured: Codex discovers ~/.agents/skills/<name>/SKILL.md and <repo>/.agents/skills/<name>/SKILL.md, never ~/.codex/skills/), a ChatGPT (web) client has no local skills folder at all (null), and every other client tag ('claude', 'none', '', or none) keeps today's ~/.claude/skills/<name>/, byte-for-byte — plus a NEW pure packageFor(client, pkg): for a codex client whose package carries a codex/SKILL.md, that file becomes the delivered SKILL.md (restamped with the package version), codex/SKILL.md itself dropped from the set, every other file untouched; every other client's package is byte-identical to today. The appolis_skill handler now shapes the package through packageFor BEFORE the files-subset filter, so a Codex client never receives the Claude-dialect SKILL.md even when it asks for {files:['SKILL.md']} only; both handlers call through the lib instead of hard-coding the old literal, so write_to and the compare-note follow the calling client's tag; the two tool descriptions' Codex clause now reads ~/.agents/skills/<name>/. Alongside, the blueprint skill package (~/.claude/skills/blueprint/) gains a Codex-native dialect: CODEX.md (the same J0–J8 job graph in Codex's own primitives — codex exec with --skip-git-repo-check and both MCP doors disabled per worker line, claude -p with --restricted --strict-mcp-config --permission-prompts none as the no-shell door, the shift/lane fence), codex/spawn.sh (both doors + a SPAWN_DRY static/dry-run mode + a sandbox allowlist), codex/ledger-line.mjs (the new ledger/packet writer, real JSON.parse/JSON.stringify, 19 keys), codex/SKILL.md (the Codex entry point delivered as SKILL.md via packageFor), and one rewritten append to SKILL.md (## 18 · Codex orchestrators, still pointing at CODEX.md, now naming ~/.agents/skills/) — nothing above the append changes. The five v1 codex/agents/.toml native-subagent role files are DELETED this round (Codex's own spawn interface has no role/cwd selector yet — deferred to its own blueprint, not shipped unmeasured). MEASURED: test/skill-homes.mjs H1–H7 green; the skill package's tests/codex.test.js K1–K9 green; workers.json untouched (K8, still 8 doors). HOW VERIFIED: both pinned suites calibrated against the dirty v1-built tree (not HEAD) before this build — every [R] check red, every [I] check green; full suite test/.mjs, 0 RED after. WHAT DID NOT CHANGE: a Claude Code client sees exactly today's write_to, note strings and files (byte-identical); no change to lib/client.js, lib/accounts.js, envelopes, tokens, roles, or handshakeLineFull; the Appolis routine file untouched; workers.json's 8 doors and their order. Carried forward, unchanged since their own ships: v0.45.0's own READ_FIRST rail (todo_4547) and its served before-number of 79,246 B stay exactly as recorded, this build's own test/rails.mjs harness still measures harness: compact=6555 B legacy=68022 B; tool count stays 13 native / 151 total (unchanged — no new tool); the publisher (todo_4467, v0.46.0, 📤) and its "unchanged" rule; the 💬 comments-form rail (todo_4605, v0.48.0) and its idempotent box.)lib/rulebook.js gains one new rail-shaped line, COMMENTS_FORM, inserted between TIME_CARD and RAILS_HEAD in both RULEBOOK and rulebookText() (exported alongside RULEBOOK/rulebookText/compact/sha16): the whole suite's handshake now tells ANY client — Claude Code, Codex, ChatGPT, an unattended run — that when a Kosmos project's envelope carries comments_form (overview/get_project/ai_worklist/any write on it), it should post one idempotent 💬 box, kosmos_post_decision_form {project, comments:true}, at the end of every reply; an open form takes the box in, an unanswered box is never doubled (already:true). This is the door half of the setting — the per-project VALUE (p.commentsForm) and the idempotent server join ship in the same run as Kosmos v8.133.0, note_4661's other repo, not this one; appolis_laws sections are untouched (a rail-shaped instruction, not a refusal, not a law). MEASURED: RULEBOOK 2,081 → 2,502 B (cap 3,000, test/rails.mjs T1); this build's own test/rails.mjs harness now measures harness: compact=6555 B legacy=68022 B (up from 6,123 B — the one new line). HOW VERIFIED: pinned test/comments-form.mjs (A1–A4, calibrated RED at HEAD a484411 — see .blueprints/comments-form-setting-20260918/calibration.txt — 4 ✅/0 ❌ after this build); full suite test/*.mjs, 41 files, 0 RED. WHAT DID NOT CHANGE: v0.45.0's own READ_FIRST rail (todo_4547) and its served before-number of 79,246 B stay exactly as recorded; tool count stays 13 native / 151 total (unchanged — no new tool); the publisher (todo_4467, v0.46.0, 📤) and the 📜 ONE LAW BOOK shape (v0.47.0, below) are untouched; the shift and compliance tools; echo.fold, lane_activity, the fast door's timings; mission-control (Kosmos) ships its own v8.133.0 build in the same ship, its own row on its own board.)id:rules is now THE law book — lib/laws.js (new): six DEFAULT_LAWS lines (the seed for an empty store), lawBook/lawsText/RAILS/railsText. HOUSE_RULES and ownerRulesText DELETED from worker.js; lib/rulebook.js's JUDGEMENT paragraph, lawsDigest and headlineOf DELETED (the laws are short enough to ride in full). sections.house GONE — appolis_laws {section:'house'} now errOks, naming the retirement. The legacy appolis_laws {} formula moves to lawsText(rules) + SHIFT_RULE + directives + skillsLine (the v0.44.0 byte-identity invariant this replaces is RETIRED by design — DESIGN.md §2.2). lib/claude-md.js gains compose(rules) — the CLAUDE.md block is COMPOSED from the law book, never hand-typed; appolis_claude_md_publish no longer accepts text (a call with it errOks, naming 📜 Laws), only recomposes on a no-argument call, and saveSuiteRules recomposes it on every rules save too. The 📜 Laws page: the old houseRules box is REPLACED by a read-only "📏 The rails" list (code · when · fix, from lib/laws.js RAILS); GET /id/admin/rules drops house, returns { rules, rails }. The clock tool descriptions and lib/shifts.js's SHIFT_RULE move to ONCE-per-session wording — clock in ONCE at the first change, clock out ONCE at the very end of the session, after every question is asked and answered (Tyler's decision, todo_4605 §1). MEASURED: served handshake <n> B (≤ 10,240), tools 151 total / 13 native (unchanged — no new tool); this build's own test/rails.mjs harness (the 11-directive/7-law fixture) now measures harness: compact=6123 B legacy=68022 B. HOW VERIFIED: pinned test/one-law-book.mjs (T1–T9, every group calibrated RED at HEAD, 55 ✅/0 ❌ after this build); 7 given-spec re-pins (test/rails.mjs, test/chatgpt-door.mjs, test/shift-one-piece.mjs, test/shifts-own.mjs, test/shifts-wire.mjs, test/rules-cards.mjs, test/skills.mjs), each isolated-calibrated, all green after this build; full suite 40 files 0 RED. WHAT DID NOT CHANGE: v0.45.0's own READ_FIRST rail (todo_4547) and its served before-number of 79,246 B stay exactly as recorded; tool count stays 13 native / 151 total (unchanged — no new tool); the shift and compliance tools; echo.fold, lane_activity, the fast door's timings; the publisher (todo_4467, v0.46.0, 📤) and its size cap stay, only the text argument retires; mission-control (Kosmos) ships its own v8.132.0 build in the same ship, its own row below.)appolis_skills_publish / scripts/publish-skills.mjs's "unchanged" is now decided from the HASH half of a skill's <date>.<hash12> stamp (lib/skills.js sameContent/hashOf, new), never the date — a re-publish of byte-identical files on a later day is a true no-op: the stored package, the index entry and the handshake generation all keep their PREVIOUS values; a real content change still gets a new stamp and bumps. Packer hygiene: a .bak copy or a SKILL.v.md versioned copy never ships (lib/skills.js shipsFile/SKIP_FILE, new — dropped even through the appolis_skills_publish tool road, not just the folder walk), and a skill's own tests/ directory now ships (SKIP_DIR moved into lib/skills.js and no longer skips it, todo_4251 — a skill installed from the registry can run its own gate). A new bearer route, GET /id/skills, lets scripts/publish-skills.mjs --dry compare its local stamps against the SERVED index and print "(unchanged)" truthfully instead of guessing from the clock — a fence (read-only) key may read it, only a publish key may still PUT. THE CLAUDE.md BLOCK (todo_4571): a new pure module, lib/claude-md.js (render/versionIn/apply/versionOf/sameContent), and a new owner-only tool, appolis_claude_md_publish, hold ONE canonical marked block for ~/.claude/CLAUDE.md; the handshake now names its version in a new 📄 CLAUDE.md block <version> line (sits before the skills line, ≤ 320 B measured on the pinned fixture) and appolis_laws {section:'claude-md'} serves the full block ({ version, path, block, how }, or { version: null } unpublished) — read FRESH from KV on every call, never from the cached handshake snapshot, so a publish is visible on the very next call, and a KV read failure THROWS the tool error rather than silently answering unpublished. A Claude Code session with no marker (or an older version) writes the block between its markers at the top of its file; a teammate's own text above or below is never touched; non-Claude clients need nothing. WHY: Tyler, todo_4467 — the skills registry was re-fetching all 8 house skills (≈ 650 KB) on every machine's first connect each day, purely because the stamp's DATE half changed even when the files did not; note_4566/note_4569 — the long CLAUDE.md file drifts from the board's own rules with no way to keep it current except a manual paste. HOW VERIFIED: pinned test/publisher.mjs, a real worker.fetch() harness (P1–P10, same shape as test/rails.mjs) — every P1–P10 group was calibrated RED at HEAD and flips GREEN; test/rails.mjs T14/T18 moved (12→13 native tools, 150→151 total) and test/chatgpt-door.mjs T6 moved (the deck's live label/foot v0.45.0→v0.46.0), both re-verified fully green; appolis_laws {} (no section) still sha256-matches the byte-identical v0.45.0 legacy formula on the pinned fixture — claude-md never touches it. MEASURED: tools 151 total / 13 native (the 12 of v0.45.0 plus appolis_claude_md_publish); the blueprint skill's own dry run under the new packer-hygiene rules: 21 files · 143 KB as the script prints it (148,173 raw bytes ≈ 145 KB before CRLF normalisation; 15 old − 1 SKILL.v1.md dropped + 7 tests/ now included — SKILL.md.bak-0844 was already excluded by the walk's own text-extension filter, unrelated to the new rule). WHAT DID NOT CHANGE: appolis_laws {} (legacy) byte-for-byte; v0.45.0's own READ_FIRST rail (todo_4547) and its served before-number of 79,246 B stay exactly as recorded — this ship only layers the publisher and the CLAUDE.md block on top (RULEBOOK grows by 12 chars to name claude-md, still ≤ 3,000 chars — this build's own test/rails.mjs harness now measures harness: compact=3581 B legacy=70658 B, up from 3570 B); the skills line's own wording; the shift and compliance tools; mission-control (Kosmos) — out of scope this version, DESIGN.md §6.)instructions becomes a compact rulebook — the rail names, the tool names and the judgement calls this house enforces, ≤ 3,000 chars (lib/rulebook.js, new) — assembled with one line per open directive (compliance.directivesLines, new), one line for doors (compliance.doorsLine, new), one for shifts (shifts.handshakeShort, new) and one for skills (skills.handshakeLine, redefined — the old paragraph survives byte-identical as skills.handshakeLineFull). The FULL text (house rules, the owner's laws in full, every open directive in full, the doors paragraph, shifts, skills) never goes away — it moves to a new native tool, appolis_laws {section?} (12 native tools now, up from 11; 150 tools total across the connector), answering the exact byte-identical v0.44.0 formula (kept as the handshake snapshot's legacy field) with no section, or one named part (house · shifts · laws · directives · skills · a cmp_… directive id) with one. The handshake snapshot becomes v:2 — { instructions, legacy, sections, ... } — and a v!==2 (or missing) snapshot is treated as MISSING at initialize: rebuilt inline, never served, so the first post-deploy handshake on every account is already compact. A new rail, READ_FIRST (lib/rails.js, new, store injected like lib/echo.js), sits in the tools/call fan-out right after the target app (slug) is resolved and before the call is forwarded: a body-replacing kosmos_update_note / agora_update_note (one carrying text) on a note this session never read this session is refused — the room is never called — naming the exact get_note call that clears it; a title-only or appendText-only write, or a note already read via get_note / read_decision_form / this session's own create_note, passes untouched; a rails-store read failure REFUSES rather than failing open. WHY: Tyler, todo_4547 (2026-09-16, note_4565) — the served handshake measured 79,246 B (78,262 chars) on the served hub before this build: HOUSE_RULES 2,396 B · SHIFT_RULE+owner's laws 3,814–7,819 · nine open directives' full bodies 7,819–77,315 (69.5 KB) · shifts 200 B · skills 1,731 B — nearly all of it read once and then ignored on every later call of the same session. HOW VERIFIED: pinned test/rails.mjs, a real worker.fetch() harness (T1–T18, same shape as test/fast-door.mjs/test/echo-once.mjs): RULEBOOK ≤ 3,000 chars and names every rail code and tool name (T1); initialize on an 11-directive fixture (bodies ≥ 60,000 chars) answers ≤ 10,240 B, carries every directive's id and title but no body sentinel, and 7 law-digest lines (T2); appolis_laws {} sha256-matches the real HEAD initialize response for the same fixture — byte-identical legacy — and is ≥ 60,000 B (T3); every named section and a cmp_<id> answer their own text, a bogus section is a real error naming the six valid sections, never the generic "unknown tool" message (T4); READ_FIRST refuses with no prior read, naming kosmos_get_note and appendText — the room is never called (T5); passes after a get_note (T6); a read on one session id never clears another (T7); no session header names Mcp-Session-Id in the refusal (T8); title-only and appendText-only always pass (T9); create_note counts as a read on the id it returns (T10); a rails-store failure refuses, naming the ledger (T11); read_decision_form counts as a read (T12); the same rail on agora_update_note (T13); tools/list carries exactly 12 native tools, appolis_laws with readOnlyHint:true (T14); shifts.handshakeShort and compliance.directivesLines (T15–T16) and the new skills.handshakeLine (T17, ≤ 400 chars for 8 skills, the old paragraph gone); version surfaces (T18). MEASURED: served before-number 79,246 B — this build's pinned test/rails.mjs harness measured harness: compact=3570 B legacy=70658 B on its own 11-directive/7-law fixture (60,500 body chars) — a 95% reduction on that fixture's own before/after (legacy 70,658 B → compact 3,570 B); the served after-number is recorded on note_4565 after the drill. Tool count: 12 native / 150 total. WHAT DID NOT CHANGE: the ⛔ per-call compliance tail, echo.fold, lane_activity, the heartbeat, the envelope, handshake.isStale/later, the fast door's timings; every existing producer (directivesText, doorsText, handshakeText, SHIFT_RULE, HOUSE_RULES) keeps its name and output — only the assembly and the snapshot shape changed; ~/.claude/CLAUDE.md (a form, never edited from a session); the publisher (todo_4467, v0.46.0); mission-control (Kosmos) — out of scope this version, DESIGN.md §6.)fwd() answers and before the ⛔ compliance tail is appended) now runs the room's JSON result through a new pure module, lib/echo.js, keyed by the sanitized Mcp-Session-Id (sid) and the account (a.id) in KV under echo:v1:<account>:<sid> (24h TTL). A session's laws, compliance and a non-mirror standing_rules each fold to { sha, unchanged: true, seen_at } once their SHA-256 (first 16 hex chars) matches what that session was already sent; a standing_rules that mirrors laws folds to { mirror_of: 'laws' } on every delivery, even the first. The ⛔ compliance tail folds the same way, to one line naming the app, the open count and its sha. A changed block, a new open directive, a different session, or no session id at all still gets the FULL text — nothing is hidden, only repeated less. WHY: Tyler, todo_4526 (2026-09-16): "the connector echo — do whatever you need to do to make this happen … why are we wasting here?" — the served 1st Kosmos write already cost 25,714 B (2026-09-16 18:15Z), most of it the same seven laws and the same standing rules a session had already been told. HOW VERIFIED: pinned test/echo-once.mjs, a real worker.fetch() harness (T1–T12): the first write of a session is FULL with standing_rules folded to the mirror shape (T1); the identical second write is folded (T2); a read's differently-shaped laws block is full again on its first delivery and short on its second (T3); a changed law re-opens FULL then settles short with a new sha (T4); a different session id always gets FULL (T5); no session id always gets FULL and the KV's echo:v1: prefix is never even read (T6); a KV get() failure answers FULL and 200, and the map it wrote makes the NEXT call short (T7); a compliance.unknown block is never folded and never stored (T8); a newly opened directive re-opens the tail to FULL, then folds again with a new sha (T9); a non-JSON or keyless text item passes through byte-identical (T10); initialize and tools/list are untouched (T11). MEASURED: served before-number 25,714 B (the 1st Kosmos write, 2026-09-16 18:15Z, kosmos v8.130.0 + appolis v0.43.2) — this build's pinned test/echo-once.mjs harness measured harness: write#1 4157 B · write#2 665 B · get_project#1 3968 B · get_project#2 560 B (the mirror fold already shrinks write#1 from its pre-fold 7371 B, since standing_rules stops duplicating laws even on the first delivery); the served after-number is recorded on note_4552 after the drill. WHAT DID NOT CHANGE: mission-control (Kosmos) itself — out of scope this version, DESIGN.md §6; Kosmos's own direct /mcp callers still get the full block, by design; the handshake's own size (todo_4547).)mintEnvelope() gains an eighth parameter, sid, signed into the room envelope as sid: sid || null next to client; the request-scoped fwd() (the one whose parameter is body) passes the request's already-sanitized Mcp-Session-Id — the same value worker.js has parsed at the door since v0.36.3 (String(request.headers.get('mcp-session-id') || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64)), now signed instead of only forwarded as a plain header. WHY: Kosmos wants to answer "laws: unchanged" after the first call of a connector session, but could not tell sessions apart — the signed envelope carried account, email, name, role, type, teams, shifts, held, client, and no session id. NOW A ROOM CAN. WHAT DID NOT CHANGE: no room reads sid yet (half B, its own lane); no new header, no transport change; the two other mint sites — the background snapshot rebuild's own fwd (parameter msg) and the 5-arg callRoom helper — pass nothing for sid, so a snapshot or an out-of-request call always carries sid: null (a snapshot serves many sessions, never one). VERIFIED: pinned test test/sid-in-envelope.mjs (T1–T7: a real request with a session header verifies with that sid signed in; no header → sid strictly null; a junk/over-length header → the envelope carries the DOOR's sanitized value, never the raw header; the background rebuild's envelope stays sid:null even when the triggering request carried one; source pin on the 8-parameter declaration and the one call site that passes sid; every pre-existing envelope field still present; version.)annotations, nothing read the client (T2/T3 did not). NOW: lib/client.js clientOf(userAgent) (codex before chatgpt/openai); lib/tool-hints.js isReadTool(fullName) (write overrides first, read prefixes as whole segments); tools/list adds a description fallback and readOnlyHint:true on reads (a room's own wins; none on a write); the client rides the signed envelope, the x-appolis-client header, and clock_in's who.client. Profile: ⬇ Save for Codex (#con-codex-dl) downloads appolis-connector.env. Out of scope: ChatGPT search/fetch, OAuth, Daedalus's meta.client. Pinned: test/chatgpt-door.mjs.)expire() now CLOSES an open shift past its TTL whose lanes are all watch-mode — an implicit presence, never a claim — with released {by:'registry', how:'expired'} and a card saying "implicit · expired", so it never shows as UNCLOSED; a shift with any edit lane still goes STALE and is reported, never hidden; expire() returns {stale, expired}; the old ghosts close on the registry's next write. todo_4382: scripts/publish-skills.mjs sets process.exitCode instead of calling process.exit() after its fetch (the Windows/node 24 libuv assertion — exit 127 after a successful publish). Pinned: test/implicit-expiry.mjs (9).)kind (publish · read; legacy rows count as publish); the store keeps twelve; POST /id/admin/skills/key {label, kind:'read'} answers APPOLIS_FENCE_KEY= for ~/.appolis-fence.env (shown once, downloadable); PUT /id/skills/<name> refuses a read key with 403; GET /id/admin/skills/keys lists id · label · kind · created_at (never a hash); DELETE /id/admin/skills/keys/<id> revokes; GET /id/shifts?lane= reads with either kind. The admin department is 📜 Laws (Laws · Skills · Keys); the city tile, the district heading and every toast say laws; the publish-key mint moves from Skills to Keys. Pinned: test/key-kinds-and-laws.mjs (24). Left: P5 the manager's take.)TTL_MIN is now AI 120 min, a person 360 (cap 720) — on now that the heartbeat is per session (v0.36.3); the rule sentence reads "Silent for two hours (a person: six), a shift goes STALE — reported, never hidden" (Kosmos's built-in copy follows in P2). Q2: the worker's who carries role; in open() a take:true from the MASTER account on another account's OPEN lane evicts it — the shift closes as taken with released { by, how:'taken', reason:<the taker's intent>, told:false } — and the evicted session is TOLD: takenFor() lists a caller's taken-and-untold shifts with a one-line notice, tellTaken() (called inside open) marks them told, so the notice rides that session's next clock-in as taken_from_you + notice; appolis_shifts lists taken_from_you too. Anyone who is not the master is still refused (LANE_HELD), and the master without take:true too. Q5's door: GET /id/shifts?lane=repo/<folder> with Authorization: Bearer ask_… (a key from the skills key store) answers the OPEN shifts whose edit lanes collide with that lane by segment — the deploy guard and the commit hook (P3) read it before a ship or a commit; no key, no app key, no admin → 401; nothing on the door writes. Pinned: test/shifts-round2.mjs (24); test/shift-one-piece.mjs re-pinned. Left for P2: the Kosmos rule copy, the refusal of an un-clocked AI edit, a manager's take; P3: the guard and the hook.)RETIRED_ROOMS map (hephaestus → daedalus) keeps the old slug out of the mounted rooms, so tools/list carries daedalus_roster · _daimon · _dispatch_plan · _ledger only, and a call by the old prefix is answered by name — "hephaestus_roster is retired (2026-09-15, the Daedalus flip) — call daedalus_roster instead, and refresh your tool list" — instead of a silent unknown-tool error; the doors check asks the room as daedalus only, and the doors text says the old names are retired and tells a cached tool list to refresh. KEPT on purpose: the hephaestus slug in MCP_APPS/APP_KEYS (the binding, the key, the compliance roster row — no wrangler change), the entitlement alias both ways (Raven's grant is on the old name and still opens the room), the envelope's own-role alias, REPO_PROJECT.hephaestus (the folder moves later, Q3), the directive titles' "(was Hephaestus)" and the LOCAL fallback clause (their cleanup is its own step). Pinned: test/daedalus-flip.mjs (19); rooms · compliance · daedalus-shadow re-pinned. The deploy is gated on the old host answering 301 to the new one.)daedalus joins MCP_APPS and APP_KEYS as an alias of hephaestus — the SAME service binding (the Worker script is not renamed) and the SAME key (the room verifies one key and checks the aud; a second secret would be one more thing to rotate for no gain), so the fan-out now lists daedalus_roster · daedalus_daimon · daedalus_dispatch_plan · daedalus_ledger beside the hephaestus_* names, each forwarded under its own aud. A hephaestus grant opens the daedalus door and vice versa (the entitled set and the envelope's own-entitlement lookup both alias), so Raven and every member keep the room without a data change. The doors check asks the room by its new name first and falls back to the old one; the SERVER line reads DAEDALUS_SERVER_URL then HEPHAESTUS_SERVER_URL; the doors line reads DAEDALUS DOORS / the DAEDALUS FIRST directive and names mcp__daedalus-local (the hephaestus-local name until a machine's registration is renamed). REPO_PROJECT.daedalus → hephaestus (the folder and the Kosmos slug stay). At the ship, outside the code: the two directive titles retitled through the admin door (ids stay; a title change is not material, no rev bump), CLAUDE.md's free-helpers rule reworded, and the suite standing rule's replacement text posted for Tyler to paste (Q5). Deployed only after MIRROR LIVE on todo_4260 — ship.sh refuses unless daedalus.appolis.app/health answers app "daedalus". Pinned test/daedalus-shadow.mjs; test/rooms.mjs and test/compliance.mjs re-pinned to the alias.)appolis_skills / appolis_skill / appolis_skills_publish were advertised by tools/list but a call fell through to the fan-out and died "unknown tool" — the v0.38.0 handlers sat inside the native intercept whose gate list never named them; the three names now gate the intercept, so a member's session can fetch a house skill. (2) close() in lib/shifts.js found a shift in open OR closed, never checked its state, replaced the card and duplicated the closed row; now anything not open or stale answers ALREADY_CLOSED and the card stays as written. (3) The v0.37.1 sibling-session NOT_YOURS only looked among the caller's own account's shifts, so another ACCOUNT's shift had no guard at all; shifts.findShift looks the id up across the doc and another account's shift answers NOT_YOURS, even to the owner, unless force:true from the master account. Pinned test/shifts-close-guard.mjs. The one row already damaged (sh_d6bece9ecd: Raven's card replaced, duplicated in closed[]) is recorded on the blueprint-skill board, not repaired.)lib/skills.js (pure) — validatePackage (name a-z0-9-, safe relative paths, text only, ≤ 200 KB a file, ≤ 200 files, ≤ 2 MB, SKILL.md at the root), versionOf (YYYY-MM-DD.<hash12> over the files with SKILL.md's own version: line stripped), withVersion, descriptionOf, indexEntry, handshakeLine. (2) KV: id:skills (the index) and id:skill:<name> (the package); id:skillkeys (publish keys, sha256-hashed, newest five valid). (3) Three connector tools: appolis_skills (the index), appolis_skill {name, files?} (the package with write_to), appolis_skills_publish (owner only, the fallback). (4) The handshake ends with 🧰 SKILLS · n house skills served … compare each SKILL.md version: line … call appolis_skill {name} and write its files into ~/.claude/skills/<name>/ before you start work. (5) scripts/publish-skills.mjs packs ~/.claude/skills/* (text files; cache/latest/sessions/node_modules skipped), stamps the version into the local SKILL.md and PUT /id/skills/<name> with Authorization: Bearer ask_… — the key from the Rules → Skills tab (POST /id/admin/skills/key, shown once, ~/.appolis-skills.env or Downloads/appolis-skills.env). (6) The portal: Rules → Rules · Skills sub-tabs; skillsSection() — a card per skill (version chip, description, files · KB · updated · by, the install hint) with ✕ (styled confirm → DELETE /id/admin/skills/<name>) and 🔑 Mint a publish key (an overlay, shown once, with a download); GET /id/admin/skills and GET /id/admin/skills/<name>. Pinned: test/skills.mjs. Not in this version: a download bundle for clients that cannot write files; per-person skills; signing.).blueprints/layout-rework-20260911/DESIGN.md). BEFORE: a numbered list of raw textareas, every rule fully open, a "Save rules" button to remember, the explanation at the bottom. NOW: rulesSection() renders a CARD per rule — .rulen number · the TITLE in bold (the text up to the first " — " or the first sentence end, ≤ 90 chars) · the rest cut at 150 chars behind a ▸ read all toggle that never disappears (ruleReadAll, the full text on data-full) · the rule's textarea kept in the card as rule-<i> but hidden until ✎ (ruleEdit) · edits save when you leave the box (ruleBlur → rulesSave, only when something changed) · ✕ asks through askConfirm then saves (ruleRemove) · + Add a rule appends and opens the new card (ruleAdd) · the one-line lead sits under the heading. The 🏛 built-in cards (v0.37.1) and the storage (suiteRules, collectRules by id) are unchanged. Pinned: test/rules-cards.mjs; test/shifts-own.mjs's harness (DATA + esc only) still evaluates the function.)log_work title cut at 300 characters; a long intent or a two-item clock-out digest pushed the trailing [sh_id] off the end, and Kosmos v8.114.0's changelog fold (matched on that id) could not pair the lines. NOW: clipLine(text, id, max = 300) in lib/shifts.js trims the body with a … so the whole line fits and the id is always last; both clock lines in the worker go through it. Pinned: test/clock-line.mjs.)SHIFT_RULE (the handshake and the Rules tab) says it — clock in the moment you start, clock out the moment the work is finished in the same answer that reports it, a commit waiting for a deploy window is a finished shift (deployed:false), stopping with nothing running means off shift, waiting for a person is not a shift, more work later is a new clock-in, never leave one open, never close another session's; the clock_in / clock_out descriptions carry the same line. (2) The card gains deployed (kept by cardFrom, in the schema). (3) TTL_MIN.ai 240 → 60: a forgotten AI shift shows UNCLOSED after an hour, not four (every connector call heartbeats a working session; a stale shift never blocks and its own heartbeat revives it). (4) appolis_clock_in answers with also_open — the account's other open shifts (id · label · lanes · intent · opened_at · yours) and a note that a finished one is clocked out by its own session. Pinned: test/shift-one-piece.mjs. Filed on mission-control: Kosmos's copy of the house rule and the project page's shift strip (one line + a pop-up, shift lines out of the changelog stream).)appolis_shifts is bounded, and the built-in rules show on the Rules tab. MEASURED from the live registry the same morning: the Appolis session's shift sh_5c9eb51e2f was CLOSED at 07:34Z by a sibling session (how: clock-out, a card the sibling wrote) and its heartbeat_at had frozen at 06:40Z while the session worked — on the desktop app every chat shares ONE connector, so the v0.36.3 session id names the connector, not the chat (two sessions carried ms_38ba1… at once), and a shift opened under the connector's previous id fell out of the sid pool. Plus todo_3906 (lanesFrom dropped project when repo was given; the clock line went to a project named after the folder), a sibling's "lanes array refused" (the bridge delivers arrays as one-character items), todo_3837 (card as a JSON string threw) and todo_3833 (appolis_shifts = 60,931 chars). NOW: (1) heartbeatTargets keeps alive EVERY open shift of the account that holds an edit lane, each on its own throttle; a watch-only shift is presence and expires on its TTL; sid stays for labels and the default shift. (2) appolis_clock_out {id} on a shift opened under a different session id → NOT_YOURS unless force:true; WHICH_SHIFT rows carry label + yours. (3) lanesFrom: repo + project compose both lanes; one-char arrays re-join; commas split; REPO_PROJECT gains flip-my-life-theme → flip-hq. (4) logProject(lanes): the first non-repo lane's board, else the repo's mapped board, else the caller's project. (5) label prefixes the session line (<label> · <sid8>); the clock_in description asks for it. (6) appolis_shifts: slim rows with journal_count and yours; {id} returns one shift with its last 50 entries; journal() caps at 100; cardFrom parses a string card. (7) GET /id/admin/rules returns house: [SHIFT_RULE, HOUSE_RULES] and the Rules tab renders them read-only as folding cards above the owner's rules. Pinned: test/shifts-own.mjs; heartbeat-caller.mjs's two (a)-assertions rewritten to the new rule. Kosmos-lane follow-up: the implicit shift a bare log_work mints should say "implicit — <board> activity", not echo the line that spawned it.)kind (complied | not-applicable), proof (an https link) and reason; a directive carries proof: 'visual'|'none' (a label like the template, never material); attest() refuses a complied claim on a visual directive without an https proof, refuses a non-https proof, and requires a reason for not-applicable (which then needs no changed/verified/proof); proofOf() derives the link for records from before this version (first https URL in the evidence — the record is never rewritten); submissions(doc) lists every claim newest first with the directive and the row's stateFor; board() rows carry the proof; openFor/directivesText tell an app when a directive NEEDS VISUAL PROOF. (2) the connector asks for it and posts no form — appolis_compliance_attest gains proof, kind, reason (required shrinks to directive · app · version; the lib enforces the rest per kind); the handler no longer posts a Kosmos decision form — its answer names the queue; applyFormAnswers still drains the forms that already exist; GET /id/admin/compliance returns submissions. (3) four sub-tabs: Submissions (stat tiles; AWAITING YOUR REVIEW open, ACCEPTED / REJECTED / RECALLED BY A REVISION folded; a row = app · directive · kind · version · date · 👀 See it · Accept/Reject; tap to expand changed/verified/evidence/reason/owner note) · Directives (Open/Closed/All chips, ONE "+ Issue a directive" button opening the form as an on-demand overlay — never a hidden .overlay, the scroll-lock is derived from presence; each card = chips + a progress bar + one dot per app + "▸ Read the full requirement (N criteria)" as a toggle over the body, criteria and per-app rows; the 🏷 template chip is a BUTTON opening the template panel — the reference app's row, its 👀 proof, changed/verified; a 👀 needs-proof chip; Edit / Close as before, the edit and issue forms gain the 👀 checkbox) · Suggestions (the 📥 box) · Surfaces (🧩 + a 🏷 Templates strip of clickable chips). complianceSection() keeps its signature and reads the sub from the shell route; with no sub it renders every block, so compliance-panel.mjs passes unchanged. Pinned: test/submissions.mjs (lib refusals · proofOf · submissions() · board/openFor/directivesText · the shell subs · the tool schema · a handler with no post_decision_form · the admin read · the panel markup rendered through the same harness · versions). Kept for later: proposals and suggestions still post their forms (B1 scope was attestations); B2–B5 (Rules · City · Identity · Mail) follow as their own blueprints.)mineFor(doc, account).find(open) — the account's OLDEST open shift — so with parallel seats (v0.36.1) every other session of the account went stale while working (sh_19962215d4: ~8 forwarded calls, heartbeat_at never moved; sh_0edb019229 the same), and a stale shift is takeable without force — a live session could be evicted, and the owner's Q4 pick (AI 2h) would have made that daily. ROOT: the hub could not tell the account's connector sessions apart (same token, same seat, who.label = 'connector · ' + a name connectors never had — blank on every board line). NOW: (1) initialize answers with the streamable-HTTP Mcp-Session-Id header (ms_ + 24 hex; a client that already sent one keeps it) and the hub reads it back on every request as sid (sanitised, never used for auth); (2) who for the five clock tools carries sid when present and the label is connector · <sid8>, or connector · <email> — never blank; (3) defaultShiftFor(doc, account, sid) prefers the caller's own session's shift — one → it, several → WHICH_SHIFT (whose list now shows each shift's sid), none → the old exactly-one rule — for clock_out and appolis_heartbeat; (4) heartbeatTargets(doc, {account, sid, now}) picks the OPEN shifts to keep alive — the caller's by sid when any carry it, else EVERY open shift of the account (the floor for a client that never echoes the header) — each throttled on its own heartbeat_at, and the new heartbeat_many op bumps them in ONE store write, skipping an id that closed in between (a heartbeat must never fail a tool call). Untouched: open(), collisions, TTLs, stale, take, handoff; a shift opened before sids existed behaves exactly as before. Pinned: test/heartbeat-caller.mjs (lib behaviour + the wiring — rpc headers, the header on initialize, both defaultShiftFor(…, sid) call sites, the fan-out block, versions at-or-past). NOT done here: Q1/Q4 tunables (note_3788, safe to flip now) · the implicit shift a clock-out line spawns.)lib/email.js: (1) refreshDomains kept ONLY the suite apex and its subdomains — on purpose, the Resend team also holds the fraud bot's domains — so a person's domain was dropped from the stored list on every refresh and domainStatus could never see it verified: the "People who may send" door was closed from the inside, for every person, from the day it shipped; (2) addSendingDomain treated Resend's duplicate answer as a failure and threw the vendor's sentence at the owner, when the domain already being there is the GOOD case; (3) nothing ever called Resend's /domains/{id}/verify — a pasted DNS set sits at not_started until someone presses Verify in Resend's own dashboard, so step 4 of the runbook ("press Check domains") would have spun forever. NOW: the stored list keeps every allowed sender's domain and every name the owner added or adopted through the panel (extra, stored beside the list with Resend's ids, carried across every refresh, a failed one included); a duplicate answer makes the panel LOOK THE DOMAIN UP in the team's list and hand back its records and status (adopted:true), and only a domain the key genuinely cannot see is refused — in plain words, naming the other-team case and the two ways out; 🔄 Check domains now refreshes, asks Resend to verify every listed domain that is not yet verified (≤ 10, never throws), lists once more and says which ones it asked about; the panel prints "Resend already holds <domain>" above the records and re-reads the people rows, so Scott's row moves from "not a sending domain" to the domain's real status; a row that is not a sending domain now points at the button that fixes it. 🧪 test/email-domains-adopt.mjs pinned (23 ✅): the kept list · the carried extras + ids · adopt on duplicate · the other-team refusal · other errors verbatim with no lookup · verify for the unverified only, never throwing · source pins. Suite 20 files / 820 ✅. ⚠️ NOT proven live from this session — the panel sits behind the master cookie; the runbook (note_3259, refreshed) walks Tyler through the same five steps and the served checks prove the code and the version only.)appolis_clock_in {repo:"appolis"} was refused SEAT_TAKEN against a watch-mode implicit shift on kosmos/flip-cms — a different lane, a different repo, presence not a claim — and the only ways out offered were evicting a live sibling or "narrowing" lanes that did not touch. THE DEFECT: open() looked for ANY open shift of the same account and kind and called that a seat collision. THE RULE NOW, precisely: two seats of one account collide only where their edit lanes collide (collides, which already ignores watch on either side); disjoint lanes are two shifts, one account, so Tyler's ≤3 parallel sessions each hold their own lane; a different kind inherits only on a colliding lane and opens its own shift otherwise; SEAT_TAKEN names the held LANES, not one lane, and do offers take:true only for your OWN shift. todo_3668 — the per-account read: GET /id/shifts?app=<slug>&account=<id> answers { held (excluding that account), mine (trimmed), stale, seq, unknown:false } and every held row carries who.account, so the Kosmos web door can tell a person's own AI from a stranger — until this it failed open with a warning and the strip could not show End shift. todo_3680 — the bridge: the Claude Code connector bridge splits array parameters into characters (the same trap Kosmos closed for post_decision_form in v8.90.0), so every appolis_clock_out was THIN_CARD and sessions' shifts went stale at TTL instead of closing; cardFrom now takes each of changed · verified · assumed · open as an array OR one entry per line, inside card or at the top level, and preserves absence so the thin-card rule can still name what is missing; lanesFrom composes lane / lanes / repo / project the same way and refuses a whole-app claim in the rule's own words ("a lane needs the project, the area, note, to-do or repo you are about to touch"); with several shifts and no id, clock_out and heartbeat answer WHICH_SHIFT with the list rather than picking one. todo_3642 (1)(2): getShiftsDoc() marks a failed registry read unknown instead of pretending it is empty, mintEnvelope signs shifts_unknown (Kosmos's connector door already fails CLOSED on it), the handshake says the registry could not be read, and both POST routes accept {op, args} or the fields at the top level — one shape rule, both doors. 🧪 The two pinned suites were UPDATED by the orchestrator before the code and proven to discriminate on the old tree: test/shifts.mjs +13 (disjoint lanes coexist · a colliding lane is still SEAT_TAKEN with the lanes named · a watch shift never blocks · inherit only on collision · defaultShiftFor · cardFrom · lanesFrom · who.account) and test/shifts-wire.mjs +9 (shifts_unknown · the unknown handshake · lanesFrom/cardFrom/defaultShiftFor reached · WHICH_SHIFT · the account read · b.args on both routes · the description's "per line" · 0.36.1); test/shifts-do.mjs unchanged, the race still holds. ✅ VERIFIED LIVE: this session's own appolis_clock_in {repo:"appolis"} — refused before the fix — opens beside the Hermes session's implicit shift after it; and its appolis_clock_out through the same bridge that produced THIN_CARD closes with a full card.)lib/shifts.js holds the pure ops — open · heartbeat · journal · close · handoff · take · force_close · expire — over one record {who, lanes[], intent, opened_at, heartbeat_at, ttl_min, state, journal[], card, handoff, released}; lanes nest by SEGMENT (kosmos/proj_x covers kosmos/proj_x/docs; docs and board are siblings and never collide; proj_x never touches proj_xy), and a watch lane collides with nothing. lib/shifts-do.mjs is a second Durable Object, ShiftRegistry (binding SHIFT_DO, migration v2), line for line the compliance store's shape — one request at a time, so two clock-ins on one lane in the same moment land one after the other and the second is refused; every write mirrors to KV id:shifts in the one slim shape; it starts EMPTY and never seeds. THE RULES THE TESTS PROTECT: a colliding open is refused LANE_HELD naming the holder, their intent, and the exact appolis_clock_in / appolis_handoff call that would succeed · a person's own AI INHERITS the person's shift (same id, inherited:true) and is never refused against them · two seats of the SAME kind on one account (two Claude Code sessions — the 2026-08-19 failure) collide like strangers, SEAT_TAKEN, unless the second says take:true, which records the first as taken · past heartbeat_at + ttl (AI 4 h, person 10 h, cap 12 h) a shift goes STALE: it stops blocking, is named in every handshake as UNCLOSED, and only its own account's heartbeat revives it or a person closes it with a reason · a thin AI card is refused by the names of the missing keys (changed · verified · assumed · open; could_not_finish:true is an honest close) while a person's card is never refused · hand-off marks the shift HANDED, the receiver's own open within those lanes releases it as handoff, and everyone else still collides · take is allowed on a stale shift, on your own from another seat, or by force — and the old record always says who and how. FIVE NATIVE TOOLS on the one connector, intercepted before the fan-out like the compliance tools: appolis_clock_in (lanes composed from project/area/note/todo/repo, or given; the canonical lanes echoed back), appolis_clock_out (the card), appolis_heartbeat, appolis_handoff, appolis_shifts; clock-in and clock-out each forward ONE log_work line to the lane's Kosmos project so the 🤖 Cowork changelog and the 72-hour concurrent_activity scan keep working unchanged — the old habit is absorbed, not replaced — and a line that could not be posted is said in the result. EVERY ENVELOPE the hub signs now carries shifts (the caller's own claims) and held (everyone else's open or stale claims on that app, ≤50) — a room trusts only what the hub signed, so Blueprint 2 can refuse a write inside a held lane without a registry of its own; a holder's heartbeat rides every forwarded tool call, throttled to one write per five minutes. THE HANDSHAKE gains a 🕐 SHIFTS block: YOU ARE ON SHIFT … (inherit it, do not open a second) · HELD by others · UNCLOSED (a stale shift named as a dirty tree) · a HAND-OFF waiting for you. THE RULE: HOUSE_RULES loses the old 🕐 PUNCH A TIME CARD paragraph and the initialize instructions carry SHIFT_RULE, the version-B text, in its place — one time card, not two. THE DOORS: GET /id/shifts?app= and POST /id/shifts (x-id-internal for the rooms — this is how a person's two taps in Kosmos will land — or the master cookie), GET/POST /id/admin/shifts and a 🕐 Shifts department beside 📜 Standing rules: on shift · unclosed · recent, with force-close through the styled prompt. 🧪 THREE PINNED SUITES, written before the code and proven to fail on the old tree: test/shifts.mjs (the pure ops), test/shifts-do.mjs (the race: two opens at once, exactly one wins; the mirror; refusals are refusals), test/shifts-wire.mjs (the binding, the migration, the export, the five tools, the intercept before the fan-out, shifts/held in the envelope, the handshake, ONE rule, the routes, the board, both version constants). ⚠️ NOT YET REFUSED ANYWHERE: this half only registers, signs and tells. The refusal of a write inside a held lane happens in Kosmos (Blueprint 2, its own lane), the person's two-tap clock-in lands through POST /id/shifts from Kosmos, and an unclaimed lane is warned, not refused, until every scheduled session is taught — door before callers. A repo/ lane is a sign: the hub cannot see a folder.)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.GET /api/sandbox/envs (admin cookie or publish key) — the live sandbox environments and the candidates in review with their preview links; the nine appolis_sandbox_* connector tools; the preview host appolis-sandbox (sandbox/wrangler.jsonc, binds APPOLIS_SBX + LOADER only) at https://sbx.appolis.app/p/<cand>/ (static) and https://sbx-1.appolis.app/ … https://sbx-10.appolis.app/ (live, Dynamic Workers).Since v0.74.0 (AKADEMEIA P1, DESIGN .blueprints/akademeia-p1-20261031, owner's plan note_8838, runbook form note_8850). A builder publishes their OWN Workshop candidate to the test domain akademeia.app — a separate registrable domain, so the appolis.app cookie and reputation stay out of reach.
appolis_app_publish {verb publish|list|wake|unpublish, candidate, app} — in SANDBOX_PASS (a builder's own app stays inside the sandbox by the owner's law-8 reading); a Claude seat may call it too. The owner part of the hostname is ALWAYS the seat's account (acc_ stripped), never an argument; only cand.account === a.id (no for) publishes. lib/grant.js files it in the sandbox family (every member reaches it).<app>--<owner>.akademeia.app; app slug 3–32 chars ^a-z0-9$, never --, not reserved (www api app apps mail admin appolis akademeia dispatch sbx static cdn _psl xn). One label because the Universal SSL certificate covers one level.index.html required · ≤ 5 MiB · sandbox.scanText / magicOk), wrapped in a hub-generated shell module worker.mjs (no fetch, no eval, no bindings; headers x-akademeia-app, nosniff, noindex, no-store on HTML) and uploaded by PUT …/workers/dispatch/namespaces/builders/scripts/<script> (multipart, tags owner:<acc> app:<slug> cand:<id> appolis:v0.74.0). A non-2xx is CF_UPLOAD_FAILED with the status and the API's message; the registry is written only after a 2xx.CF_BUILDERS_TOKEN (store 7ec74ddb…); missing or unreadable → PUBLISH_NOT_CONFIGURED. Never logged, stored or answered.APP_CAP names the cap and the hosts). An asleep app does not count.akademeiaSweep (cron 17 4 * → scheduled(), and POST /api/akademeia/sweep) puts a live app asleep when max(published_at, last visit) is older than 30 days; registry only (the script stays uploaded). wake brings it back under the cap, no upload. A store error throws (cron marked failed; the door answers 503 SWEEP_FAILED).appolis_app_kill {app | owner, verb kill|restore, reason} — the owner's Claude seat only (SANDBOX_ONLY / OWNER_ONLY otherwise); the Workshop panel's ✕ Kill / ✕ Kill all post POST /api/akademeia/kill (same body, akademeiaKill). The registry row flips FIRST, then Cloudflare (per script, or DELETE …/scripts?tags=owner:<acc>:yes); a Cloudflare failure is said (CF_DELETE_FAILED, edge: killed). restore is registry-only (killed → asleep).AKADEMEIA (6abfdf40…) — app:<script> · owner:<acc> · sweep:last written by the hub; visit:<script> written only by the dispatcher. GET /api/akademeia/apps (admin cookie) feeds the panel.akademeia-dispatch (akademeia/worker.js, akademeia/wrangler.jsonc; binds BUILDERS + AKADEMEIA only) routes *.akademeia.app by hostname: live → BUILDERS.get(script, {}, {limits: {cpuMs: 50, subRequests: 2}}); asleep 503 · killed 410 · gone/unknown 404 · missing script 502 missing · user-Worker failure 502 error (logged). Touches visit:<script> at most hourly.test/akademeia-publish-0740.mjs (16) · test/akademeia-dispatch-0740.mjs (7)..blueprints/akademeia-p15-20261031, owner's picks form note_8872): a candidate with a root worker.js publishes in server mode (row.mode / the answer's mode = server; otherwise static, byte-for-byte P1). checkServerBundle: modules = text files outside public/ ending .js/.mjs/.cjs/.json, scanned by sandbox.scanBundle (no sockets, no eval, no Function, no non-literal dynamic import); statics = everything under public/; binaries only under public/; __akademeia… names reserved; other root files are ignored (not uploaded). buildServerShell: main __akademeia_shell.mjs serves GET/HEAD of public/* from __akademeia_static/ and hands the rest to the builder's default fetch with Object.freeze({}) as env — no bindings, no storage, no secrets.akademeia-outbound (akademeia/outbound/, no bindings, no routes, workers_dev: false) is the builders namespace's outbound Worker — every fetch() an app makes goes through it, and the platform disables connect() once it is set. outboundVerdict refuses non-http(s), ports other than 80/443, user:pass@, IP literals, local names and appolis.app / akademeia.app / appolis.workers.dev / flipmylife.workers.dev (403 OUTBOUND_REFUSED, one log line naming the app); allowed calls go out with x-akademeia-app and redirect: manual, so each redirect hop meets the fence again.__Host- cookies in both directions (Cookie in, Set-Cookie out); a __Host- cookie is host-only by the browser's own rule, so no app reads another's. Dispatch limits are now {cpuMs: 50, subRequests: 10} with outbound: {script}. Deploy order: akademeia-outbound, then akademeia-dispatch, then the hub.test/akademeia-server-0750.mjs (9) · test/akademeia-outbound-0750.mjs (13).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).