Kosmos (Greek: order out of chaos) — the Appolis command center. Hosted at kosmos.appolis.app (password-gated), runs locally asnode server.js. On-disk folder is stillmission-control/.
Single source of truth for the app. This file is updated every time the app changes.
The server also renders a live version of this doc from real config + data at /breakdown — stage edits there write back into the app.
node server.js → http://localhost:3900 (also in .claude/launch.json as mission-control)assets/features.html is the SOURCE (it carries a __VERSION__ placeholder); public/assets/features.html is a STAMPED ARTIFACT, and that is the one docs-hub/build.js:61 copies to the published page. So a changelog row written into the source alone deploys cleanly, verifies as present in your file, and never appears on docs.appolis.app — this bit v8.76.0 and would have shipped a silently stale deck. Write the row into assets/features.html, then run node scripts/build-deck.js BEFORE docs-hub/build.js. (The breakdown has no such trap: build.js renders APP_BREAKDOWN.md itself.)ID_SECRET moves from vars to a Wrangler secret, same value. No version bump, same rationale as the 08-19 entry below: nothing observable changed, only where the credential lives.) Why this one was safe to move despite the "deliberately stays in vars" note below: the weekly Appolis tripwire reads the value from appolis/wrangler.jsonc specifically (the command in its scheduled-task SKILL.md does cd "F:\Claude Code\appolis" before the grep) — only the APPOLIS copy needs to stay readable, and it does. Kosmos's copy was the worst of the six: the only one both git-tracked (value in history) and echoed into the terminal by every wrangler deploy. The weekly session's check 5 counts a config leaving vars as rotation PROGRESS, so this is measured, not contradicted. The other four plaintext copies (appolis, agora, phantasia, hermes) stay until runbook Step 2 deletes the credential outright.
The window was ~2 seconds (12:35:53 → 12:35:55 ET): strip + deploy, then secret put chained in the same shell command. Done midday but only after a 120s wrangler tail showed the worker idle (11 events, all BoardDoc loads + two /api/db polls — zero hub/lander/SSO traffic), which is the "off-hours" condition measured rather than assumed. A five-repo consumer audit ran first: kosmos reads env.ID_SECRET at 16 sites (SSO resolve, hub + Free Will sign-in/join, 8 outbound x-id-internal calls, 3 inbound /internal/ doors, the nightly Hermes revenue cron) and every one fails CLOSED and self-heals — the appolis-side connector hub keeps sending the shared value (its own binding is untouched), so kosmos_ connector tools were the loudest casualty for those ~2s.
⚠️ CF API error 10013 on assets-upload-session is PRE-MUTATION and retry-safe. The first two deploy attempts failed with "An unknown error has occurred [code: 10013]" during a Cloudflare minor-outage window; no version is created when that call fails, so the old deployment (var intact) kept serving and the retry ~2 min later went clean. Do not read 10013 as a half-applied deploy.
Verified after, against the SERVED app: /api/version 8.53.0 · hub sign-in probe with bogus creds answers 401 "that sign-in didn't work" and not 503 "sign-in is not available", on studio7, brody, brooke and the /h/{slug} path form — 503 is the missing-binding shape, so 401 proves the binding is present and appolis was reached (kosmos→appolis leg) · Free Will /api/join answers 400 (validation) not 503, a second independent binding gate on another hostname · all 7 live landers serve, both unpublished drafts still 404, and every branch of the invoice /pay route matches source — so the change did not reach further than intended · freewill 200.
⚠️ AND THE VALUE IS PROVEN CORRECT, not merely present. This is the part that took real work, because almost nothing can tell those two apart. A typo in wrangler secret put is the ONE failure this migration could plausibly introduce, and every public door hides it: lib/hubs.js:813 maps any non-ok Appolis reply to the same 401 as a wrong password, and appolis's callerApp() makes a mismatched secret simply 404 the route. So "hub sign-in returns 401" proves PRESENT + REACHABLE and nothing about the value. Four independent verification passes all stopped exactly there.
What actually settles it is the revenue-sync path, because it is the only one with NO second key. Everywhere else is dual-key and therefore mute: hubOk() (worker.js:537) admits APPOLIS_APP_KEY or ID_SECRET, so a working connector call cannot isolate which one opened the door. But worker.js:1953-1994 (and the lander_revenue_sync MCP tool at lib/mcp.js:1230) send 'x-id-internal': env.ID_SECRET bare to Hermes, and Hermes gates on its own untouched vars copy, answering 404 on any mismatch. Run post-cutover: both steps returned HTTP 200 (orders 5855, abandoned 351) and the receipt advanced from 2026-08-20T07:20:30.527Z to 17:35:37.122Z. A wrong value could not have produced a 200 there. The stored secret equals the shared suite value, measured end-to-end.
⚠️ AND THAT SAME PATH IS THE ONE THAT WOULD HAVE FAILED SILENTLY — worth knowing for the next rotation. worker.js:3538-3541 does if (!orders || !orders.ok) return;: a failed nightly pull writes nothing, logs nothing, throws nothing, and leaves the previous receipt untouched, so lander_revenue_status keeps answering "synced": true with a complete, plausible receipt while revenue quietly freezes. That is deliberately right for data integrity (never overwrite good days with zeros) and exactly wrong for detectability. Running the sync by hand immediately after the cutover — rather than waiting for 07:20Z — is what turned a 15-hour silent-failure window into a measurement.
Note npx wrangler dev now has no local ID_SECRET — add a line to the gitignored .dev.vars if local SSO flows are ever needed.
STUDIO_KEY and VAPID_PRIVATE_JWK move from vars to Wrangler secrets. No version bump, deliberately: nothing a person or another app can observe changed — only where the credentials live. If a later release adopts this commit into its number, that is fine; it is recorded here so the record is not silent either way.) Move, not change — same values, so nothing behaves differently. This matters most here because wrangler.jsonc is git-tracked in this repo, so both credentials sat in local history as plaintext; they are now in the secret store and readable by nobody, including future sessions. (Hermes and Phantasia gitignore their configs, so theirs were on disk only — Kosmos was the exposed one.)
⚠️ THE RUNBOOK'S ORDER IS IMPOSSIBLE, and this is the useful part. ROTATION_RUNBOOK.md Step 0 says "wrangler secret put with the existing value, then delete the line from vars and redeploy". You cannot — the Cloudflare API refuses a secret whose name is already a plaintext vars binding: "Binding name 'STUDIO_KEY' already in use" [code: 10053]. The var must be stripped and deployed first, which opens a brief window where the binding is undefined. It fails CLOSED (comparisons against undefined deny), so it is unavailability, never exposure — but it is real, so it was done as one window per repo rather than one per key, off-hours, and the values were backed up out-of-repo first because stripping the config destroys the only copy you have.
⚠️ VAPID_PRIVATE_JWK IS A JSON WEB KEY — it contains commas and quotes. It was extracted with an escape-aware parser; the obvious grep -oE '"KEY"[^,]*' used elsewhere in this suite truncates it at the first comma and would have stored a corrupt key that looks fine until a push is actually sent, which is the kind of failure nobody notices for weeks.
ID_SECRET deliberately stays in vars in all five configs: it is the only credential the weekly Appolis tripwires can use, and no session can read a Wrangler secret. (Superseded for KOSMOS on 2026-08-20 — see the entry above: the tripwire reads the APPOLIS copy, so only that one must stay readable; kosmos's moved to a secret.) Verified after: kosmos 200, /api/version 8.35.0, the signup probe still 401, whole suite 200.
kpush_…, minted in the profile panel beside the connector link, downloadable as a one-line key file the owner drops into his user folder) authenticates two note-scoped routes — GET /push/<key>/notes/<id>/sha returns the body's 16-hex sha (sha256 of the trimmed text, the pipeline's own hash) without downloading it, and PUT /push/<key>/notes/<id> with If-Match: <sha> replaces the body compare-and-swap: a stale sha is a 412 with the live one and nothing written; vault notes, checklists, empty and over-1MB bodies are refused by name; the text is stored exactly as sent and stamps editedAt and pushedAt. The write goes through the board's Durable Object like every other write, so the warm copy sees it at once. The rule is a pure module (lib/notepush.js) with a pinned test (test/note-push.mjs: the sha, the shapes, compare-and-swap, the limits, idempotence); the worker wiring is three thin routes copied from the /mcp/<token> and /api/profile/mcp-token blocks. The client is ~/.claude/skills/blueprint/mirror-push.mjs: head → refuse if the plan's recorded sha is not the live one → put with If-Match → compare the returned sha with the file's → MIRROR MATCH sha=…. First live proof: the same HEPHAESTUS-DESIGN.md a sonnet pushed that afternoon for 262k tokens, pushed for 0, byte-identical. Tests: node test/note-push.mjs → ok/FAIL 0; the whole suite green. Blueprint v2 (note_3530): design + pinned test by the orchestrator, a haiku builder in a staging copy taken from HEAD while another session held the lane, a haiku executor once the lane was free, the deploy by the parent in a quiet window.)POST /api/profile/push-token was in worker.js all along, but the v8.102.0 builder's spec had dropped its block ONE LINE BELOW the opening brace of the /api/import/attachment route — nested inside it, reachable only when the path was the importer's, which is never — so the request fell through to the 404. The push routes themselves (GET …/sha, PUT) and the profile flag were fine, and a bad key was refused 401, which is why every served check of v8.102.0 and v8.103.1 passed. Now the block sits where its siblings sit, between /api/profile/ics-token and the importer. Pinned by test/push-token-route.mjs (20 checks): a REAL request through the worker with a real session — a nonsense profile route is the 404 Tyler hit, the mint answers 200 with kpush_ + 36 hex and the one-line key file, the registry holds it, GET /api/profile reports the flag and never the key, regenerate replaces it, no session is 401 — plus the block's placement (once, sibling indentation, brace depth zero from the mcp-token route, after ics-token and before the importer, sibling closers, the importer body its own again). No served probe can see this defect from outside — the global /api gate answers 401 to any path without a session before a route is even looked at — so the proof is the pinned test's REAL request through the worker, run in the repo before the deploy, and the owner's own Generate click after it. Lesson, the second of the day for the blueprint skill: a route's acceptance test pins its REACHABILITY with a real request and a real session, never a module test alone, and never an unauthenticated probe behind an auth gate.)genPushToken() — the function that mints the key and offers the kosmos-push.env download — but no markup in the profile panel ever called it. A function with no button; the pinned test covered lib/notepush.js and never the panel. Now the 🔑 Push key panel sits between the AI connector and the Calendar feed, where the other two personal links already live: Generate (or Regenerate when a key exists — GET /api/profile now returns has_push_token; the key itself is only ever returned by the POST that mints it, once), the key shown at that moment, ⬇️ Download key file → kosmos-push.env, and the one line that says where it goes: your user folder, as .kosmos-push.env. Pinned by test/push-key-row.mjs (20 checks): the row is inside viewProfile(), its button calls genPushToken(), it carries #pushkey and #pushkey-dl, it sits after the connector and before the calendar, the GET carries the flag, and the v8.102.0 wait-copy line survives. The lesson goes into the blueprint skill: a new UI action's acceptance test pins its REACHABILITY — the markup that calls it — never only the function.)2.0 forgot.** Tyler, 2026-09-10 18:59Z, on the form that asked him for the two clicks: "I'm not seeing that in the section you said that it is. Please look at the browser and see for yourself." He was right: v8.102.0 shipped genPushToken() — the function that mints the key and offers the kosmos-push.env download — but no markup in the profile panel ever called it. A function with no button; the pinned test covered lib/notepush.js and never the panel. Now the 🔑 Push key panel sits between the AI connector and the Calendar feed, where the other two personal links already live: Generate (or Regenerate when a key exists — GET /api/profile now returns has_push_token; the key itself is only ever returned by the POST that mints it, once), the key shown at that moment, ⬇️ Download key file → kosmos-push.env, and the one line that says where it goes: your user folder, as .kosmos-push.env. Pinned by test/push-key-row.mjs (20 checks): the row is inside viewProfile(), its button calls genPushToken(), it carries #pushkey and #pushkey-dl, it sits after the connector and before the calendar, the GET carries the flag, and the v8.102.0 wait-copy line survives. The lesson goes into the blueprint skill: a new UI action's acceptance test pins its REACHABILITY — the markup that calls it — never only the function.)devices on an edit and folded it on read, and the route passed the body through — only the edit box sent text alone. Now the box shows the same three size chips a new note has (This applies to · Desktop / Tablet / Phone), seeded from the note's own sizes, at least one always on, and Save sends them with the text; the list shows the new sizes at once through the same local hold that already covers the words. Client only. 🧪 test/hub-marks-edit-devices.mjs pins the store fold (mobile-only reads back; a text-only edit keeps the sizes; an unknown size is dropped), the route (the owner's edit with devices answers 200 and the list reads mobile / mine:true) and the served shell (the mk-edev chip row, MKEPICK seeded from the note, Save posting devices, the hold carrying them, one size always kept) — 12 passed. Built by a haiku builder in a hotfix worktree cut at v8.101.0 while master still held the undeployed v8.102.0; the moment v8.102.0 shipped (18:32Z) the code commit was cherry-picked onto master and released as v8.103.0 — nothing was downgraded, nobody's commit was shipped for them.)editMark in the store, POST /api/mark {act:'edit'} behind an author-only gate, the Edit button and inline box in the note dialog — and the button renders only when the note carries mine. GET /api/marks handed the OWNER the raw list so they keep seeing reviewer addresses, and the raw list never carries mine: publicView is what sets it, and the owner never went through it. Measured live on 2026-09-10: 16 marks by the owner, mine on none. A freshly-posted note got mine:true from the client's MKFRESH hold, which is why it "sometimes" worked until a reload. THE FIX: hubmarks.ownerView(list, me) — the owner's own view, which KEEPS by (they invited these people) and sets mine on every mark and reply — and the route uses it for the owner. publicView, the author-only edit gate and the client are untouched. 🧪 test/hub-marks-owner-mine.mjs drives the real handleHub: the owner's own note reads back mine:true, a reviewer's mine:false with its address kept, the owner's reply mine:true, the owner edits their own note (200) and is refused on the reviewer's (403), and an anonymous reader on a public hub still sees no address — 19 passed; reverted against the unfixed route it fails. Built by a haiku builder from the orchestrator's design and pinned test; deployed by the parent in a quiet window.)<meta name="hub-palettes" content='[{"id","label","swatch","default"}…]'>; the injected review layer reads it once, validates (id ^[a-z0-9-]{1,24}$, label ≤ 40, swatch #rrggbb, at most 12, invalid entries dropped, unparseable = empty) and reports the list with EVERY ready post; the document toolbar shows a swatch strip (#docpal, right after Desktop / Tablet / Phone, visible on phones too) only when a document declares two or more; a tap posts {act:'palette', id} into the frame; the layer sets data-palette on <html> ONLY for a declared, non-default id, removes it for the default / empty / unknown, and acks; the shell keeps the choice per document for the visit and re-sends it when the frame says hello, so a device switch keeps the preview. Nothing is persisted, nothing changes for other viewers, and a document that declares nothing sees no change at all. The FLIP 7 landers declare six (black · cocoa · espresso · walnut · chestnut · latte) in their own blueprint, note_3378. 🧪 test/hub-palette.mjs EXECUTES the real injected layer in a stubbed frame: the ready post carries the six ids in order with an invalid seventh dropped; a declared id sets data-palette and acks; the default clears it; an undeclared id — including an injection-shaped string — never sets anything; no meta → [] and a palette message is a no-op; unparseable JSON → []; 40 entries → 12; and the shell carries #docpal after #docdev, palBtns, act:'palette', PAL, d.palettes, with #docpal kept out of the phone-hide rule — 15 passed. The ten hub files 545 ok / 0 FAIL; 78 files, every one exits 0 in the repo. Built by a haiku builder from the orchestrator's design in a staging copy (round 1 green — the two "failures" it reported were the orchestrator's own strict deepEqual rejecting arrays born inside the vm sandbox; the TEST was fixed, not the layer), run by a haiku executor, deployed by script in a quiet window. NOT done, deliberately: persisting a palette; applying it for other viewers; choosing the colour — John and Tyler flip through the six live.)mk_ufdqnw2e ("test") was still on the server eleven minutes later, resolved:false. Driving the same delete signed in as owner found THREE defects behind one symptom. (1) The armed Delete disarmed itself after four seconds — two taps are required and a re-arm is indistinguishable from a confirm, so tap → read the new label → think → tap again re-armed it while the note sat there. That is what ate his note. (2) A delete that DOES land is served back for another minute or two. The tombstone was a BRAND NEW KV key and the list index takes up to a minute to show one, so every read in between folded the note straight back in. Measured live: POST → 200, the very next read still carried the note, gone at ~2.5 minutes. v8.98.2 had papered over this with a five-minute hold in the browser — which fixes it for the one tab that did the deleting and for nobody else: a second reviewer, a phone, or a reload after the hold expired all saw a note deleted minutes ago. (3) mkTell dropped the pin push silently whenever the frame had not said hello, so the shell could update while the document went on showing the note, with nothing anywhere saying why. THE FIX, IN THE STORE: deleteMark now marks the mark's OWN entry deleted — a key the list index already holds, so it is visible on the very next read with no index to wait for — as well as appending the tombstone, which stays because it is the audit trail and it is what makes a delete replayable. listMarks never seeds a tombstoned entry (older deletes still fold through the tombstone row, so nothing needs migrating). THE FIX, IN THE SHELL: Delete stays armed as long as its modal is open; mkTell holds the latest marks push and flushes it when the frame answers; and an MKRES hold sits beside the existing edit and robot holds so a stale read cannot bounce a note back across the tabs. THE FEATURE: the notes panel gets an Open (n) / Done (n) tab strip in the Reviewers panel's own pill idiom, and every row carries a one-tap ✓ Done / ↩ Reopen — no drilling into a note to file it. Done IS archived: mkVisible stops sending done notes to the frame so they have no pin, the 🗒 badge counts open only, and countsFor counts open marks so a tile cannot go on reading 12 after all twelve are done. Nothing is deleted to be archived — a done note keeps its whole reply thread and is still returned by listMarks for good, which is the ledger he asked for; Delete stays a separate, deliberate, two-tap act for things that were never feedback. ⚠️ One consequence, deliberate: every mark on the FLIP 7 hub is already resolved, so on deploy they all move to Done, the three landers lose their pins and the tiles read 0. 🧪 test/hubmarks.mjs 60 → 65, and the four new assertions are load-bearing: reverted against the unfixed store they fail 4/4, including the note stays deleted even when the tombstone is not in the index yet, which drops the tombstone key out of the KV double's index and demands the delete hold anyway. The ten hub files run 545 ok / 0 FAIL; 77 files, every one exits 0. Both tabs were rendered through hub-shell-runs' own shell harness before deploy — Open shows one row with ✓ Done and hides the done note, Done shows one row with ↩ Reopen, the badge reads (1), and only the open note is pushed to the page as a pin, with zero shell errors. ⚠️ That harness parses id-bearing elements only, so the row markup was asserted as the written HTML string; the behavioural proof is the real pass through the deployed hub.)rawTagTest: true on a document that begins <div style=…. The check asked "does a raw <tag appear ANYWHERE?" (/<[a-z][sS]*>/), and a single stray raw bracket in an otherwise fully escaped document answered yes — so the decoder bowed out and every tag was shown as text. It now decides by which form dominates: count <tag against <tag and decode only when the escaped form outnumbers the raw one. Fully escaped decodes, real markup is left alone, prose containing 3 < 4 is left alone, and one stray bracket no longer flips a whole document. Those four cases are EXECUTED in the test rather than asserted about. Suite 3718 across 77 files, 0 failures; ratchet 0; preview-page.mjs (51). ⚠️ Also learned, and worth more than the fix: Kosmos registers a SERVICE WORKER (public/sw.js, app.js:7401), so a redeployed static page can keep serving the OLD copy to a browser that already has it — a cache-busting query does not necessarily defeat it. The served bytes were right the whole time; two rounds of debugging went into a page the browser was not showing. Clear caches and unregister the worker before concluding anything about a static page.)/preview.html, and the NOW panel rendered <div style="font:14px system-ui… as literal text. The markup had arrived ENTITY-ESCAPED, and the page only recognised raw < as markup, so it fell through to its plain-text branch and displayed the tags. My own ask was malformed — but a session will get this wrong too, and "show me the actual thing" is the entire point of F8, so a stray escape must not defeat it. panel() now accepts both: raw markup renders as-is, markup that was escaped on its way here is decoded first, and genuinely plain text still renders as plain text. Still sandboxed, still no-referrer — decoding changes what is DISPLAYED, never what is allowed to run. Suite 3714 across 77 files, 0 failures; ratchet 0; preview-page.mjs (47).)PATCH → 200, picks non-empty). F8 was not, and F8 is new at rev 2, issued today from his own words: "a DESIGN OR FUNCTION ASK IS A THING HE LOOKS AT, NOT A DESCRIPTION… the ask links a live page showing the ACTUAL proposed thing — what it is now beside what it would be, rendered in the app's own styling — and the yes/no sits on that page as well as on the form. 'Here is what it would look like' in prose does not pass." Appolis had public/preview.html; Kosmos had progress.html and the lander pages and nothing of the kind — so every design ask Kosmos made still failed it. Including, pointedly, the one it made an hour earlier: the new form styling was put to him as prose and screenshots in chat, which is precisely what F8 rejects. WHAT SHIPPED. public/preview.html — ?form=<noteId> — takes three things from Appolis's look-first page and does a fourth differently. Taken: the decision sits at the END of what you are reading and is never sticky (a sticky bar floats over the very panels you are meant to look at); the buttons are coloured by meaning, mint yes and coral no; and the bar says what pressing it does. Different: the questions on this page are the same .qq cards and .hubchip.qopt chips the Inbox renders, straight out of style.css — not a second look at the same act — and the answer writes the same listItems back to the same note, so the Inbox and this page are two doors onto ONE answer rather than two answers. That is what "never trapped behind one surface" has to mean to be worth anything. The v8.97.x question styles were unscoped from .modal so both surfaces take them from one place. THE REST OF THE WIRING. post_decision_form gains preview_now / preview_proposed / preview_label (bounded at 20k each), attaches them to the note and hands back previewUrl; the form modal shows a 👀 See it link when a preview exists; and the connector's own description now tells a session that a description does not pass. Session-authored markup is rendered in a sandboxed, no-referrer iframe — it can be looked at and can never run — and the page says so where the reader can see it. A BOUNDED READ, deliberately: rendering one preview must not pull the whole board, so GET /api/notes/:id was added rather than reusing /api/db — the waste cmp_18c8b53201 T5 names, avoided at the moment it would have been created. The page also carries the centred wait that names the work with a live elapsed time and resolves into the outcome rather than vanishing (cmp_progress0001 c2 and c12), reads back what the server actually stored, and on a failure keeps your answers on the page and says so. 🧪 test/preview-page.mjs (45) pins the contract: now beside proposed, sandboxed and escaped, the shared vocabulary rather than a second look, the same whole-array write with the Q<n> prefix kept and the same ▶/🛑 rules, the empty send refused, the centred wait ending visibly, and the connector/API wiring. It says plainly in its own header what it can and cannot prove — the behavioural proof is a real pass through the deployed page. Suite 3712 across 77 files, 0 failures; ratchet 0.)dfIntro(n) — and on a legacy form the body IS the question, so there were no Q<n>. lines for dfIntro to strip and the same sentence appeared twice, once grey and once bold. Exactly the duplication v8.97.0 set out to remove, reintroduced at the other end. The preamble is now rendered for GROUPED forms only; a legacy question lives in its card heading and nowhere else. The suite could not see it — every assertion was about dfItemsHtml, and the duplication happened in openDecisionForm one level up — so the new assertions pin BOTH halves: that the preamble is gated on grouped, and that a legacy question appears exactly once in the rendered card. Suite 3662 across 76 files, 0 failures; ratchet 0; decision-form-picks.mjs (118).)dfItemsHtml now renders exactly what openTodoAnswerForm has rendered since v8.90.0: one .qq card per question, a mint .qq-n badge, choices as .hubchip.qopt chips, and the question's own .qq-in words box. A LEGACY form becomes a single card headed by its question. dfChoose() replaces the checkbox handler: single-select clears its siblings, a question marked "(pick any that apply)" does not and is labelled as such, and the chips restyle IN PLACE rather than re-rendering the modal — which would have thrown away whatever was typed in the boxes. State still lives in FORM_PICKS by index, so dfApply is untouched and everything v8.94.0–v8.96.1 fixed still holds. dfIntro() strips the question lines from the preamble so nothing is said twice. The shared "Anything else" box is now rendered only for GROUPED forms — a legacy form's card carries its own, wired to FORM_OWN so dfApply reads it exactly where it always did. And the ❓ to-do form gets the same decision bar: "Send your answers — this is the whole decision", the mint ✅ Send my answers, and "✏️ Edit the to-do instead" demoted to a ghost secondary. 📐 PLANNED AS A BLUEPRINT (F:\Claude Code\.blueprints\kosmos-form-uniformity-20260909\, note filed on the board): every edit is an anchor-verified apply-spec.js JSON, and the planner ran --check on all four spec files before filing. Two existing assertions were invalidated BY DESIGN (a legacy form no longer renders checkboxes; an option label is no longer a <span>) and their replacements are in the spec rather than left to anyone's judgement — as is the harness wiring for dfIntro, because a lifted harness must be handed every new dependency by name or it silently diverges from the shipped code. 🧪 test/decision-form-picks.mjs (116) now asserts the uniformity itself: one card per question, chips and never checkboxes, each chip carrying its index and siblings, both surfaces sharing .qq/.qq-t/.qq-n/.qq-opts/.qq-in/.hubchip.qopt, the to-do form carrying the same bar with the same words, the old primary gone, dfIntro keeping the preamble while dropping the question lines, single-select replacing a pick while multi keeps both, and the multi marker stripped from the heading but shown as a hint. Suite 3660 across 76 files, 0 failures; ratchet 0.)read_decision_form response on the live board rather than trusting the one field I had just changed. listenModeOf() correctly returned polling: false and the STOP hint, but the response ALSO carries a top-level hint, and on a still-unanswered form that line read "Nothing yet — keep listening (~5 min cadence while the user is active)" — flatly contradicting the off switch two fields above it, and a session reading the nearer line would have gone right on polling. The waiting hint now asks the cadence first: with polling off it says "do NOT poll and do NOT re-arm a wakeup. Stop here and wait to be spoken to." WHY IT SLIPPED: test/decision-form-picks.mjs lifted the real read_decision_form but handed it a STUBBED listenModeOf that always returned cruise — so the hint could never see an off state, and the contradiction was invisible to a green suite. The test now passes the REAL resolver through, and asserts both directions: a waiting form under off must not say "keep listening", and a normal cadence still must. Same lesson as the rest of this run, one layer down — a stub proves the logic and never the wiring. Suite 3635 across 76 files, 0 failures; ratchet 0; decision-form-picks.mjs (91).)checklist with a projectId, so every option, every ✏️ row and the ▶/🛑 controls counted as open work. Measured on the LIVE Appolis board before touching anything: 23 rows, of which 22 were form choices and exactly ONE was real work. projOpenRollup now gives each unresolved form a SINGLE row — its title, how many questions it holds, and "answer →" — and skips its items entirely; an answered form drops out. This is the same treatment isLiveDocList already gave shot lists after 40 shot items blew the same list up, so the rule is now consistent: a thing with its own surface gets one row here, not twenty. 2 · LISTENING MODES CAN BE TURNED OFF. Off is not a slower cadence, it is NO cadence: listenMode() returns a real off state, the chip reads 🔕 Off and is coloured like a stop rather than a speed, the section header says "OFF — nothing is checking back", and — the part that matters — listenModeOf() in the connector returns polling: false, seconds: null and a hint telling a session to stop re-arming its wakeup and wait to be spoken to. Without that last piece the button would have been decoration while sessions kept polling. 3 · THE DECISION BAR, TAKEN FROM APPOLIS. Tyler: "design needs to be really solid on these across the board… Appolis had a pretty solid design for what its form looked like." Appolis's look-first page (public/preview.html, v0.33.0) gets three things right and all three are now here: the decision sits at the END of what you are reading and is deliberately not sticky (its own comment: "a sticky bar floats over the very panels he is meant to look at"); the buttons are coloured by meaning — mint for the affirmative, coral for the negative — not by prominence; and the bar says what pressing it does. So ▶ Continue / 🛑 Stop are now "✅ Send my answers" and "⛔ Stop the session" under the line "Send your answers — this is the whole decision", with "They land on the question the waiting session reads, and it carries straight on. Nothing is sent until you press this." Delay and Reschedule drop to quiet ghost buttons — they are escapes, not the decision. PLUS the tripwire that should have existed all along: after a Send, dfApply READS BACK what the server actually stored, and if nothing was recorded it says so on the spot — "⚠ Sent, but nothing came back recorded" — instead of the waiting session discovering it days later. Every one of #82/#85/#86 shared that shape: the app believed it had sent something the server never stored. 🧪 test/decision-form-picks.mjs (89) now also executes the rollup (two open forms + one real item = THREE rows, not sixteen; a form appears once by title, never as its options; an answered form drops out), both halves of the off switch (the app reports off with no seconds; the connector reports polling: false with a hint that says STOP rather than "poll slowly"), the read-back tripwire on both a real save and a silently-dropped one, and the bar's own design contract — that it states the consequence, that mint means send and coral means stop, and that it is NOT sticky. Suite 3633 across 76 files, 0 failures; ratchet 0.)dfPick, so a tick is recorded — that part was real and is still true. But the SEND was never exercised: I verified the tick and then deliberately stopped short of pressing Send, because sending would answer a live question on Tyler's behalf, and I said so at the time. That gap was the whole bug. The Appolis lane ran the missing test at 07:07Z (note_589 #86) and found the decisive evidence — after clicking ▶ Continue, the browser network log showed no write at all. THE CAUSE. dfAct called askConfirm('Send your answers — the session continues working?'), and askConfirm calls dropOverlayChained() — it replaces the form with a second dialog and only resolves on "Yes, continue". So the sequence was: tick your answers, press Continue, watch the form vanish, and be looking at a small confirmation box. Nothing had been written. Dismiss it, tap outside it, press back, or simply believe you were done, and the answers were gone. That is exactly what Tyler described twice — "Kosmos was having an issue with the answers being recorded" — and exactly why the Appolis lane saw no network request. PROVEN IN THE REAL UI BEFORE ANY CODE CHANGED, with real mouse clicks, signed in as the owner, on a throwaway form (note_3253) so no real question was answered for him: the tick records (FORM_PICKS [0]), ▶ Continue produces only the confirm dialog, and the write (PATCH /api/notes/note_3253 → 200) fires ONLY after "Yes, continue" — after which read_decision_form returns picks: ["Option A"], verdict: "answered". So the save path was always sound; the trigger was behind a door nobody knew to open. WHAT CHANGED (public/app.js). ▶ Continue now calls dfApply directly — no confirmation, no second overlay — and the existing toast is the visible outcome (cmp_progress0001: the outcome appears where the person was looking). 🛑 Stop keeps its confirm, because parking a session is consequential and rare, and declining it still hands the form back intact. The empty-send guard stays too — it is the one place a question before sending is genuinely useful. Second: the drafts are now cleared only once the server has the answer. The PATCH is wrapped, and a failed save says "⚠ Not sent — your answers are still here. Try Continue again." and reopens the form with every pick and every typed answer still in it, instead of quietly dropping them. ⚠️ SAID PLAINLY: this removes a second look Tyler asked for (the old comment read "▶/🛑 get a second look before anything fires (Tyler)"). Sending an answer is not destructive and a session can always ask again, so one lost answer costs more than the confirmation saves — but it was his call originally and it is his to take back. 🧪 test/decision-form-picks.mjs (64) now EXECUTES dfAct: ▶ Continue asks for no confirmation and produces exactly one PATCH carrying the pick; 🛑 Stop still asks, and declining it writes nothing and reopens the form; the empty-send guard still asks and then sends a real readable pick; and a failing api() leaves picks and typed answers intact, hands the form back, and tells the person. Four new mutations turn it red — restoring the ▶ confirm, clearing drafts before the save is known to have worked, swallowing a failed save, and dropping the Stop confirm — on top of the twelve from v8.94.0, all sixteen verified with both files restored byte-for-byte. 📌 THE LESSON, and it is the same one twice: I verified the half I could reach and reported the whole. v8.94.0's clock-out named the unverified step correctly and shipped anyway. A step you cannot test is not a caveat to publish — it is the test you have not written yet. Suite 3608 across 76 files, 0 failures; ratchet 0.)openDecisionForm into a new dfItemsHtml() and, in doing so, replaced a WORKING inline checkbox handler — onchange="this.checked?FORM_PICKS[id].add(idx):FORM_PICKS[id].delete(idx)" — with a call to dfPick(...). dfPick was never written. It appears exactly once in the repo, once in the served bundle, and in no commit in the project's history; git log --all -S "function dfPick" is empty. So from 4cc0c8c (09-04) every tick on a connector-posted form threw ReferenceError into a console nobody was reading, the native checkbox still looked ticked, and dfApply — which rebuilds checked from FORM_PICKS — then wrote false over every option, erasing picks that had already been saved from another surface. Reproduced live and signed in on kosmos.appolis.app before any code was touched: clicking "✅ Accept — it complied" on note_3197 left FORM_PICKS [] and the console carried Uncaught ReferenceError: dfPick is not defined. THE SECOND DEFECT, independent of the first. dfApply held one line — if (/^✏️/.test(it.text) && ownText) { it.text = '✏️ ' + ownText; it.checked = true; } — and there is only ONE shared #df-own textarea in the modal, so a multi-question form got that same string written into every ✏️ item. Worse, it rebuilt the text as '✏️ ' + ownText, destroying the Q<n> · prefix that groupAnswers() matches on — which is exactly why read_decision_form returned answers[n].own === null for every n while picks repeated one string eight times. WHAT CHANGED. public/app.js: dfPick(id, idx, on) exists and records the tick; FORM_OWNQ holds a draft PER QUESTION; a grouped form now renders a real <textarea class="qq-in"> under each question — the same shape the ❓ to-do form has had since v8.90.0 — instead of the ✏️ checkbox row Tyler could not edit ("It's just a checkbox and it says 'Edit this item' to check it. I cannot edit it."); dfApply writes each answer into ITS OWN ✏️ item and keeps the Q<n> · prefix; dfOwnSeed seeds a box from an answer already stored on the note; and dfHasAnswer gates the Send — ▶ with nothing picked and nothing typed now asks first, and taking the offer files a real, readable "✏️ (no preference — your call)" pick rather than resolving the form onto nothing. lib/mcp.js: read_decision_form no longer counts a lone ▶ as an answer (answered = picks.length > 0), returns noPicks: true and a hint telling the session to re-ask rather than act on an empty form, and exposes answeredAt; buildFormItems stops emitting "Q1. Q1. …" when the caller numbered their own question. ⚠️ ONE BEHAVIOUR CHANGE WORTH KNOWING: a form already sitting in the ▶-with-no-picks state now reads as waiting instead of answered. That is deliberate — it was never answered — but a session polling one will keep waiting until it re-asks. 🧪 test/decision-form-picks.mjs (51) EXECUTES the path rather than grepping it, which is the real lesson here: the only previous test touching this code asserted /function dfItemsHtml\(n, id, picks\)/.test(APP) — a source-text regex that proved the function had been typed, never that it ran, which is how 3508 green assertions sat on top of a dead handler for five days. The new file lifts the real functions and calls them: a tick is recorded, a Send preserves it, two questions get two DIFFERENT answers with prefixes intact, groupAnswers reads own back per question, ▶-with-nothing does not resolve, the legacy one-question form stays byte-identical, and every function named by a rendered on handler is asserted to exist. Six mutations were run against it — emptying dfPick, restoring the fan-out, dropping the prefix, forcing dfHasAnswer true, restoring the old verdict, restoring the double prefix — and all six turned it red. A sweep of all 235 inline handlers in app.js found dfPick was the ONLY undefined one. ⚔️ THEN AN ADVERSARIAL REVIEW WAS RUN BEFORE THE COMMIT, and it earned its keep. Five lenses over the uncommitted diff raised 32 findings; 16 survived refutation, and the top one was that the FIRST cut of this fix had reintroduced the very shape it was written to kill: dfApply rebuilt every ✏️ Q<n> row from a FORM_OWNQ that nothing ever seeded, so a question left alone was overwritten with the placeholder — silently erasing an answer already on the note, on Continue, Stop and Delay alike. Rebuild-from-empty, exactly like dfPick. Six more real defects came with it, all now fixed: the shared "✏️ Your own answer" box was still rendered on grouped forms but had nowhere to go, so the sentence typed into the most prominent field on the form was dropped and produced the empty ▶ the new guard exists to prevent (it is now labelled "Anything else" and saved as ✏️ Also: …); FORM_PICKS was seeded only on the FIRST open, so a stale set un-ticked whatever had been checked from the note view (it re-seeds every open now, and unions rather than replaces); picks are array indexes, so a note that gained or lost a row while the modal was open would tick the wrong options (FORM_AT now refuses and reopens); the app called a bare ▶ "resolved" while the connector called it "waiting", so a stranded form vanished from Tyler's Inbox while its session waited forever (formResolved now requires a real pick or a real typed answer, and both sides agree); a ✏️ row still carrying its placeholder counted as a pick*, so ticking it in the note editor — which its own text tells you to do — resolved a session onto the instruction sentence; and the de-dup regex ate a legitimate "Q1-2026 or Q2-2026" question head. 📌 THE ONE-LINE RULE OF THIS RELEASE: never rebuild state you did not seed. dfPick erased picks by rebuilding from an empty set; the first fix erased answers the same way. A second, smaller lesson is pinned in the code itself: formAnswered and formResolved MUST stay on one line each, because the test harnesses lift consts line-wise and a wrapped const is silently truncated — the lifted copy then behaves differently from the shipped one, which is how a green inbox-forms.mjs briefly disagreed with the app. 🧪 test/decision-form-picks.mjs (51) EXECUTES the path rather than grepping it, including the real read_decision_form handler lifted out of the connector — because the review fairly pointed out that the first cut pinned the connector half with a source-text regex, the exact failure this file's own header indicts. inbox-forms.mjs gained three assertions and its harness was handed the new dependencies by name. Twelve mutations were run against the finished code and all twelve turned it red, with both files restored byte-for-byte afterwards. Suite 3595 across 76 files, 0 failures; ratchet 0.)askTrayHtml(asks) came out of viewBoard so it can be executed by the test; ASKS_OPEN is a session variable, not a stored setting: it survives the 45s refresh and every re-render while he works through them, and the next load starts collapsed again, which is the only thing "starts collapsed" can honestly mean. The chevron is a real <button> with aria-expanded (the A13 rule from the chrome work), the label toggles too, and open the Inbox → still goes straight there in one tap. Collapsed, the tray's own padding tightens so it is a line rather than a box with room for rows that are not there. 🧪 test/inbox-forms.mjs 25 → 33: the tray is EXECUTED both ways — collapsed renders no .askline and carries the count, the button and the Inbox link; opening it renders one row per ask and repaints; closing it empties again; an empty board renders no tray at all; plus a source assertion that the default is false and is not read from settings. Suite 75 files, 0 failures.)#/inbox, viewInbox) is now the tray of everything waiting on the person — ❓ decision forms, ❓ question to-dos, 🚪 hub join requests — newest first, each row with its project, when it was asked and how many questions, and one Answer → that opens the existing form modal; ✅ answered forms fold below as history. askRows() is the one list: the Board renders it as a tray at the top (up to six lines, then "+ N more in the Inbox →"), and the Hub tile / rail badge count it (pendingAsks()). After an answer the next form no longer pops open by itself — the toast says how many more wait and the person picks. TRIAGE (#/triage, viewTriage) is the old Inbox body unchanged — suggestion chips, re-file dropdowns, accept-all per tier — with its own 🗂 Hub tile counting unfiled notes; every user-visible "inbox" in that sense (import landing, share toast, wait messages, setup guide, sync prompt, the ✨ feature bubble, the You stat) now says Triage. Default rail pins unchanged (Inbox stays pinned). 🧪 test/inbox-forms.mjs EXECUTES the helpers and viewInbox against a fixture board (an open form with two questions, a resolved form, an answered form, a ❓ to-do, a done one, a join request) and asserts the router, the two Hub tiles, the tray, and that the pill and the auto-advance are gone. ⚠️ Verified by the executor's tests and the served version; the shipping session had no signed-in Kosmos pane, so the served board was not opened in a browser — Tyler's look is the last check. Follow-ups filed for the Inbox's next lives: a real email inbox (todo_3154), profile-to-profile chat (todo_3155), friend requests (todo_3156).)resolve() cannot find the remembered element, draw() no longer creates a pin: moved still counts it, the drawn report now carries mov (the ids), the shell tags those rows "spot changed" in the Notes list (where they already group under "Elsewhere on the page") and says so once in the toast — "2 notes lost their spot — that part of the page has changed; find them in the Notes list under \"Elsewhere on the page\"" — joined with the not-shown-at-this-size line when both apply. The amber .mk-moved style went with the pins it styled. (2) A frame without a box is not a size. The first draw after a load or a device switch can run while the shell still has the frame hidden or unsized; every element then has no client rects, and v8.91.0 counted them all as hidden — the Notes list tagged three visible notes "not shown at this size" until the first scroll redrew. Now, when the document's own root has no rects, draw() measures nothing and reports nothing; the resize that follows the frame becoming visible draws for real. Tests: hub-mark-layer asserts an ambiguous or vanished anchor draws NO pin and is named in drawn.mov; hub-mark-geometry's orphan case asserts the same instead of a corner position. Suites 60 / 6 / 7 / 7. Replay of Tyler's five Ageless anchors against the page as served today: two orphans (the removed header cells) named in mov, one phone-only note hidden at desktop, two section notes placed — and no pins in the corner.)markLayer against the live page (scratchpad/mk-repro.js) — two causes, both invisible to the test suite. (1) Hidden ≠ moved. A note left on the phone's 2×2 spec strip resolved fine at desktop — the element exists — but display:none gives it a 0×0 rect at (0,0), so draw() put the pin at the top-left corner, and every such note stacked there. Now an element with no client rects is counted as hidden: no pin, the drawn report carries hidden + hid, the shell toasts once per change of that set ("2 notes sit on a part of the page that is not shown at this size — pick another size to see it") and tags those rows in the Notes list. (2) Trim after the cut. hubmarks.cleanAnchor() stores s(txt,160).trim() — trimmed AFTER slicing — while the layer's textOf() trimmed BEFORE slicing, so a fingerprint whose 160th character was a space came back one character longer than the stored copy; resolve()'s text cross-check then failed on every rung and the note was drawn amber in the corner and grouped under "Elsewhere on the page" — exactly his "everywhere note". textOf now trims after the cut too, so click-time, store and draw-time agree byte for byte. Tests: the four mark suites still pass (60 / 6 / 7 / 7); the layer test's DOM double has no getClientRects, so the hidden rule is guarded on the method's existence and treats an unknown as visible. Verified on the served hub after deploy: Tyler's five notes on the Ageless page draw on their elements at desktop with the phone-only one reported hidden, and the strategy-section note resolves instead of orphaning.)post_decision_form → a ❓ checklist note → the "answer →" pill → a modal with one-tap choices, a your-own-answer box and Stop/Continue — but sessions had stopped using it, for two reasons the door itself caused: its options had to be an ARRAY, and an array param reaches this connector as ONE string through some transports (the array-params trap, note_1439), so the old handler mapped a string's characters into items; and it took exactly one question, while what a session usually needs is a brief's worth. So sessions asked by convention instead — a to-do starting with ❓ — and a to-do opens in the generic EDITOR: a textarea holding the question, a due date, a project, and nowhere to answer. THE DOOR (lib/mcp.js): asList() takes a list as an array, a JSON-encoded array, one item per line, or "a | b | c" — never a string's characters again. questions[] posts several questions in one form — objects {q, options?, multi?}, strings shaped "Question? :: a | b", or one string with a question per line — each with its own choices and its own ✏️ own-answer line; the body numbers them; read_decision_form returns answers[] grouped per question. A legacy one-question form keeps its EXACT item shape, so every form already on a board and the modal that renders it are untouched. options is no longer "required" (either shape is). add_todo's description now steers: asking the person something → post_decision_form. THE APP (public/app.js): openTodo() routes a ❓ to-do — one whose text starts with ❓, is not done, and carries no ✅ ANSWERS block yet — to an ANSWER FORM: the text is parsed into questions (numbered, Q1., or bulleted lines; the preamble becomes the title, minus the "NEEDED FROM" shouting), "(a) … (b) … (c) …" and yes/no become one-tap chips, every question gets a text box ("or in your own words…"), plus "Anything else", and Send answers ▶ appends ✅ ANSWERS (Tyler, date): 1) … 2) … onto the to-do — the one place every waiting session already reads — and drops the ‼️ (the ask is answered; the to-do stays open and visible). The row says "answer →" on a question; "✏️ Edit the to-do instead" is one click away; an answered to-do opens as a to-do. The send goes through the shared wait helper with its own words and a failure meaning, and confirms the outcome with a toast (progress rev 7's new criterion 12, on this one path). The connector's multi-question forms render GROUPED by question in the existing modal (dfItemsHtml). ⚠️ LEFT FOR THE FOLLOW-UP, said plainly: on a connector-posted form the per-question ✏️ own-answer rows are still checkable items whose text you edit in the note — the text-box-per-question the ❓ to-do form has is not yet on that path. 🧪 test/decision-forms.mjs (41) EXECUTES both halves — lifts asList/formQuestions/buildFormItems/groupAnswers out of the connector (NUL-stripped) and isQuestionTodo/parseTodoQuestions/buildAnswersBlock/openTodo out of the app: every list shape, the legacy shape byte-for-byte, three questions in mixed shapes, answers grouped per question, the Flood to-do parsed into four questions with chips, the ANSWERS block verbatim, and the routing (❓ → form, plain → editor, "edit instead" → editor). Suite 3508 across 74 files, 0 failures.)settings.pins = { sync, bar, phone } — bar is the desktop list and the shared list while synced; phone is the rail's own list once they are separate; the v8.87.0 .mobile key is still read. SYNCED (the default) hides the choice: one ☆ pins to both bars, and the tooltip says so. SEPARATE shows two chips — 🖥 Desktop (n/6) and 📱 Phone (n) — and the ☆ edits whichever is chosen; the rail shows the phone list, the top bar the desktop list, and a pin on one never touches the other. Going separate COPIES the shared list into the phone list so nothing vanishes from either bar; syncing again ADOPTS the desktop set, the way the reference does, because the strip is the capped one and therefore always fits both. THE CAP: the desktop bar holds six — the strip must never wrap (A7) — and a seventh star says "The top bar holds 6 — unpin one first" and writes nothing; the phone rail is uncapped because it scrolls. While synced the cap guards BOTH lists, so "synced" stays literally true instead of drifting at the seventh pin (the reference lets its two lists diverge there). A bar setting that fails to save is put back and says so. 🧪 test/chrome-hub.mjs 164: synced by default with both bars reading one list, the control in mint with no chips while synced, separate copying the list and writing both to the account, a star editing only the chosen bar with the rail and the top bar diverging accordingly, the six-pin cap refusing and writing nothing, the uncapped rail, sync-again adopting the desktop set, the cap guarding both lists while synced, and the rollback. Mutation M14 (the cap removed) turns it red. Suite 3467 across 73 files, 0 failures. Verified on the served app: the control renders in the Hub, going Separate and pinning on the phone surface changed the rail and not the top bar, and the account was put back exactly as it was.)margin-top:calc(-9px - env(safe-area-inset-top)) with a matching padding — so its tint and its right-hand divider ran up under the status bar while the rest of the row started below it. The reference app's corner never enters an inset: its rail pays the bottom inset as the CONTAINER's padding and the + corner sits inside the row. Same here now: the corner eats only the header's padding (margin:-9px 0 -9px -16px, -12px on a phone), the header's own background fills every inset, and the corner starts where the row starts. Nothing changes where there is no inset. Pinned in test/chrome-hub.mjs (149): no env(safe-area-inset anywhere in the corner's rules, while the header itself still pays the top inset. ⚠️ Not observable in the browser pane, where every inset is 0 and the old and new rules render identically; the served stylesheet was checked on the wire, and the proof is the phone. Suite 3452 across 73 files, 0 failures.)overflow-x:hidden ON BOTH ROOTS WAS KILLING position:sticky. Tyler, 2026-09-03, fullscreen on his phone at last: "it's full screen, but now the header is not sticky like it is in Hermes." The header has declared position:sticky;top:0 since long before this directive, and A1 pins it. What defeated it was line 14 of the stylesheet: html,body{overflow-x:hidden;max-width:100%} (with a second body{overflow-x:hidden} in the fit-everywhere pass). That pairing is the textbook sticky killer: a non-visible overflow on html stops body's overflow from propagating to the viewport, body becomes an overflow box of its own, and a sticky child of body sticks to a box that never scrolls — so the header rode away with the page. Hermes sets NO permanent overflow on either root (only its scroll-lock class), which is why its band stays put. FIX: overflow-x:clip on both roots — clip clips exactly as hidden did (nothing runs past the viewport, the intent of that pass is kept) but does NOT create a scroll container, so sticky binds to the viewport again. Two lines. Pinned in test/chrome-hub.mjs (148): no root may carry overflow-x:hidden, and both must say clip. ⚠️ Why it read as "worked before, broke in fullscreen": it was broken at every width the whole time — a 57px header scrolling away under a two-row phone chrome simply went unremarked until fullscreen gave the page the whole screen and Tyler scrolled it. Verified on the served app by scrolling 800px at 375×812 and reading the header's rect: top stays at 0. Suite 3451 across 73 files, 0 failures.)display, display_override and orientation, both served as application/manifest+json with public, max-age=0, must-revalidate, both linked the same way. The ONE head-level difference was Hermes's <meta name="mobile-web-app-capable" content="yes"> — the legacy Android app-mode flag — which Kosmos lacked (it had only the apple- twin). Added for parity. ⚠️ What that leaves is phone-side, and this session cannot force it: an installed Android app is a WebAPK; Chrome re-reads its manifest at most about once a day, re-mints the WebAPK in the background, and applies a changed display mode only on the launch after that. Removing the icon and re-adding it from Chrome's menu mints a fresh WebAPK from the current manifest at once. Opening kosmos.appolis.app in a Chrome tab will never go fullscreen — only the installed icon does. Said plainly to Tyler. 🧪 test/chrome-hub.mjs 147 (the grid, its nine dots, its colours, the mark's absence from the corner and presence in the lockup, the meta). Suite 3450 across 73 files, 0 failures.)"display": "fullscreen" with display_override: ["fullscreen","standalone","minimal-ui"]; Kosmos declared "standalone", which on Android keeps the system back/home/recents bar on screen and shrinks the app above it. Kosmos now declares exactly what Hermes does, plus orientation: "any". ⚠️ An installed PWA picks a manifest change up on its own schedule — Android Chrome re-reads it on launch and may apply a display-mode change only after a relaunch or a re-add to the home screen. Said plainly to Tyler. With the system Back hidden, the header's own ‹ Back matters more, so on phones it is now ALWAYS in the row — dimmed and inert on home (aria-disabled="true", .backbtn.off) rather than appearing and disappearing, which shifted the row. (2) THE CORNER. The Hub button is a sectioned-off corner now: it runs to the header's own left edge (a negative margin eats the header padding and pays the safe-area inset back), is divided off by a right border the way the rail's + corner is divided off by a left one, carries a faint tint that deepens on hover and while the Hub is open, and stacks — the mark ABOVE the word "HUB" in small caps. The mark is 40px on desktop, 36px on a phone (it was 28px beside the word). The header grew to fit it; --bandh is measured, so nothing cared. (3) ONE LIST, TWO BARS. The fixed seven-tab NAV retired. The desktop top bar now renders the person's pins — the same railPins() list the phone rail renders — with the current one marked and the Inbox count as a badge, and an empty bar says "☆ Pin your favourites in the Hub and they land here". Pinning in the Hub lands on both bars at once. Storage moved from settings.pins.mobile to settings.pins.bar, because "mobile" was a lie the moment the desktop read it; the old key is read for one release. The ☆ tooltip says which bar. A13 holds: Landers, Playbook and the rest are Hub tiles one tap away, and Board/Inbox/Calendar/Recents are the default pins. 🧪 test/chrome-hub.mjs 143 (lifts and executes renderNav() now too: the desktop bar shows the same four pins, marks the current one, drops the fixed NAV, keeps Landers as a tile, and a Hub pin lands on both bars at once); the manifest is asserted; 12/12 mutations red. Suite 3446 across 73 files, 0 failures. Verified on the served app at 375×812 and 1280×900 — see the clock-out.)position:fixed;left:14px;bottom:14px;z-index:80, so the moment the phone rail arrived it covered the rail's FIRST pin (Board, by default) and swallowed the tap: the pin was there, drawn, and unreachable. Lifted to bottom:calc(72px + env(safe-area-inset-bottom)) below the chrome breakpoint — !important, and it has to be, because an inline style outranks a stylesheet rule; the reference app lifts its own bug fab over its own rail for exactly this reason. Pinned in test/chrome-hub.mjs. Nothing else changed. Suite 3434 across 73 files, 0 failures.)required; Kosmos goes first by the owner's call, and the element-by-element brief was todo_2487. Tyler, 2026-09-02: "every app … that has a top menu and a collapsible menu [gets] the Hermes sticky header, menu to hub button treatment and the same bottom mobile hub button menu bar treatment", "I like how when you open the hub up is when you see the name of the app and the tagline", and "every app should allow the user to pick their starting screen when they enter the app." Hermes v2.9x is the reference; this copies the PATTERN and none of the pixels — Kosmos's mint and ink, Kosmos's mark, Kosmos's motion. THE HEADER IS ONE ROW THAT CANNOT WRAP, at every width. The logo block became the Hub button — the mark plus the word "Hub", top-left, aria-label="Open the hub" with aria-expanded — and the wordmark and tagline LEFT the chrome: they live in the document title, in the Hub's lockup and on the login card, and nowhere else, because a header that repeats the app's name on every screen spends the most valuable phone pixels saying what the person already knows. The phone header was two rows, 103px measured, with the search field on a line of its own; it is now one 57px row of [Hub] [‹ Back] [search] [↶]. It pays env(safe-area-inset-top/left/right) for the first time — index.html has declared viewport-fit=cover and a black-translucent status bar since v5.x, so in an installed iOS PWA the first row was sitting under the clock. And its height is MEASURED into --bandh by a ResizeObserver (with a resize fallback, plus first-frame, window-load and fonts-ready triggers), so nothing anchored to the header hard-codes a pixel again — the retired .menupop{top:58px} was guessing at a bar that computes to 57.47px on desktop and was 103px on a phone. THE ☰ RETIRED INTO A HUB PAGE. #menubtn, #menuscrim and #menupop are deleted, not shimmed; buildMenu() became buildHub(). The Hub is Kosmos's own full-screen map — position:fixed;inset:0, the name + tagline lockup sticky at the top, ✕ and Esc, the page behind frozen — carrying every one of the 25 destinations the menu had, grouped Go to / Pipelines / Tools / Spaces / You / Elsewhere, each with a ☆ that pins it to the phone rail. ⚠️ The Appolis city is a TILE inside it, never what the Hub button opens — and that finally puts the city on a phone at all: the 🏛️ button lived only in #nav, which is display:none below 861px, so for its whole life the city door was desktop-only. ⚠️ Needs attention joined the list while the tiles were being written. THE PHONE RAIL. A fixed bottom bar, appended to <body> and never inside the header (a rail that lives in the header dies with any rule that hides the header — the reference app's own scar), signed-in only, paying env(safe-area-inset-bottom), carrying the person's pinned destinations at exactly a quarter of the scroller each with scroll-snap-type:x mandatory and scroll-snap-stop:always, so a swipe can only rest on a boundary and you never see three-and-a-bit icons. Defaults: Board, Inbox, Calendar, Recents. The right slot is the + quick-add, not a second Hub — bottom-right stays reserved for quick-add, so below the breakpoint the floating #qcfab folds into the rail's corner and above it stays exactly where it has always been. Pins live on the ACCOUNT (settings.pins.mobile, through the existing PATCH /api/settings, which shallow-merges any key so no server change was needed) — deliberately better than the reference, whose per-device localStorage pins are its own documented weakness. ONE CHROME BREAKPOINT: 860px, where the tabs hide AND the rail appears, so there is no band of widths with neither. The brief floated 640px; the measurement said otherwise — at a 900px viewport #nav is 946px of nowrap tabs, so 641–860px could never have been the single row the directive requires. ⚠️ AND THAT MEASUREMENT FOUND A LIVE BUG: .topbar had scrollWidth 1442 inside an 885px bar, i.e. the whole page scrolled sideways at every width from 861px up. #nav now gets min-width:0;overflow-x:auto and scrolls inside the row instead of pushing the page. PART B — OPEN ON. settings.startScreen, per person, on the account, so it follows them between devices. The offered list is DERIVED at render time from hubDests() — the same one list the Hub tiles and the rail read, never a second hand-kept one — plus Your Kosmos, AI Hub and their own un-archived projects; a pipeline they have hidden is not offered, and the super-only doors (Accounts, Studio), the utility screens (setup, profile, calc, import, archived, trash, vault) and the Lander Builder are not places to land. A deep link always wins: the setting decides a bare open only, and # and #/ alone count as bare. Chosen-but-gone → the Hub, said once by name, and the value is cleared so it never asks twice. Nothing changes for anyone who has not chosen — today's landing and the first-run #/setup guard stay the default. Set it from the Hub or from the profile screen, both rendered by ONE startPicker(). #/hub is a real deep link, so another app in the city has one URL that lands a person in this app's map. BACK READS THE SCREEN, not the history depth (the owner said "potentially"; it earned its place): something open → close it · an item → the crumb that view already draws · a top-level route → HOME, meaning their chosen start screen · standing on home → the control hides itself. Kosmos has no full-screen subsection views in the reference's sense, so that clause does not apply and the header stays on every route. Z-ORDER, stated ordinally: page content < #qcfab 60 < rail 70 < header 75 < Hub 78 < modals and the login wall 80 < toast 99 < the wait popup 9998 < the version banner 9999. 🔍 AN ADVERSARIAL REVIEW OVER THE BUILT CODE BEFORE COMMIT — 5 lenses, 73 agents, 34 findings, each one put to two independent refuters. ⚠️ The P1 was mine and it was ugly: the rename above was applied with a bulk replace, and it caught the PROJECT pin button too — 📌 Pin on a project page would have called the rail pinner, silently discarded its second argument, never set p.pinned, and appended the project id to the phone rail's pin list as permanent account state; a project whose slug happened to be inbox or calendar would have removed that destination from the person's bar. Restored, and toggleRailPin now refuses any key that is not one of its own destinations, so no future slip can persist junk. Also found and fixed: every Hub tile was a div[role=button] with no keyboard activation AND a real button nested inside it (both now real buttons, the ☆ a sibling) · .backbtn[hidden] lost to the media query's display:grid, so the Back control could never hide · the Hub's sticky lockup did not pay the top inset, sliding its ✕ — the only pointer way out on a phone — under the iOS status bar · the picker emitted two <option value="">, so choosing Board CLEARED the setting and toasted the opposite (Board has its own key now) · the Lander Builder was offered as a home, which was a loop nobody could walk out of because every "← Kosmos" link points at the bare root (dropped from the offer; it stays a Hub tile) · archived projects were offered as homes · setStartScreen never rolled back a failed save and the profile copy of the picker went stale · a star tap threw you back to the top of the Hub · Back to a Hub home opened it and the queued hashchange closed it a tick later · the Spaces tile vanished for anyone whose start screen was not the Board · the start-screen redirect pushed a history entry instead of replacing one · two rapid star taps could let a stale response win · the retired .brand .tag rule was shrinking the Hub's tagline to 8.7px · the picker's explainer line had no rule at all · the rail cut every label at its first space · the login gate left an empty chrome band. ⚠️ AND ONE THE FIX ITSELF INTRODUCED, caught by the test hanging: warming the company list from buildHub() repainted the Hub, which warmed it again — an unbounded loop whenever the answer leaves COMPANIES null, which the team door's known 403 (todo_1669) does. Asked once per session now. 🐛 TWO MORE THE BUILD SURFACED ON ITS OWN: the 45-second live-data poll skipped a re-render "while the menu is open" by testing an element this rebuild deleted, so the guard was permanently false and a tick would have rebuilt the Hub under the person's hands; and that same poll assigned the raw board over the namespaced one, dropping the shared-id tagging refresh() applies — a PRE-EXISTING defect that was quietly corrupting every shared-row consumer between polls, not just this feature. 🧪 test/chrome-hub.mjs (130) EXECUTES the chrome — it lifts hubDests, railPins, toggleRailPin, renderRail, startOptions, startHome, bareOpen, applyStartScreen, backTarget, goBack, openHub, closeMenu, buildHub, startPicker and mountChrome out of the app with a brace-matching lift and runs them against a DOM double with a recording network layer: the measured --bandh at two real heights, one control saying Hub, all 25 carried destinations present, a member seeing no super-only door, a Kosmos-only account seeing no team space, the rail signed-out/one-pin/no-pin/dead-pin cases, the pin write going to the account and being put back when it fails, the derived option list, the deep link winning, # and #/ counting as bare, the revoked start clearing and toasting once, and every Back case. 12 mutations were run against it and all 12 turned it red. Suite 3433 across 73 files, 0 failures; ratchet 0; version guard green. ⚠️ VERIFIED IN A BROWSER AT 375×812 AND ≥1280 ON THE SERVED APP — see the clock-out on the board for what was measured and what was not.)const bootTok = progStart(…) INSIDE the try block and released it in the finally — where a const from the try block does not exist. The release threw a ReferenceError every load: the board painted, but its overlay wait was never retired, so the veil (pointer-events on) sat over the app until the 30-second watchdog dropped it — "taking forever" — and the window then wore "Loading your board…" for the rest of the session — "the loading just hangs". node --check cannot see block scope, and the v8.86.0 test PINNED the line by regex (it saw const bootTok = progStart( and progDone(undefined, bootTok) inside the finally and called it good) without executing it — the exact source-only trap round 2 had called out for the driver, repeated one block further down. FIX: let bootTok = null; declared before the try, assigned inside it, released in the finally guarded by !== null. THE TEST NOW RUNS THE BOOT: test/progress-driver.mjs lifts the boot IIFE out of app.js and EXECUTES it with recording stubs on both paths — signed in (/api/me answers → render → released by its own token, after the render) and signed out (/api/me throws for the login gate → still released by token, nothing rendered). A scope slip, a lost token or a release on the wrong path fails it. 169→177. Suite 3302 across 72 files, 0 failures; ratchet 0. ✅ VERIFIED ON THE SERVED APP after deploy: the login gate paints with the window CLOSED within the first seconds (checked in a browser against kosmos.appolis.app, see the clock-out on the board). 📝 Trap recorded on note_1448: a token released in a finally must be declared before the try, and any boot/lifecycle block gets an executing test, never a regex.)api() without a label opened the window saying "Working" over "Working…", and the boot's big line was a flavour line. WHAT SHIPS: a route table in the one shared helper — WAIT_LABELS + labelFor(method, url) — so every call names the THING ("Loading your board…", "Publishing the hub…", "Filing the notes…") with a SECOND LINE saying what is actually happening ("Pulling your projects, notes, to-dos and events from your account."), a live clock, and the same words on the minimised pill. 55 entries, written by reading the 160 call sites in context (a four-agent pass over app.js) and corrected twice by review (a deleted to-do is not "gone for good" when ↶ Undo brings it back; publishing a hub writes no documents; only the sync-to-standard keeps a version; filing a note lands in an AREA as often as a project). Suffix routes are RegExp rows so "…/restore" is not "Creating the note…"; a route not in the table is DERIVED from its path and method ("Loading warehouses…"), so a new endpoint can never show up as "Working…". THE WINDOW KEEPS A LIST, NOT A COUNT: progStart returns a token and progDone(result, token) retires exactly that wait, so the window names the OLDEST wait still running and moves its words on when that finishes instead of naming finished work; a late release from a cycle the 30s watchdog already closed finds nothing and cannot eat a newer wait. A FAILURE NOW ACTUALLY SHOWS — the round-2 P1: since v8.59.0 api()'s finally called progDone in the same tick as progFail and wiped the coral "Stopped" window before a single paint, so the app had never once shown the failure state its own proof page demonstrated; progDone now refuses to close a cycle a failure owns and api() skips its release on the failure path. The reverse bug went with it: a BACKGROUND poll that 5xx'd was the only failure that DID persist — a modal "Stopped / nothing was saved" over whatever you were doing every 45s of a KV blip; quiet work now fails quietly and never zeroes another wait's count. A failed READ says "nothing changed", not "nothing was saved". THE PRESSED BUTTON GOES BUSY BY CONSTRUCTION: busy() existed and was called nowhere; api() and invFetch() now mark the button that was focused when the call began and restore it in finally — never the window's own Minimise/Close. ONE DRIVER, FOR REAL: invFetch (the invoice helper, 19 call sites — publish, mark-paid, void, Stripe link/sync, delete) rode no indicator at all and now rides this one with its own rows (a 409/501 is an ANSWER and simply closes the window; only the network failing is a failure); sign-in, sign-out, the 🏛 Appolis and 🔌 connector popups were bare fetches and now name themselves; a hub file/HTML upload is ONE wait across the handshake AND the up-to-30 MB PUT ("Uploading “title”…" → "Sending 12.4 MB to the hub."), failing the window on a failed PUT instead of a toast; the Keep import is one wait with a known total and named batches ("Batch 2 of 5 — 80 added so far.") instead of a window strobing per call; moving a project to the Trash, restoring it, and moving a to-do into a list say so instead of "Saving…"; the companies fill is background, so the boot overlay no longer veils an already-painted board while an Agora + Hermes fan-out returns. ⚠️ ONE READ STAYS OUTSIDE THE HELPER ON PURPOSE — loadFinance()'s AI-cost fill, fetched after the money strip has painted: nobody is waiting on it, and a Studio outage must not interrupt every project page. Said so at the call. NO DOUBLE-TELLING (rev 6's third ruling): api() takes { inplace: true } for a view that already renders its own specific labelled loading state where the content will appear — the project page's team panel ("Loading the team side…") — and opens no window on top. Reduced motion now stills the pulsing dot too. 📄 /progress.html rebuilt around the words — the driver pasted verbatim (a test fails if it drifts), every demo calling it the way api() does (start → work → fail or release BY TOKEN, so the failure demo shows the state the app really reaches), the route table rendered from the SAME constant the app uses, the derived case, the overlay, named batches, the in-place case, the overlap whose words move on, and an honesty section that names what Kosmos does not use AND ⚠️ the other pages this worker serves that are NOT yet at rev 6 — the Lander Builder (a 3px top bar; its intake work bar already meets rev 6), the hub shell (top bar + relabelled buttons), the lander analytics page, the Free Will pages, the breakdown page — filed as todo_2391. This version is attested for the APP SHELL only, on purpose. 🔍 THREE ADVERSARIAL ROUNDS BEFORE COMMIT — round 1 (4 lenses) on the first cut, round 2 (4 lenses, 16 agents: 6 confirmed by refuters + 36 more) which found the failure-window wipe, the never-called busy(), the invFetch bypass, the ten bare fetches, the finished-work label, the watchdog's stale release and the source-only test, and round 3 (3 lenses, 15 agents; 6 confirmed + 26 more) over the fixed code, which found the OTHER door to the failure window — progStart re-armed the cycle and stripped the coral chrome, so a batch loop’s next call or the refresh after it hid a failure before a paint; a wait started while a failure or result is on screen now QUEUES behind it and Close re-arms a cycle for what is still running — plus: Safari never focuses a tapped button, so document.activeElement alone left every control live on the phone (a capture-phase press is remembered and the button is disabled for real, not only styled); the veil is a property of the WAIT, so retiring the boot’s token drops it while a rider continues as a window, and the 30s watchdog drops the veil instead of blanking the screen; a per-file or per-batch failure inside a held batch no longer destroys the batch’s own wait (inner calls are quiet, failures are counted on the outer wait, nothing that failed ends in a ✓, and a batch where nothing landed fails the window); a hub PUT that throws mid-upload fails the window instead of closing it quietly; the invoice keystroke autosave and idle republish are in-place (the editor’s own flag says it); a failed sign-out says you are still signed in; the Appolis apps popup renders a failed read instead of a false “no Appolis ID” diagnosis; a call site can say what a failure means for the data (to-do→list’s second step); logging a bug names itself. Every one of those is executed by the test, not grepped. 🧪 test/progress-driver.mjs (169) now RUNS the lifted driver, api() and invFetch() against a DOM double with a recording fetch, a CAPTURED timer (the reveal path is the real tick) and a focused button: the words for every entry, derivation for every method, suffix routes and precedence, the wait list moving its words on, the failure surviving its own release, quiet failures staying quiet and never touching another wait's count, the network failing out loud, the 401 gate, the pressed button busy and restored, the watchdog and the stale token, determinate, result restraint, Minimise/pill/Close, the invoice helper's answer-vs-failure split, every wired call site, the reduced-motion dot, and the proof page's byte-identical driver and api-shaped demos. Suite 3294 across 72 files, 0 failures; ratchet 0. ⚠️ NOT VERIFIED IN A BROWSER AT THE TIME OF WRITING — the served /progress.html is checked after deploy; see the clock-out.)Kosmos · <hub> <no-reply@kosmos.appolis.app> while Resend has that subdomain verified (it is, since 2026-09-01), else from the apex with a stated fallback. ⚠️ THE RECORD, STATED PLAINLY: the v8.76.0 entry's "Appolis did its half in v0.20.0" was wrong — v0.20.0 computed the derived From and discarded it, so EVERY mail from every app left as "Appolis <no-reply@appolis.app>" from 2026-08-21 until v0.22.0 on 2026-09-01; sug_8523034ccf (filed 08-26) was right. And this panel hard-coded <no-reply@appolis.app> beside the name from 2026-08-23 (v8.71.0) until today — true for nine days, then a literal the directive's new criterion 2 forbids. WHAT SHIPS: lib/hubmail.js gains identity(env, surface) → GET /id/mail/identity (app-key gated), returning what the door will stamp — {app, name, address, from, fallback, note} — and sendMail now hands back the from, address and fallback the door actually stamped, and console.warns when a mail left from the apex. /api/review/template carries from_identity; the owner panel's From line is rendered by ONE function, rvFromLine(), from the door's verdict: the door's from VERBATIM when it answered cleanly · an amber line on fallback (the apex address it really uses + the door's reason) · an amber line on unavailable (Appolis unreachable, late, or a 200 carrying no address) with NO address shown · and — the case the review caught — an amber line when the key presented is not an app key (app:null → degraded, carrying the door's own note), showing "Appolis <no-reply@appolis.app>" rather than a "Kosmos · <hub>" we composed. POST /api/review/test returns the stamped from/address/fallback and the panel prints "Sent to you as <from>" (amber on fallback) — the WIRE header, which is what an attestation quotes. The static explainer hub-mail-preview.html no longer asserts any address. 🔍 ADVERSARIAL REVIEW BEFORE COMMIT — 5 lenses, 23 findings. The refuters mostly died on a usage limit, so every finding was reproduced by hand and fixed instead: the legacy-key answer dressed up as Kosmos (P1) · a 200 without an identity read as healthy · identity() pre-sliced the surface to 40 UTF-16 units while the door caps AFTER NFKC-folding, so preview and send could disagree and an emoji at the boundary threw URIError — no local cap now · no deadline on a read that sits in series in the panel's GET — 3s now, env.IDENTITY_TIMEOUT_MS for tests · test/hubmail.mjs printed its ✅ summary BEFORE the seven new assertions, so the suite tally read 29 for 36 · the panel's address, fallback, unavailable and legacy renderings were unpinned — a literal apex address passed the suite; four render scenarios + an address assertion now, and mutations M1 (literal address) and M3 (unavailable line deleted) turn it red · from_identity unpinned at the server — pinned in hub-review-flow, 404 path included · reserved characters in the surface, key precedence, and the fallback warn — pinned. 🔍 A SECOND PASS OVER THE PATCHED CODE (3 lenses, 15 agents, all finished; 23 findings, 6 confirmed by two refuters each, 17 P3s reproduced by reading) was taken in full: the deadline now covers the BODY of the identity read, not only its headers · degraded is two facts kept apart — the door does not know this key as Kosmos (app:null with its own note, or a pasted OTHER app's key, which now degrades too) versus this deploy has no APPOLIS_APP_KEY · a 200 without a From line is unavailable, and the unusable branch the door never emits is gone · the test-send passes degraded through and the panel goes amber on it, on a fallback, or on a missing From — and never says "that is the From line Resend received" when none was reported · the unavailable/absent captions stop vouching for a name Appolis will fold and shorten · and 24 more assertions pin the split branch in both directions, the amber colour with the wanted address and the door's reason, the stamped From on the test-send wire, the preview/test-send surface pairing, each half of degraded alone, the non-JSON / no-address / no-From / 401 messages verbatim, the cleared timer, a binding without a key, and an unencodable surface. Mutations M-A (degraded line deleted), M-B (stamped From dropped from the test-send) and M-C (another app's key treated as healthy) each turn the suite red. REFUTED by the docs lens and left alone: the two "stale" comments todo_2357 named (hubinvite.js:517, hubs.js:261) describe the display NAME and are still true. 🧪 hubmail 29→56 · hub-owner-panel 29→42 · hub-review-flow 183→187. Suite 3124 across 71 files, 0 failures; ratchet 0. 🐛 ALSO FIXED, FOUND ON THE WAY: test/invoice-publish.mjs carried dueAt: '2026-09-01' in two fixtures and went red on 2026-09-02 by calendar alone — the owner preview stamped Past due and the published copy did not; pinned to 2099 as line 52 of that file already was, and the real gap it exposed is filed as todo_2372. ⚠️ AND THE VERSION GUARD HAD BEEN RED AT HEAD: e562153 shipped lib/hubs.js under the already-served v8.84.0 (631dac1) and was deployed red — test/version-guard.mjs said so exactly. This build takes a NEW number for that reason too. ⚠️ NOT VERIFIED HERE, and it is the half the directive actually demands: the panel and the test-send were exercised against the DOM double and stubbed doors; no live hub was opened and no real mail was sent by this session. The attestation waits for one real "Send this to me" with the inbox From line quoted verbatim.)DEV was seeded from devNow(), which reads the reviewer's own window width — so opening the hub on a handset put you straight into a drawn iPhone, bezel and all, before choosing anything. That single line also caused the second complaint: “when rotating a mobile device, it is not putting the preview in a landscape orientation”. An emulated device is pinned to the portrait numbers in DEVS, so turning the real phone moved nothing at all. Emulation is now opt-in — DEV starts 'desktop', and the frame appears when you pick Tablet or Phone and not before. 🔄 AND ORIENTATION NOW EXISTS. Every device in DEVS is listed in portrait, so landscape is those two numbers swapped — which is literally what a phone on its side reports as its CSS viewport. One helper, devDims(), serves every caller so there is a single place this can be wrong, and there is no second table to get out of step. The toggle labels the action (“Turn sideways” / “Stand upright”), following the rule the frame and fullscreen buttons already set. It also follows a real rotation: if the reviewer is holding something small, their turn is the instruction — orientFollow() tracks it on orientationchange and resize; on a desktop, where nothing rotates, it stays manual. ⛶ DIALOGS WERE INVISIBLE IN FULL SCREEN, and existed the whole time. dvFull() fullscreens #docview, and a fullscreen element is the only thing the browser paints — so a card appended to document.body was in the DOM, was focusable, took the click, and could not be seen until Escape. Reported as “I click note and it does not show until I exit full screen”. ovHost() mounts overlays and toasts into document.fullscreenElement when there is one. 🎯 A NOTE IN THE LIST NOW TAKES YOU TO IT. The list opened the note and left the document where it was, so which line was this about stayed a hunt. The shell has no reach inside the frame, so it asks (act:'goto') and the layer scrolls, centres and flashes the element. An orphan says so rather than scrolling somewhere arbitrary and looking broken. 🗂 AND THE LIST IS GROUPED BY THE PART OF THE PAGE IT IS ABOUT. secOf() only ever returned a data-sec id — which the landers stamp and the decks do not, so every note left on a deck grouped under nothing. New secLabel() answers the question a person would ask: the nearest labelled container (data-title, data-sec, aria-label), then the heading inside the enclosing <section>, then the last heading passed in reading order. ⚠️ It is recomputed by the frame on every draw, so notes left before labels existed are retro-labelled instead of piling into “elsewhere” forever; orphans are reported empty and collected honestly at the end. 🔒 THE FULL LIST IS NOW THE OWNER'S ALONE, and this deliberately overturns v8.73.0. That build gave the roll-up to every viewer, reasoning that reading what has been said stops a review repeating itself — still sound, and outranked: the list carries the 🤖 hand-off flags and is the queue work is assigned from, which makes it a control surface. ⚠️ Nothing is hidden from a reviewer — every pin on the page, and every note and reply behind it, is unchanged. Only the roll-up is gated. 🧪 test/hub-mark-layer.mjs 49→60 and test/hub-shell-runs.mjs 120→141. The owner gate is tested by rendering the page twice from the server, once with the owner cookie and once without — ME is a var inside the shell IIFE and is not reachable from outside it, which is correct, and means poking a flag would have tested nothing. The double gained docorient and El.prototype.querySelector, both of which devOrientBtn() and secLabel() return early or throw without — omitting them would have let the new assertions pass by never running. 📷 THE BEZEL FURNITURE TURNS WITH THE DEVICE, caught by looking at the deployed page rather than by a test. Landscape drew the Dynamic Island centred on the TOP edge, where a real iPhone puts it on the left, standing up — the camera does not migrate just because the picture did. That is the same class of inaccuracy this file already refuses on device dimensions (“a model listed 20px wrong would send someone hunting a break that does not exist”): a reviewer checking whether a sticky header clears the island in landscape would have been reading a lie. The home indicator genuinely does stay along the bottom edge when turned, so it is deliberately left alone. ⚠️ NOT VERIFIED ON A REAL HANDSET. This session cannot drive a phone; the rotation follower and the fullscreen mount were exercised against the DOM double and the emulated viewport, not on glass.)rates() now ships dwellN (the sample the mean was taken over) and dwellMax (the widest single session in it) beside avgDwell, and the tile states both, adding a plain-words warning when one session runs 10x the mean. That warning is a PROMPT TO LOOK, not a correction. ⚠️ doubleMaxIs is deliberately NOT weighted by _sample_interval, and that is the whole reason it is a separate helper: the sample interval scales COUNTS, because a surviving row stands for n rows — but a MAGNITUDE does not, and multiplying would manufacture a maximum no visitor ever produced. 🐛 A REAL DEFECT FOUND IN THE TEST SUITE ITSELF, and it is the finding of this build. The IF-type guard classified an expression as Double by matching a double column followed by a multiplication sign — a proxy that held only while every double aggregate was a weighted SUM. Against an unweighted MAX(IF(…, double2, 0.0)) it read Double as Integer and demanded a bare 0 — which is precisely the 422 that kills the entire dashboard, the exact failure that assertion exists to prevent. The test would have induced the bug it guards against. It now classifies on the VALUE ARM of the IF, which is what actually decides the type. Mutation-verified in both directions: 0.0→0 turns the SQL guard red, and stubbing the max out of the tooltip turns the UI assertion red. 🧪 test/analytics.mjs 34→40 and test/lander-analytics-ui.mjs 59→63; the UI assertions run against the RENDERED page, not rates(), because an API that carries the fields is worthless if the tile drops them. Closes todo_2229. ⚠️ NOT VERIFIED IN A BROWSER — this session cannot start a dev server, so the tooltip was exercised by rendering the shipped page in the test harness and asserting its markup, not by hovering it.)projectRulesRow. On a side project with no rules of its own that block landed in the function's EMPTY branch — which has no header, therefore no chevron, therefore no way to collapse. Studio 7 Sessions' five real standing rules run to roughly 1,500 characters, so a side project of it opened with a wall of rules it did not write, pushing every panel down the page. In the tests each rule was one word. The empty branch is now split: genuinely no rules renders the plain one-liner it always did, while inherited-but-none-of-its-own gets a real collapsible header — count, provenance, and a chevron — defaulting to CLOSED, because these are reference material this project did not author. toggleInheritedRules mirrors toggleProjectRules, per-device in localStorage. ⚠️ THE PATTERN IS NOW UNMISTAKABLE AND WORTH STATING AS A RULE FOR THIS APP: a stub proves the LOGIC and never the PROPORTIONS. Three times in two versions — the summary (~1,400 chars), the session chips, and now the rules (~1,500 chars) — a field the tests filled with a placeholder turned out to be enormous in real data, and each one was a layout failure no correctness assertion could ever catch. Any new surface that renders a user-authored string should be looked at on the real board before it is called done. 🧪 test/side-projects.mjs grows to 51: closed by default with the long text genuinely ABSENT from the markup (not merely hidden), the count and provenance still stated while closed, a chevron offered, opening reveals the rules, and a project with neither its own nor inherited rules still says so plainly. Mutation-verified twice — always-expanded and default-open each turn it red. Suite 3037 across 71 files, 0 failures; ratchet 0.)DB.trash is a FLAT ARRAY of project rows, not the {projects:[…]} shape assumed when trashedParentOf was written — its defensive fallback turned out to be load-bearing rather than decorative. And DB is script-scoped, NOT a property of window, while every function declaration is global; that is why a probe reading window.DB returns nothing on this app.)startSideProject/agoraLink); a native Kosmos project had no such button. Agora itself has no sub-project code at all, so there was nothing to port: the reference implementation was Kosmos's own. THE DATA SHAPE IS ONE FIELD: parentId, a bare same-board slug on the CHILD. Not an object (the agoraLink two-shape reader is a permanent tax paid for exactly that), not a children[] array on the parent (a two-sided invariant with three writers and no transaction — sideCount at app.js:1079, computed every render and read nowhere, is the in-house proof that stored counts rot). ⚠️ DEPTH IS CAPPED AT ONE, AND THAT IS THE SAFETY PROPERTY, NOT A LIMITATION. A parent may not have a parent and a child may not have children, so a CYCLE IS UNCONSTRUCTIBLE rather than detected — which means nothing in this app walks a project graph. That matters concretely: projectRules feeds five MCP response paths, and a throw inside render() freezes the previous view on screen PERMANENTLY, because render assigns app.innerHTML only on its last line and the 45s poll swallows its own errors. ⚠️ SAME BOARD ONLY, ENFORCED AT FOUR INDEPENDENT POINTS. sideParentGuard refuses a shared parent and refuses the namespaced acc_x~slug form by shape; shareSafeProject strips parentId OUTBOUND so the link never reaches a collaborator (a bare slug on their board would resolve to THEIR project of that name); the cross-account safeBody strips it INBOUND so a manager cannot re-parent the owner's project; and orphanCount catches anything that slips. mergeSharedProjects is deliberately NOT taught to follow the pointer — one grant must never become a read of the owner's whole board, which is verbatim the v8.1.0 design an adversarial review reverted. 🐛 A REAL BUG FOUND BY MUTATION TESTING, AND IT IS THE FINDING OF THIS BUILD. parentId was first added to the PATCH whitelist — the raw p[k] = body[k] loop with no validation — and validated in its own block below. Three mutations to the guard changed nothing, because the unvalidated write landed FIRST: a self-link then failed for the wrong reason ("that project is itself a side project" — because the write had just made it one), and a rejected value stayed on the in-memory row after the 400. parentId is now deliberately absent from that whitelist, with the reason recorded beside it. A redundant-looking write that masks its own guard is exactly what a green test suite cannot see. WHAT THE CHILD GETS is an ALLOWLIST, not a filter over the project object, so a new money field cannot leak in by being forgotten: identity + summary, the parent's rules as a labelled inherited list (never merged into its own — that hides provenance and silently blows the 20-rule cap), an INDEX of its notes and working lists, recent AI sessions there, and its sibling side projects. Never finance, invoices, shares, clientId, vault notes or archived notes. Parent and child are rows in the SAME db on both surfaces, so this is a .filter() — no endpoint, no fetch, no cache, nothing to go stale. WHAT THE PARENT GETS is one collapsed line of chips and nothing else — that is the anti-muddying half of the ask, and the side projects' notes and to-dos never enter the parent's board or its ai_worklist scope. ADOPTION, NOT JUST CREATION: a "side project of" picker on the Filed-under row links or unlinks an EXISTING project — three of Tyler's four examples already exist as projects, so a create-only flow could not be applied to the work that prompted the feature. Connector: get_project carries main_project / side_projects and standing_rules.inherited; overview carries parentId; ai_worklist gains a family block that names the family WITHOUT widening scope (every filter is projectId === p.id, and widening it would make the one-lane house rule a lie). create_project and update_project take parent — ⚠️ those two need a connector tool-list refresh; listChanged is false on purpose. The response paths do not. ALSO FIXED IN THE SAME PASS, both found while reading: get_project's working_lists and notes were the only read paths in lib/mcp.js missing !n.vault, so a sealed note's title and text went out over the connector; and create_project hardcoded areaId: 'apps' regardless of section, so a side project of a video project filed its notes into apps. 🧪 test/side-projects.mjs (46) drives the REAL handleApi and the REAL handleMcp and lifts the shipped client functions out of app.js by brace-matching. Mutation-verified twelve ways — self-link, slug shape, grandchild, trashed-child (the 3-deep hole: trash a child, re-parent its parent, restore), shared parent, the guard block itself, hard-delete sweep, Agora exclusion, parent vault leak, dropped inherited rules, unflagged missing parent, the client's absence branch and its shared-project guard. Each turns it red. Suite 3032 across 71 files, 0 failures; ratchet 0. ⚠️ NOT VERIFIED IN A BROWSER — this session cannot start a dev server, so the new row and picker were exercised by executing the shipped functions in Node and asserting their output, not by looking at them. Known-not-taught downstream, neither broken: worker.js mapP (Phantasia's video door) and lib/breakdown.js's project table show no family.)mergeSharedProjects withholds archived and vault notes from collaborators (if (n.projectId !== pid || n.archived || n.vault) continue;), and the share banner promises it — so serving their attachments was a revocation hole. Archive a note to un-share it and the images kept loading; a vault note is ciphertext the owner alone can open, and its attachments would have walked out in the clear beside it. sharedAttachmentOk now withholds a file whose only references inside that project are on archived or vault notes. ⚠️ NOTE THE DIRECTION, because it is the whole reason this is allowed at all: using a reference to DENY is the safe mirror of the rule that got v8.1.0 reverted. Never AUTHORIZE on a string the reader can write; withholding on one can only ever give away LESS, never more. The worst a planted reference can do here is hide an image from somebody who was entitled to see it. ⚠️ SCOPED TO THE PROJECT ON PURPOSE. An archived note in a DIFFERENT project must not hide an image the collaborator can legitimately see on this one, and a file still referenced by a visible note in the same project stays visible. Both are asserted. 🧪 test/shared-attachments.mjs grows to 16: archived hides it, vault hides it, a visible sibling keeps it, another project's archive does not reach it. Mutation-verified twice (drop the check → 2 red; drop the project scoping → 1 red). Suite 2986 across 70 files, 0 failures; ratchet 0.)/attachments/ keyed the KV read on the REQUESTER, so a collaborator asking for the owner's image looked in their OWN keyspace and missed. Every image on every shared project was broken. v8.1.0 already tried this and was REVERTED after an adversarial review, correctly: it authorized on a STRING REFERENCE the requester could write (note.attachments is stored verbatim from the client; grantShare needs no acceptance; /api/share/directory hands out every account id), which let any account read any other account's bytes. A reference is not a capability. THIS ONE AUTHORIZES ON PROVENANCE WRITTEN AT UPLOAD. A sidecar attmeta:<uploaderId>:<name> names the project a file belongs to, written server-side at /api/import/attachment. ⚠️ The property that makes it sound: the sidecar key is namespaced by the AUTHENTICATED UPLOADER's account id, taken from the session and never from the body — so whatever a caller claims, the only bytes their claim can ever authorize are bytes already in their own keyspace. A forged claim exposes nothing but your own image. At read time sharedAttachmentOk() resolves the sidecar, loads the OWNER's board and checks a LIVE grant via shares.shareRole — so revoking a share closes the read immediately; a sidecar that outlived its grant would be a permanent leak. ⚠️ AND WHY NOT JUST SCAN THE OWNER'S NOTES FOR THE FILENAME (the obvious cheap fix, which is wrong): a CONTRIBUTOR can write notes into the owner's project, so note.attachments is partly requester-writable and would hand them a read of any file in the owner's space they could name. project.refs would be safe (manager rank) but notes are not, and one door beats two. ⚠️ THE STORED PATH IS UNCHANGED — only the REQUEST is qualified. v8.1.0 changed the stored shape to attachments/@<owner>/<name> and broke the connector's own get_image, which strips @ and / rather than parsing them; that killed the handwritten-scan pipeline. The browser adds @<owner> at render time via attSrc(), and nothing else moves. ⚠️ FAILS CLOSED, AND THERE IS DELIBERATELY NO BACKFILL. Images uploaded BEFORE v8.81.0 have no sidecar and stay broken on shared projects — re-upload one and it works. Inferring provenance from note.attachments to fix history would reintroduce the exact hole above, so it is not done. 🧪 test/shared-attachments.mjs (12) drives the SHIPPED worker.fetch over a mocked platform and reads the real bytes back: the positive case, plus the four that matter — provenance for a project I am not on does NOT authorize me, no provenance fails closed, revoking the share closes the read, and a bare path is still strictly my own keyspace. Mutation-verified four ways (gate never called → 4 red; missing provenance allowed → 1 red; live grant unchecked → 3 red; sidecar keyed from the body → 1 red). Client attSrc is lifted out of app.js and executed, not grepped. Suite 2982 across 70 files, 0 failures; ratchet 0.)The drawn device shipped in v8.77.0 is the right default — it is what answers "does the sticky order bar collide with the home indicator" — but it costs real estate twice: the bezel eats pixels, and a tall phone gets scaled down to fit the window. An iPhone 17 Pro Max in a 900px-tall window lands at 0.82, which is fine for judging layout and tiring for reading copy.
⚠️ WHAT FRAMELESS KEEPS IS THE WIDTH, AND THAT IS THE WHOLE DESIGN. Width is the only property that changes how these pages lay themselves out; drop it and this stops being a phone preview and becomes a narrow desktop window. So the exact device width stays and everything that is merely presentation goes: bezel, notch, home bar, side buttons, the fixed device height, and the fit-to-window scale. Measured live at 440×956 in a 900px window: framed, the page draws at 360×783 at 0.82; frameless, at 440×803 at 1:1 — 80px more width on screen and full size, with the layout byte-identical because the CSS viewport is 440 either way.
⚠️ The label says what pressing it DOES — Hide the frame / Show the frame — following the rule the full-screen button in this file already set: a toggle that always reads the same word is half a label, because you cannot tell from it which way you are about to go. It is offered only alongside a device, because on Desktop there is no frame to hide.
⚠️ AND ONE BUG THE TEST CAUGHT BEFORE ANYONE SAW IT. devModels() returns early on Desktop, and the first cut called the frame toggle's renderer at the BOTTOM of that function — so switching back to Desktop left a "Hide the frame" button sitting in the bar with no frame to hide. It is called before the early return now. Choosing a different phone while frameless also had to be checked: it stays frameless rather than silently snapping the bezel back on.
🧪 Suite 69 files, 0 failures; hub-shell-runs 120. The toggle is DRIVEN, not string-matched: framed→frameless→different model→framed→Desktop, asserting the CSS viewport width survives every step, that the scale is dropped and restored, that the notch and home bar go and come back, and that the control hides itself on Desktop.)
WHAT THE DATA SAID. John's grant on that hub read revoked:false, gen:3, and there was no revocation tombstone anywhere in the namespace. gen:3 means reinstate had run and succeeded three separate times. So nothing was stuck and nothing had failed — every write he made had worked. The word he kept seeing was not a row label at all.
WHERE IT ACTUALLY CAME FROM. The INVITE BOX. Typing a revoked person's address and pressing Invite and email them refuses with "that address was revoked from this hub — reinstate it deliberately if you want them back in", which the panel printed as "Not invited: …" and stopped. ⚠️ The refusal itself is correct and stays — quietly readmitting somebody who was thrown out is precisely the bug it was written to prevent (v8.7x). But the invite box is exactly where an owner reaches for "give them access again", and the answer named the fix without saying where it lived. A guard that is right and unactionable reads, from the outside, as a thing that is broken.
THE FIX IS A DOOR, NOT A WEAKENING. That one refusal now offers its own remedy in place: the message explains why it is refused, and a "Let them back in, then invite" button reinstates and immediately re-runs the invitation — with the typed name and job still in the fields, because those are only cleared on success. One tap from cul-de-sac to done. The server-side guard is untouched and a test now pins that it is still there, so this cannot quietly decay into "re-inviting a revoked person just works".
⚠️ AND THE PLUMBING THAT MADE IT UNFIXABLE. rvJson reduced every failure to new Error(message) — so a refusal carrying a machine-readable reason ({revoked:true}) arrived at the catch as prose nothing could branch on. The body now rides along on the error. That is the general lesson: a server that answers with a reason is wasted on a client that only prints it.
⚠️ ONE MORE SILENT DEGRADE, FOUND WHILE LOOKING. rvRefreshRoster swallowed a failed refresh entirely — "the panel keeps what it has rather than blanking", which is the right instinct and the wrong implementation. Not blanking is correct; doing it silently is the degrade-and-report-success shape the house bans, and it produces exactly this complaint: the action succeeds, the refresh quietly dies, the row still shows the old state, and the owner presses the button again and again while the panel tells them nothing happened. It now keeps the list AND says the list is stale.
🧪 Suite 69 files, 0 failures; hub-owner-panel 29. ⚠️ Driven in a real browser, the whole recovery: invite refused against a server that says revoked → the explanatory message and the recovery control appear → one tap reinstates → the invitation re-runs automatically → the fields clear on success. Two calls to the invite endpoint, one to reinstate, in that order. And the guard is asserted still present in hubreview.js rather than assumed.)
hub_edit_reviewer, hub_remove_reviewer, hub_resend_invite), because half the management living only behind a browser panel is how a list goes stale.🎯 REMOVE IS NOT REVOKE, AND THE DIFFERENCE IS THE WHOLE FEATURE. Revoke is a decision about a PERSON: they are out, it is recorded, and a tombstone deliberately refuses any later re-invitation so nobody is quietly readmitted. Remove is about the LIST — this row should not be here at all: added twice, wrong address, a colleague who left before ever signing in. So remove clears the tombstone too. Without that, the worst possible state: the person vanishes from the roster and re-inviting them fails with "that address was revoked", pointing at a record the owner can no longer see. ⛔ Neither touches their NOTES, ever — destroying somebody's review because a list was tidied is unrecoverable, and the notes are the entire point of the exercise. Their name stays printed on every one.
⚠️ EDIT DOES NOT REWRITE HISTORY, AND SAYS SO. Name and position are stamped onto each mark when it is written, so correcting a typo cannot retroactively fix notes already left — and an owner who fixes a misspelling and sees nothing move would reasonably assume it failed. Both the panel and the tool state it plainly: the roster and every future note change; existing notes keep the name they were signed with, because that stamp is the record of who said what and rewriting it would put words in somebody's mouth under a name they never used. Edit also leaves gen alone (so it does not sign out every device they confirmed), leaves at alone (when they were invited is a fact, not a field), keeps the grant id stable, and works on a revoked person — their name is beside every note either way.
✉️ RESEND HAD TO GET PAST A GUARD THAT WAS DOING ITS JOB. The invitation carries idempotency key hubinv:{slug}:{grantId}, so a second send returns the original message id and mails nothing — correct as a retry guard, and it meant "resend" could not work at all. Dropping the key would have made the button a way to mail somebody repeatedly by accident, so a resend keys on a one-minute bucket instead: a double-click still dedupes, a deliberate resend an hour later goes. ⚠️ It is refused for a revoked person (the sign-in link cannot work for them, and the mail would read as being invited back in) and on an unpublished hub (nobody can sign in, so it points at a door that cannot open) — each with the fix named rather than a flat failure. ⚠️ And the welcome email now has one definition shared by invite and resend; two copies would have drifted the first time a field was added, and the resend would quietly become a different email from the one it claims to repeat.
⚠️ ONE BUG WORTH RECORDING, because it cost sixteen assertions at once. The first draft added function rvLoad(){ return rvRefreshRoster(); } as a convenience alias — and rvLoad already existed at the top of the panel, where it fetches the template AND the roster and builds the whole thing. A second hoisted declaration with the same name silently replaces the first, with no warning anywhere: opening the panel stopped fetching the wording, the tabs rendered as nothing, and the suite went red across the board for a reason that looked nothing like its cause. The route helper is rvRefreshRoster; there is exactly one rvLoad.
⚠️ AND ONE API SHAPE THAT NEARLY PRODUCED A LYING ERROR MESSAGE. getGrant answers null for a revoked person by design — it is the may this person act accessor, not a raw read. Using it in the resend path would have collapsed two different situations into one message: somebody never invited, and somebody revoked. Only the second has a fix the owner can act on, and they would never have been told it. Both the route and the tool read listGrants instead.
🧪 Suite 69 files, 0 failures. hubreview 76 (up 24): the edit/remove rules, the key sweep, that an empty name falls back while an empty job title is a real answer, that a partial edit changes nothing else, and the pair that justifies having both verbs — after REVOKE re-inviting is refused, after REMOVE it succeeds as a genuinely fresh grant. hub-review-flow 183: all three driven through the real routes as the owner, including that a removed person's note survives, stays attributed, and that the address can be invited again. ⚠️ Verified in a real browser against a roster double: all four controls render, Resend is correctly absent on the revoked row, Edit turns the row into a pre-filled form carrying the history warning, and Remove arms on the first tap before it will act.)
lander_pool_put, plus its byte half at PUT /api/lander/pool/upload/{token}.THE QUESTION THAT PRODUCED IT WAS BETTER THAN THE ANSWER I WAS ABOUT TO GIVE. Tyler upscaled twelve lander photos in Topaz, converted them to WebP and asked me to use them. They were on his machine, and I confirmed against the WordPress REST API that nothing had been uploaded to that site since 7 July — so they had no public URL and the landers could not reference them. I was about to offer to build an R2-backed asset route on the hub worker. He asked instead: "How are we doing it for the current Landers on the Lander Builder right now?" The answer was already in the codebase. The Lander Builder has had an image POOL since it shipped: ⬆ Upload image POSTs to /api/lander/pool, the bytes land in LANDERS KV as media:t_verdant:{name}, and the engine serves them at landers.appolis.app/{slug}/media — public, no auth, Cache-Control: immutable for a week, Access-Control-Allow-Origin: *. That last part is what makes it usable from a hub document at all: the doc frame is sandboxed into an opaque origin, so its images must be genuinely public. No new hosting was needed, and I nearly built a second one. Look before offering to build.
WHAT WAS ACTUALLY MISSING WAS ONLY A DOOR. The pool was reachable from the builder UI and from nothing else — which needs a browser session, so an assistant holding a finished image could only ever say "upload it yourself". The new tool writes into the same KV keys the builder writes and the bytes come back out through the same public route the engine already serves. No new storage, no new public surface, no second home for brand images.
⚠️ IT HANDS BACK AN UPLOAD URL RATHER THAN TAKING THE BYTES. A 700KB image is ~930KB of base64 inside one JSON message, and this was a twelve-image job. Same handshake hub_put_doc has used since P1, and the route sits beside /api/hubs/upload/ above resolveAccount for the same reason: auth IS the token, minted only by an authenticated connector call, single-use, one-hour expiry.
⚠️ IT REFUSES EVERYTHING THE BUILDER REFUSES, and the restatement is deliberate. This route does not pass through the builder's POST handler, so the builder's 2MB cap would not have protected it — a door that quietly accepts what its sibling rejects is how two paths onto one store start disagreeing. And the MIME is normalised to a closed raster set (webp/png/jpeg/gif/avif, image/jpg folded to image/jpeg), never trusted from the caller: these bytes are served from a host that also serves every tenant's live landers, CORS-open and cached for a week, so an active-document type served from there would be stored XSS on that host. Same reasoning as safeFileMime on the hub.
🧪 New file test/lander-pool-upload.mjs, 27 assertions, and it lifts handlePoolUpload out of the shipping worker.js by source rather than testing a copy — the same technique the markup-layer test uses. It pins the key shape against the builder's, the single-use token, the exact 2MB boundary (both sides of it), every refused content type including image/svg+xml and an absent one, and that a root account keeps unprefixed pool names, because tidying that later would break every published lander at once. Suite 69 files, 0 failures.
⚠️ VERIFIED LIVE: the deployed route answers a bogus token with its OWN 404 (upload token unknown or expired) and a GET with its own 405, proving it is wired rather than falling through to a generic handler. ✅ AND NOW EXERCISED END TO END, TWELVE TIMES. Tyler refreshed the connector tool list and all twelve upscaled WebP files went through the door in two batches — 122KB to 696KB, every one accepted, every one live at landers.appolis.app/{slug}/media with image/webp and its exact byte count returned. Spot-checked by fetching three back and decoding the headers: the one he originally pinned comes back a valid 1716×1408 WebP of 468,996 bytes, matching the local file precisely.
🖼 AND THE BLURRY-IMAGE NOTE IS FINALLY CLOSED. All six landers were re-pointed at the pool copies. ⚠️ Treated as the rename it was, per the standing rule: every new URL confirmed live BEFORE a single lander was edited, every consumer of every old filename enumerated across all six first (67 references in total — img-33 alone appeared 12 times), a leftover sweep after that came back clean, and a check that none of the twelve was referenced from a CSS attribute selector rather than an img src — because the two crop fixes shipped earlier that day select on src="img-product-03" and src="img-23-2", and re-pointing a file named in a selector would have silently un-done one of them. Neither was in the set.
MEASURED AT 2560, THE WIDTH THE ORIGINAL COMPLAINT WAS WRITTEN AT: across all six landers, zero broken images and zero upscaled images. The photo he pinned went from being drawn at 3.2× its own resolution (429px source at 1361px) to 1.26× smaller than its source (1716px at 1361px) — from blown up to downsampled, which is the sharp direction. img-37-1, the worst offender on the site at 328px drawn near 1200, is now 1312px at 1224. The seven blends all sit between 1.01 and 1.37. The single remaining sub-1.0 figure anywhere is img-40-1 at 0.89, which is the largest photograph on the site stretched full-bleed across a 2560 screen, was never in scope, and is unchanged.
⚠️ AND THE THING WORTH REMEMBERING FROM ALL OF THIS: none of it needed new infrastructure. The hosting had existed since the builder shipped. The only missing piece was a door, and the reason it stayed missing for that long is that nobody looked — including me, one step from proposing a second hosting system before Tyler asked the question that made me go and read.)
He is right that a bare narrowed rectangle is a poor stand-in. It tells you the WIDTH and nothing about the shape of the thing people actually hold — and "does the sticky order bar sit under the home indicator" is exactly the question a lander review needs to answer. The Phone and Tablet buttons now open a model picker: Galaxy S25 (360×780), iPhone SE (375×667), iPhone 17 (402×874), Galaxy S25 Ultra (412×891), iPhone 17 Pro Max (440×956), iPad Pro 11-inch (834×1194) and iPad Pro 13-inch (1032×1376).
⚠️ THE NUMBERS ARE THE FEATURE, NOT THE DECORATION. A skin that looks like a phone and lays out at the wrong width is worse than a plain rectangle, because it invites trust it has not earned — so every viewport above was looked up against published resolution tables rather than recalled. And the list is chosen for its SPREAD, not for its badges: 360 / 375 / 402 / 412 / 440 covers the range phones genuinely land in, and the two that earn their place are the ones nobody would pick for status — the Galaxy S25 at 360, the narrowest thing anyone will read this on, and the iPhone SE at 375×667, which breaks layouts the other way by being short rather than narrow.
🎨 THE BEZEL IS DRAWN IN CSS, NOT SHIPPED AS AN IMAGE. Every other simulator ships a PNG skin per device; this file has a standing rule against external assets and it applies here as much as anywhere — a phone drawn in CSS scales to any size, costs nothing to load and cannot 404. The parts are a rounded body, the screen, a notch that changes per device (Dynamic Island / punch-hole / none), a home indicator or a physical home ring, and the side buttons. ⚠️ Every overlay is pointer-events:none — a notch that swallowed a click would break marking up the header, which is the part of a lander people comment on most.
⚠️ AND A TALL PHONE IS SCALED, NEVER RE-LAID-OUT. An iPhone 17 Pro Max is 956px of screen plus bezel, taller than most laptop windows once the toolbar is off. The whole device is scaled down uniformly to fit, so the page is still being laid out at the real device width — that is the entire point — while what you see is simply smaller. Measured live: 0.87 at 440×956 in a 950px window, 1.0 for the SE, and the negative margin gives back the space a transform does not, so there is no dead gap under the phone.
⚠️ WHAT IT STILL IS NOT, said out loud rather than left to be assumed: the device pixel ratio cannot be faked from CSS, so a 3× screen is not being simulated. The picker's tooltip says so in words. There is no touch, no mobile browser chrome and no on-screen keyboard either. It reproduces layout faithfully and nothing else.
⚠️ The arrows measure the phone BODY now, not the iframe — hugging the screen would have put them on top of the device's own edge. getBoundingClientRect reports the visual box, so that stays correct on a device scaled to 0.87. Verified in a browser across all five phones: the arrows clear the bezel with a 9px gap on every one.
🧪 Suite 68 files, 0 failures; hub-shell-runs 104. The picker is DRIVEN, not string-matched: every model is selected in turn and its applied viewport asserted, including that no two models produce the same one — a picker where two entries do the same thing is decoration. The fit-to-height is tested in a deliberately short window AND a tall one. ⚠️ Two assertions in hub-review-flow pinned the single-width model that no longer exists and were rewritten, not deleted.
📥 SEPARATELY, THE SOFT PHOTOS ARE COLLECTED at F:\FML\AI to the MAXX\Lander builder\needs-upscale — the twelve files that need re-exporting, with a README giving each one's real size, where it appears, the width it is actually drawn at, and what to export. Tyler has his own Topaz licence and asked for the files rather than spending Higgsfield credits; there is no Topaz MCP connector in the registry, checked, so this is the honest route. ⛔ Nothing was generated — the house rule is that no generation fires without approval, and handing over source files is not one.
⛔ AND cmp_34f01cf47c IS WITHDRAWN BY THE OWNER — "don't worry about the email line thing then, if it has to be done in another place." Kosmos's half shipped in v8.76.0 and stands; the From line is Appolis-side and stays filed as sug_8523034ccf. This lane is not pursuing it further.)
THE SERVER WAS NEVER WRONG. Marks are keyed hubmk:{slug}:{docId}:… and GET /api/marks?docId= can only read one document's prefix; the roster confirmed one note on The Photo Edition and one on Black & Gold, nothing anywhere else. The leak was entirely in the browser, in MKFRESH — the read-after-write hold added in v8.71 so a note you just left does not blink out of existence while KV catches up. It was keyed by mark id alone, so the merge injected it into whatever list passed through next, including a completely different lander's. And its release condition is "once the server list carries it" — a condition another document's list can never satisfy — so it did not expire either. One note stuck to every lander until the tab was reloaded, inflated the count on all of them, and drew as an orphaned pin in the corner because its anchor cannot resolve on a page it was not written about.
THE FIX IS SCOPING, NOT CLEARING. Wiping the overrides on navigation would have hidden the symptom and thrown away the protection: leave a note, click to another lander and back, and the hold would be gone exactly when it is needed. Every override now carries the document it belongs to and is only consulted for that one. ⚠️ The same defect was latent in the delete hold, in the opposite direction — MKGONE released as soon as any other document's list came back without the id, so a deleted note reappeared the moment you navigated away and returned. MKEDIT and MKAI are scoped for the same reason. A record that arrives without a docId is now never injected at all: an unattributable override is what caused this, and losing a pin for a few seconds is cheaper than putting a note on the wrong page.
⚠️ AND THE TEST THAT COULD NOT HAVE CAUGHT IT — WHICH IS THE REAL FINDING. hub-shell-runs ran the shell inside new Function('window', …), which passes window as an ORDINARY ARGUMENT. In a browser window IS the global, so window.hubCloseOv = … creates a binding; in that harness it set a property on a plain object and created nothing, and the shell's own hubCloseOv() threw "hubCloseOv is not defined" the moment anything actually saved. Nothing noticed, because that throw is caught and reported as a toast the double then removed on a zero-delay timer. So no save had ever completed under test — the whole write path was unreachable, and every assertion about it was a string match on the served source. The shell now runs in a real vm context where window is the global; the double resolves elements built at runtime instead of storing innerHTML as an inert string; and it remembers every string put on screen, so a transient error is evidence instead of a vanished toast. Turning it on immediately surfaced a second gap it had been hiding: clearTimeout was never provided at all.
🧪 Suite 68 files, 0 failures; hub-shell-runs 79. The regression test drives the actual sequence — open a lander, leave a note, receive the eventually-consistent list that does NOT yet contain it, open a different lander — and checks both directions plus the return trip. ⚠️ Three assertions pinned the pre-fix source strings and were updated deliberately, not to go green.
🖼 AND THE SECOND REVIEW NOTE, ACTIONED — "Same image cropping here as well", pinned to the finale shot of Black & Gold. Same defect as the first note on a different picture: img-23-2.png is 728×574, landscape — the bag standing beside a shake — and every lander forced it into a 4:5 portrait box with object-fit:cover. Measured on Black & Gold at 2560: the picture was being drawn 571px wide inside a 360px box, so 211px was thrown away — about 37% of it, which is the bag on one edge and the straw on the other. ⚠️ He saw it on one lander; it was in all six. Fixed by giving the frame the picture's own 728:574 shape rather than letterboxing it — contain alone would have left dead space inside a frame that carries a border, an outline and a drop shadow. Measured after: 360×284 drawn in 360×284, nothing lost, and 226×178 at 390px wide with no sideways page scroll. Scoped by src, appended last so the equal-specificity mobile rules cannot win.)
🤖 THE ROBOT. "I would like to incorporate our little robot icon like we have on Kosmos to assign to-dos and tasks to the AI. I like that feature. I'd like to incorporate it here." A note on a hub now carries the same dim-grey-until-you-tap-it robot the board has — lifted verbatim from public/style.css:449-451 rather than restated in this file's idiom, because a second visual language for one gesture is how people stop trusting either. ⚠️ Owner-only, and not rendered for anyone else: it queues work onto the owner's AI, so a reviewer pressing it would be assigning homework to a stranger. A control you can see and cannot use is worse than one that is not there. ⚠️ It is an EVENT, not a field — the store has never mutated anything, so the flag is appended the way a resolve is and folded back on read; un-assigning is simply a later event and the history of who handed what over survives.
🖍 AND THE HALF THAT WAS MISSING ENTIRELY. A session could READ a review and could not answer one word of it: no reply, no marking done, no handing it over. So the loop ended wherever the fix ended and the person who raised the note was never told — which is the shape of feedback nobody bothers to leave twice. Three tools now: hub_reply_mark answers in the thread, hub_resolve_mark closes or reopens, hub_assign_mark is the robot from the connector side. ⚠️ The blocker was not the tools, it was the roster: hub_reviewers returned every note's words and NO handle — no mark id, no doc id, and a single lossy on: string in place of the anchor. It now carries id, doc_id and the full anchor, and its marks object is keyed by document id rather than title, because two documents may share a title and the old shape silently dropped one of them along with its whole conversation. Flagged notes surface as ai_queue on the roster and as hub_notes on ai_worklist for the hub's project — project-scoped deliberately, since the index that makes it one KV get is keyed by project.
⏱ AND ONE REAL DEFECT THE NEW TOOLS MADE REACHABLE. The fold replays entries in key order and lets the last win, so two events on the same mark inside the same millisecond folded in whatever order their random ids happened to sort in. A person cannot do that; a script can, every time — resolve then reopen from a connector would land backwards often enough to look like the tool ignoring you. Timestamps are now strictly increasing per isolate. ⚠️ An explicitly supplied timestamp is obeyed, never clamped — the first cut applied the guard to every write and silently overrode any caller that passed a time on purpose, which is the same failure shape as a catch that reports success.
◀▶ THE CARDS SLIDE NOW. "When looking at tablet or mobile view from your desktop, there's no way for you to swipe to the left and right to see cards or make them slide. So can we incorporate some sort of a arrow system that goes on the outside that doesn't interfere with the actual page…" He is right, and it is not a mouse problem: these rows are overflow-x:auto with scroll-snap-type:x mandatory and no scrollbar (the landers hide it outright), which on a touch screen is a swipe and on a desktop is nothing at all. Arrows now sit in the gutter beside the device frame and ask the document to scroll, because the frame is sandboxed into an opaque origin and the shell cannot reach inside it. Three conditions, all required: a narrowed frame; the document having actually reported a sideways row in view; and measured room beside the frame — previewing the phone layout on a phone leaves no gutter, and an arrow floating over the content would be exactly what he asked us not to build. The layer drives the row with the most of itself on screen (these pages stack several, and the first in document order is usually off screen), by 85% of the visible box so scroll-snap finishes the job. Arrow keys do the same, and never when a field has focus. ⚠️ applyDev() rewrites the frame's class wholesale, so the arrows are re-applied from inside it — beside it they would vanish on the next device switch.
🏷 AND THE EMAILS — cmp_34f01cf47c, closed. Appolis did its half in v0.20.0 (the From name derives from the app key); the subject and the body were ours and had five real gaps. (1) The subject named a person and no action — "Tyler would like your eyes on X" arrives from a verified corporate domain looking like personal mail from a stranger; it now says "has asked you to review". (2) The sign-in code mail wrote every sentence TWICE, once for HTML and once for plain text, and the drift had already started — an em dash in one half, a hyphen in the other. One source now, both halves derived. (3) Dark mode was never checked at all. The accent is stored with no format validation, so a near-black hub accent took the wordmark and every heading to invisible and painted the sign-in code itself in near-black on near-black — the directive's own "a button nobody can read is worse than no button", live. Contrast is now computed in both directions and the ink on any accent is chosen, not assumed. (4) The palette was Hermes's, borrowed with the template it was modelled on: #12121A/#1A1A24/#D6BB60 belong to neither app, and the wordmark's shipped fallback was the literal string APPOLIS. Both mails are Kosmos's own colours now, and there were three different defaults for one accent — shell #f5d76e, mail and sign-in page #D6BB60 — reduced to one. (5) The app's name was deletable: the footer is owner-editable, so rewriting it left a mail naming no application anywhere in its body. That line is rendered outside the editable field and no override reaches it. Also: the code is no longer repeated in the preview line (it is in the subject, which is the convenience — the preview line just rendered the secret a second time on a locked phone), the invitation finally says what happens if you ignore it, and hubmail says so out loud when APPOLIS_APP_KEY is missing, because that path sent successfully as an anonymous caller — not as Kosmos — and nothing anywhere mentioned it.
🧪 Suite 68 files, 0 failures. New coverage where it matters: the arrows are executed, not string-matched — hub-mark-layer drives the real layer against a page carrying a real carousel, a wide-but-not-scrollable block (the trap) and an off-screen row, and hub-shell-runs drives the served shell through appear/disable/place/hide with measured gutters. ⚠️ That second one caught a live defect: sends are gated on the frame's ready announcement, so an arrow shown on the strength of a nav report alone would have been a live control that did nothing when pressed. hubinvite-contrast now checks four accents including pure black. ⚠️ Three tests asserted the OLD behaviour and were changed deliberately, not to go green: the title-keyed roster shape (which was the bug), and two pinning the old subject line.)
THE CAUSE: the store is eventually consistent, so the refresh fired immediately after a write can still be served the state from BEFORE it. A previous session already met this for ADDED notes and built mkMerge/MKFRESH so a new note does not blink out of existence — but it only ever covered additions. An edit was already in the server list, so the stale copy won and the new wording was thrown away on the next refresh. A delete was also still in the list, so the note was simply re-added and reappeared — which reads exactly like a button that did nothing. Both writes had in fact SUCCEEDED every time.
Deletes are now held out of the list and edits shown over the stale text until the store catches up. ⚠️ Every override carries its own release condition — an edit lets go the moment the server text MATCHES ours (an exact comparison, so there is no clock to skew), a delete the moment the server stops returning it, and both have a five-minute backstop. That is the trap the original comment warned about: a local shadow that never lets go would permanently mask a change somebody else made.
⏱ AND WHY BOTH WERE SLOW. Editing and deleting are gated on AUTHORSHIP, so the route reads the whole folded list to find the note and check who owns it — and then the store listed every key AGAIN to ask whether the note exists, an answer it had just been handed. Two full scans per action. The second is now skipped when the caller has already proved it, opt-in so nothing skips the check by accident. ⚠️ addReply and setResolved are NOT preceded by that read and keep their check exactly as it was — theirs is the only thing standing between a reply and an orphan.
⏳ PROGRESS WHERE THE EYE IS. "The button just goes into the dot dot dot state. No indicator of a progress bar or anything either." There was a bar — a 3px line at the top of the PAGE, which is invisible when you are looking at a dialog in the middle of it and can be off screen entirely on a phone. The open card now carries its own bar, driven by the same single busy driver.
👤 PINS ARE PEOPLE NOW, NOT NUMBERS. "The dots for the note points should be the person's initial, not numbered and then they can be color coded also so they're different per person." Six reviewers used to produce pins reading 1..20 in one colour, so you had to open every one to learn whose it was. A pin now shows their initials in a colour derived from who they are — stable, so the same person keeps the same colour wherever they comment. ⚠️ Keyed on name + position, never the email: publicView deliberately strips the address before the list ever reaches that frame, and it must not be reintroduced just to pick a colour. A settled note stays grey and a moved one stays amber — a state the reader must not miss outranks identity.
🖱 AND THE PIN YOU COULD NOT CLICK. It listened for click alone. A 24px target sitting on top of a real landing page full of its own handlers, inside a sandboxed frame, now listens for pointerup as well, takes the event in the CAPTURE phase so nothing underneath can swallow it first, guards against firing twice, and sets pointer-events/touch-action explicitly. The capture handler that suppresses the page while marking now recognises a pin by walking UP from the target, because a tap can land on something inside the pin rather than on it. ⚠️ This is a considered fix, not a confirmed one — the pin still cannot be clicked here, so it is the one thing in this release that needs Tyler to try it.
🧪 Suite 68 files, 0 failures; hub-mark-layer 35, hub-shell-runs 49. ⚠️ The layer change broke the DOM doubles in hub-mark-geometry and hub-mark-misattach, which lacked addEventListener — both taught it, rather than weakening the layer to suit them. Measured live in a browser: the card progress bar renders at 3px in the hub accent, animating, on a relatively-positioned card.)
Deleting a note APPENDS a delete entry and leaves the original entry in place — that is the whole point of an append-only store, and every read folds the deletes back in. But countsFor was the one reader that did not: it listed KEYS and counted every one containing ":mk_", which is the original. So a deleted note went on being counted forever, and the number beside a document drifted further from the truth with every tidy-up.
It now counts what listMarks actually returns. That costs a read per document instead of a key listing, and it is the only way to be right — the sole caller (rosterPayload) already loads those notes anyway, so in practice it costs nothing. ⚠️ The cheap version was cheap BECAUSE it never opened the records, which is exactly why it could not see a deletion; there is no version of key-counting that gets this right. 🧪 A test now deletes a note and asserts the count follows, and that the count and the list agree. Suite 68 files, 0 failures; hubmarks 60.)
EACH PERSON NOW READS LIKE THIS: active · signed in now over 2 notes · 1 reply · last note 44 minutes ago · signed in 4 times · last sign-in 40 minutes ago · invited 3 days ago. Somebody who has not turned up says invited — has not signed in yet · invited 3 days ago, and somebody who arrived and did nothing says signed in — no notes yet. The facts line only prints what there is to say; a row of zeroes and dashes reads as broken rather than as empty.
⛔ ACTIVITY LIVES IN ITS OWN KEY, and that is a correctness decision rather than tidiness. putGrant REWRITES the whole grant record whenever somebody is re-invited or has their job title corrected — so anything parked on the grant is one edit away from being erased, and "they signed in four times" must survive the owner fixing a typo in their name. It also carries NO expiry: a session lapses after 30 days and its key disappears, and if sessions were the only record then somebody who reviewed last month would be indistinguishable from somebody who never came at all. Same reasoning that already keeps revocation in its own tombstone key. A test asserts the history survives a re-invite.
COUNTED FROM THE NOTES, NEVER FROM A TALLY. Notes and replies are counted by reading the notes themselves rather than by keeping a counter alongside them — a side-counter drifts the first time a note is deleted and then lies quietly forever. Deleting a note lowers the number, and there is a test for that. Sign-ins are the one thing genuinely recorded, once per successful verification, and never per page view: a page view is not an event worth a write, and the two questions an owner actually asks — did they ever get in, when did they last bother — are both answered by a sign-in.
⚠️ THE HONEST GAP, HANDLED RATHER THAN HIDDEN. Sign-ins have only been recorded from this version, so everybody who reviewed BEFORE it has notes on the board and a sign-in count of zero. Printing "has not signed in yet" beside two notes they visibly left would be plainly wrong, so activity outranks the counter: a note is proof of access. Those people read as active with no sign-in figures, and the figures fill in from their next sign-in. Tested.
🧪 Suite 68 files, 0 failures; hub-review-flow 147. MEASURED LIVE IN A BROWSER against the real roster route with three reviewers in three genuinely different states — the panel rendered "Dana · Legal / invited — has not signed in yet", "Lee · Creative Director / active · signed in now / 2 notes · 1 reply · last note 44 minutes ago · signed in 4 times…", and "Sam · Copy / signed in — no notes yet / signed in 1 time · last sign-in 1 day ago". The panel also derives the status locally when the server does not send one, so a cached page that outlives a rollback cannot quietly show "invited" over a REVOKED person.)
🗒 THE PIN WAS NEVER THE THING TO FIX — IT WAS THE ONLY WAY IN. A 24px dot inside a sandboxed frame was the single entry point to every note, so anything wrong with it took the whole feature with it. There is now 🗒 Notes (n) in the action row, open to ANYONE who can see the page: every note on that document, newest first, each showing the author and their job, how long ago it was left, the element it is pinned to, which screen sizes it applies to, its replies, whether it is done, whether it was edited, and whether it is yours. Clicking a row opens that note with Mark done / Edit / Delete / Reply. Reading is deliberately open — a review where you cannot see what has already been said produces the same note five times.
⚠️ AND THE SILENT NO-OP UNDERNEATH IT. mkOpen began var m = mkFind(id); if (!m) return; — so tapping a pin whose note the shell had not loaded (left on another device, or a refresh that quietly failed) did nothing at all: no error, no explanation, exactly what Tyler described. It now refetches once, opens the note if it arrives, and says "That note is no longer here" if it genuinely went. A silent early return in a click handler is indistinguishable from a dead button.
👥 "NOBODY IS INVITED YET" OVER A REAL INVITEE. The owner reaches the roster two ways — the connector and the hub panel. The HTTP route answered { people }; the panel reads roster.reviewers, got undefined, and rendered zero. Two doors onto the same list, written by two different sessions, each inventing its own field name. Both now return one payload from one function (rosterPayload), so they cannot drift apart again.
⚠️ AND THE TEST THAT BLESSED IT. A test DID cover the route — it asserted rj.people.length === 1 and passed happily for as long as the panel was blank, because it was written against the PRODUCER's shape instead of the CONSUMER's. It now asserts the served route sends the exact key the served shell reads. A test written from the producer's side cannot catch a producer/consumer mismatch; it just certifies it.
🧪 Suite 68 files, 0 failures; hub-review-flow 129, hub-shell-runs 41. MEASURED LIVE IN A BROWSER (the notes list is shell-side, so it renders here even though the document frame does not): the action row reads "🗒 Notes (3) 🖍 Marking… ✓ Done"; the list renders three notes newest-first — "Tyler · Test reviewer · 3 minutes ago", "Lee · Creative Director · 1 hour ago … 1 reply", "Sam · Copy · 4 days ago … ✓ done · edited" — each naming the element and the sizes; clicking a row opens Mark done / Edit / Delete / Close / Reply; Edit swaps in a box prefilled with the note and the caret at the end.
⛔ STILL UNVERIFIED: whether the PIN inside the document frame is clickable in a real browser. The list now makes that a convenience rather than the only route in, but the browser here refuses subframe loads and that has not changed.)
WHAT I DID. v8.72.0 put the invited address onto the invitation link. That URL is built in FOUR places, so I used one find-and-replace across all four and asserted the count matched. Two of those places have a grant in scope. TWO DO NOT — GET /api/review/template (the panel's preview, rendered as if addressed to the OWNER) and POST /api/review/test (the send-me-a-test button, which also goes to the owner). Both got encodeURIComponent(g.email) where no g exists, so both threw a ReferenceError and answered 500. The panel loads the first one on open, so it was dead on arrival.
THE LESSON, PLAINLY: a multi-site replace cannot see scope. Matching the expected number of occurrences proved only that I had found four identical STRINGS — it said nothing about whether the variable I was inserting existed at each one. Each site needed reading. The fix is per-site and obvious in hindsight: the preview and the test-send both carry the OWNER's own address, because the owner is who receives them.
WHY NOTHING CAUGHT IT. No test drove those two routes — the suite covered inviting, signing in, marking, editing and revoking, but never opened the panel itself. test/hub-review-flow.mjs now does: it loads the template, saves a wording change, fires the test-send, and asserts the send goes to the OWNER and to nobody the request body names (a test-send that accepts a recipient is an open relay wearing a friendly name). Mutation-checked — putting g.email back turns it red.
AND A TEST-ISOLATION TRAP WORTH THE HOUR IT COST. The new block first appeared to break the LAYER-INJECTION block that follows it, with a 404 that had nothing to do with either. loadHub keeps a module-level 90-second cache keyed by SLUG (RECENT, lib/hubs.js) and it does not know which env it was handed — so two test blocks sharing a slug are NOT isolated, however fresh their fake KV is. My block seeded an unlisted hub under the shared slug; the next block seeded a public one and was served MINE, so its request was gated. The cache is right for production, where a slug is globally unique; it is a trap for tests. The panel block now uses its own slug, and the reason is written where the next person will hit it.
🧪 Suite 68 files, 0 failures; hub-review-flow 126. Verified against the SERVED panel after deploy, not just locally.)
⏳ 1. "YOU JUST CLICK IT AND IT DOES NOTHING UNTIL IT'S DONE." Saving a note had no feedback of any kind. There is now ONE shared busy driver for the whole hub shell (hubBusy) — reference-counted so two overlapping saves cannot switch each other off, revealed only after a 140ms grace so a fast save does not flash a bar at you, and released in a .then(done, done) so a failure clears it too; a spinner that outlives its request is the bug people report as "the app froze". The bar is indeterminate on purpose: there is no honest percentage for a POST and an invented one that sticks at 90% is worse than no number. The control you actually pressed also reports for itself — "Saving…", disabled — because on a phone a bar pinned to the top of the page can be off screen entirely. Applied to saving, replying, resolving, editing and deleting.
✏️ 2. "YOU SHOULD BE ABLE TO CLICK ON YOUR NOTE AFTER IT'S BEEN DONE… AND MAKE CHANGES." Opening a pin already worked; changing what it said did not exist at all. A note you wrote now carries Edit, which swaps the text for a box in place, puts the caret at the END (a textarea that opens fully selected is one keystroke from destroying the note it was opened to amend), and saves through the same button so there is never an edit box with no visible way to commit it. The sizes travel with the words, and an edit that does not mention sizes leaves them alone — otherwise every reword would silently widen a phone-only note back to everywhere.
⛔ EDIT IS AUTHOR-ONLY — NARROWER THAN DELETE, DELIBERATELY. The hub owner may delete a note while tidying, but rewriting the words inside someone else's note publishes a statement in their mouth under their name. Delete stays owner-or-author; edit does not, and the test asserts the OWNER is refused.
Storage is an append like everything else here, so the original entry survives, the last edit simply wins with no clock comparison, replies and resolution are not disturbed, and editing a DELETED note cannot resurrect it.
🛑 3. "ANOTHER BUTTON… TO RELEASE THE MARKUP STATE INSTEAD OF HAVING TO CLICK ON IT TO TOGGLE IT." A control whose label is the state you are IN is a poor way out — you have to deduce that pressing "Marking…" stops marking. While marking there is now a separate ✓ Done, and Escape leaves marking too. ⚠️ Escape previously closed the whole document; it now unwinds the innermost state first, or the way out of marking would also throw away the page you were marking.
📨 AND THE SILENT DOOR — why a code "was not being sent" from his phone. Nothing was broken. The per-grant rate counter for that address stood at n=1 with its window opened 17:53, and he signed in and left a note at 17:56, so that code worked and was burned. No second request ever reached issueCode and no mail error was recorded — which means the address asked for had no grant. /api/review/start answers a uniform 200 "sent" for an unknown address ON PURPOSE, so a stranger cannot use the door to discover who is invited; for the actual invitee that same silence is a feature that looks broken. ⚠️ The fix must not reopen the oracle, so it is not a better error message: the invitation link now carries the address it was sent to (url-encoded, in the HTML and the text part), the page prefills it, and the confirmation ECHOES the address it just used. Echoing the caller's own input tells a stranger nothing — but it makes a wrong address visible instead of invisible. A remembered sign-in still wins over the link, being the more recent intent.
📦 ALSO RIDING THIS NUMBER, and it is not mine: commit 12d8336 ("a stale sign-in entry restores the address") changed lib/hubs.js AFTER v8.71.0 was finalised without a bump, which left test/version-guard.mjs RED on a clean tree. It is a good change — an expired remembered entry now restores the address without jumping to the code field — and it is complementary to the prefill above, so it is named here rather than shipped unannounced. 🧪 Suite 67 files, 0 failures; hubmarks 58, hub-review-flow 116, hub-shell-runs 31.
⛔ STILL UNVERIFIED BY EYE: the in-document layer — where a pin lands, whether a tap picks the element you meant — remains unproven here, because the browser available in this environment still refuses subframe loads. The SHELL half of all of the above is executed by hub-shell-runs and was measured live; the in-frame half was not.)
kosmos_hub_invite_reviewer. Three phases shipped together, because they are the same surface.── ① SIX VERIFIED DEFECTS FIRST, TWO OF THEM MINE. A 32-agent adversarial read of the feature raised 62 possible gaps; 28 were verified at high+, 21 confirmed and 7 refuted. These land BEFORE the panel because the panel's own buttons are what would trigger the first one.
• ⛔ BLOCKER — THE GRANT ID WAS DECIDED BY AN EVENTUALLY-CONSISTENT KV READ, AND A STALE ONE SIGNED REVIEWERS OUT. putGrant used existing ? existing.id : 'gr_' + newToken(). When that read came back null for a grant that really existed — a double-click, two invites in quick succession, a re-invite from another edge — the record was rewritten with a NEW id, and four things happened at once, none of them visible: reviewerFor rejects a session whose grantId moved, so every confirmed device for that person was signed out mid-review; their live code lives at cKey(slug, grant.id), so a code they had just asked for became unreachable and simply never worked; the invite dedupe key is hubinv:{slug}:{grant.id}, so a duplicate invitation really sent; and at reset, so “invited six days ago” became “just now”. The id is now derived from (slug, address), so the read cannot decide it. Every design the workflow produced added a Reinstate or re-invite button and not one of them named this — the panel as designed was a machine for accidentally signing reviewers out.
• THE SAME STALE READ SILENTLY REINSTATED REVOKED PEOPLE, because the record was rewritten with revoked:false. Revocation is now a separate tombstone key that putGrant never touches, a plain re-invite of a revoked address is REFUSED with words instead of quietly allowed, and reinstating is its own call that bumps a generation counter so it does not wake the browsers they were evicted from.
• 🕶 A PUBLIC HUB EDGE-CACHED AN IDENTIFIED PAGE FOR SIXTY SECONDS. shellHeaders took only the hub, so a public hub answered public, max-age=60 whoever was looking — while the page embeds ME, carrying isOwner and a signed-in reviewer's name, job title and email address. The viewer decides this now, at all four call sites.
• reviewOpen read !!hub.review_open and no hub has ever had that field written, so it was false everywhere: harmless only until something read it, then every review is closed.
• MINE, v8.70.0: canAct excludes the owner when action:'off' (it ends in memberOf, and an owner need not be a member of their own hub), so the grant === 'owner' delete branch I added was unreachable — the owner saw a Delete button and got “verify your email to leave notes” for pressing it.
• MINE, v8.70.0: I fixed the !grant arm to answer /api/ callers in JSON and left its sibling serving HTML with a 200 on it. Half a fix is worse than none, because the case looks handled.
🧪 test/hubreview-grant-stability.mjs (13) uses a KV double that can be told to LIE the way real KV does — returning null for a key it is holding — because the stale read is the entire point. Mutation-checked: restoring the minted id fails exactly the three cases it should.
── ② THE COMPLIANCE DIRECTIVE (cmp_34f01cf47c), discharged in code. Tyler: "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." Recognition is a security property here — a mail that does not match the thing that triggered it is what phishing looks like.
• 🏷 THE WHOLE GAP WAS ONE DROPPED FIELD. Appolis v0.20.0 derives the From NAME from the app key and accepts an optional sender_name naming WHICH surface sent it. hubmail.sendMail builds an explicit body, so sender_name was being silently discarded — Appolis had shipped its half and Kosmos could not reach it. Now forwarded, and the From reads “Kosmos · <hub>”.
• THE SIGN-IN CODE MAIL HAD NO IDENTITY OF ANY KIND. Four lines of unstyled markup, four hex colours, no wordmark, no hub name in the body, no light-mode handling, no Outlook pinning. Rebuilt as renderCode() carrying the hub's own wordmark and accent, a written plain-text twin, and the line the directive asks for by name — what happens if you ignore it. Subject: “Your sign-in code for <hub>: 482913”.
• THE INVITATION'S WORDMARK SAID APPOLIS — a name most of these recipients have never heard, on a mail about somebody else's investor deck. It is the hub now; the From line and the footer name Kosmos, so the app is nameable twice over.
• ⛔ THE HARDENED SHELL IS DUPLICATED BETWEEN THE TWO MAILS ON PURPOSE — DO NOT HOIST IT. The obvious tidy is a shared helper. Don't: that block carries the Outlook [data-ogsb]/[data-ogsc] pinning whose own header says “do not tidy the apparent duplication”, the light-mode overrides and the darkened-accent rule — three interlocking things. Sharing them lets one edit break two mails at once, and the saving is thirty lines. Two mails, two shells, both tested.
🧪 test/hubinvite-contrast.mjs grew to 16, holding the code mail to the same standard as the invitation separately (duplication means a fix to one is not a fix to the other), and asserting the plain-text twin carries the identity too — the half that gets forgotten, in exactly the readers least likely to complain.
── ③ THE PANEL. One owner-only button on the hub's own landing page. Three tabs over two fetches, and it opens on the email, not on the roster — the mail is the deliverable, and the order of operations that cannot go wrong is read it, send it to yourself, check the inbox, then invite.
• The email — the real rendered invitation in a sandbox="" iframe (no allow-scripts: an email cannot run anything and neither should its preview), the From line and Subject shown as they will arrive, a toggle to the written plain-text twin, a read-only preview of the sign-in code mail, and Send this to me.
• Its words — all 22 lines, grouped and labelled from a FIELD_META that lives beside the FIELDS contract, with the {{TOKENS}} explained rather than guessed at. Only lines that DIFFER are stored, so later improvements to the shipped wording still reach every hub that did not deliberately change that line.
• People — who is invited, with their job title, who was revoked (still listed, or “never invited” and “thrown out” look identical), and whose invitation email did NOT send. Invite, revoke with a two-tap arm, and let someone back in.
• Three new owner-gated routes: GET/POST /api/review/template and POST /api/review/test. The test-send goes to the signed-in owner's own address, never one from the request body — a test-send that accepts a recipient is an open relay wearing a friendly name — and it deliberately passes no idempotency key, because the whole point is to send again after changing a word and a key would return the first version's id and send nothing.
• The owner block gained a terminal 405: it matched on path then tested method inside, so a GET to /api/review/invite fell through every arm below it to a generic text/plain 404.
🧪 test/hub-owner-panel.mjs (22) executes the real served script against a DOM double whose innerHTML setter actually builds elements — a double that only stored the string would make every getElementById return null and the test would pass by doing nothing. It opens the panel, drives all three tabs, and asserts every one of the 22 editable lines is reachable.
🧪 Suite 67 files, 0 failures (+2 files, 35 new assertions, and 9 more folded into the mail test). ⚠️ WHAT IS NOT VERIFIED: no real invitation or code mail has been sent yet, so the From line is proven by the door's own source and by assertion, not by a real inbox — which is exactly the proof cmp_34f01cf47c requires, so the directive stays OPEN until Tyler presses Send this to me and reads it back. Nobody has clicked the panel in a real browser either; it is driven by a DOM double, which catches a dead reference but not a layout. 💡 THE LESSON: the highest-value finding of the whole exercise — the grant id — was in none of the three designs. It surfaced only because the judging pass was told to look for what every design had missed.)
test/hub-marklayer-executes.mjs proved the __name shim works — against a hand-written esbuild stub. That pins the wrapper and nothing else: it could not notice a new inner function added to markLayer(), which is precisely the shape that killed v8.65.0 and precisely what v8.70.0 added in host(). The last two cases now apply a keepNames-like transform to the actual shipped source — all 13 inner functions (post, cssPath, textOf, sameTagIndex, secOf, anchorFor, resolve, style, clearDots, host, draw, redraw, unhover) — and run THAT in a stubbed frame, asserting it announces itself, with the mutation twin that the unshimmed form still throws. The name-finding regex asserts it matched more than three functions, so a regex that quietly stops matching fails loudly instead of proving nothing. Test-only; no behaviour change. Suite 65 files, 0 failures.) • 📌 A STALE PATH SILENTLY RE-PINNED NOTES TO THE WRONG ELEMENT (blocker). The anchor ladder's own header swears it 'never quietly re-attaches to whatever now occupies the space' — and the FIRST rung did exactly that: querySelector(a.path) returned its first hit with no cross-check, while every rung below it verified the remembered text. Re-upload a deck with one slide inserted and every stored path STILL MATCHES; it just matches the element that slid into that position. cssPath() also emits an UNROOTED chain (stops below body, caps at depth 8), so a structurally identical subtree mis-resolves with no re-upload at all. The note is then drawn as a CONFIDENT pin — no 'this changed' marker — on a paragraph its author never saw, which is worse than losing it. Fixed by cross-checking tag + text like the other rungs. 🧪 test/hub-mark-misattach.mjs (7) gives every element a UNIQUE rect so it asserts WHICH element the pin landed on, not just the moved count — with a constant rect, right and wrong are the same coordinates. Mutation-checked: all three cases go red without the fix.
• 📍 EVERY PIN WAS MIS-PLACED ON ANY PAGE WHOSE BODY HAS A MARGIN OR A TRANSFORM (blocker). draw() positioned dots at pageYOffset + rect.top and appended them to document.body — assuming the containing block begins at the document origin, unscaled. A body margin shifts every pin by the margin; a transform shifts AND scales. Nothing errors; pins just land near the wrong sentence. Now a zero-height #mk-host is measured each draw: its rect gives the true origin, its rendered width against its declared 100px gives the scale, and pageXOffset/pageYOffset leave the file entirely (a measured origin already accounts for scroll). Also fixes the orphan row, whose x axis skipped the offset while y applied it. 🧪 test/hub-mark-geometry.mjs (7) asserts the actual dot.style.left/top NUMBERS against a declared rect table — the constant-rect double is precisely why this shipped. Mutation-checked on 3 cases.
• 🕶 EVERY REVIEWER'S EMAIL ADDRESS WAS SERVED TO EVERY VIEWER. GET /api/marks returned records untouched, by and all — and that route is reachable by anyone who can VIEW the doc, which on a link-shared hub means anyone holding a forwarded ?k= URL: the full roster of who was asked to review this and how to reach them. resolvedBy had a second path to the same leak (r.name || r.by). Fixed with publicView() in hubmarks — the store KEEPS by (that is the audit trail), the wire gets a server-computed mine boolean instead, replies included, because a nested array is the thing a call site forgets. Owners still see addresses; they invited them.
• ⛔ /api/review/start SWALLOWED THREE REAL FAILURES AND ANSWERED {ok:true,sent:true} (blocker) — the exact 'catch that degrades instead of failing' shape the house rules name. A thrown issueCode (what a missing APPOLIS_APP_KEY looks like, since pepper() refuses to degrade) and the 5-per-hour refusal both ended in a bare catch { return uniform; }. The reviewer waits forever while the roster shows a clean invite. The RESPONSE stays byte-identical — uniformity to the door is deliberate anti-enumeration — but our own record no longer throws the reason away: noteMailFailure for a real fault, a separate short-lived noteRefusal for a rate limit (which is not a fault and self-heals), and a successful send now CLEARS the stamp, so 'broken' means broken NOW.
• 📧 THE ONE EMAIL SIX PEOPLE WILL ACTUALLY READ WAS BROKEN IN LIGHT MODE. Every accent in this suite is picked to sit on a near-black card; the three walkthrough headings, the wordmark and the fallback link are all painted with it — so on a white light-mode card they were 1.87:1, i.e. the part explaining how to get in was the part that disappeared. Fixed by darkening the accent to ≥ 4.5:1 on white for the light block only (channel multiply preserves hue). AND the Outlook recipe was half-written — the file's own header says 'pin every surface AND its text under BOTH' and adds 'DO NOT tidy the duplication', but backgrounds were pinned only under [data-ogsb] and text only under [data-ogsc]; .num was applied to every numbered circle and targeted by NO rule, and the button had no class at all. All eleven classes now pinned under both. 🧪 test/hubinvite-contrast.mjs (7) recomputes WCAG independently of the implementation, and derives the class list FROM THE MARKUP so the next unstyled class fails without anyone updating the test.
• ✏️ THE INVITATION COPY WAS HARD-WIRED TO A LANDER REVIEW — 'Six directions to look through', 'directions for the same page' — with a hard-coded count, about to be sent for a four-deck review. The override mechanism was dead: sanitizeOverrides had no caller and hub.review_email had no writer. Copy is now generic, publishHub takes review_email, and the stored value is sanitised ON READ as well as on write.
• 🚪 THE GATE OFFERED NO ROUTE TO THE REVIEWER DOOR. An invited reviewer has no account here, so every control on the 404 gate is useless to them — and they land there constantly (people share the hub root; an expired session drops them there). Added an UNCONDITIONAL plain anchor to /review: conditional would turn the gate into an existence oracle, and /review is already uniform for every slug including ones that never existed.
• 📱 ON A REAL PHONE, PHONE-SCOPED NOTES WERE INVISIBLE — INCLUDING YOUR OWN, SECONDS AFTER POSTING IT. var DEV='desktop' was a literal: right for the simulator buttons, wrong for the person holding a phone. mkVisible() filtered their own note out on the way back. Nothing errored; the list was simply, quietly, short. Now derived from viewport width at the same breakpoints the simulator uses.
• 🔄 A PIN COULD VANISH FOR UP TO A MINUTE AFTER A SUCCESSFUL SAVE. Every write is followed by a KV list(), which is eventually consistent. MKFRESH now holds server-confirmed records and merges them until the list catches up, then stops shadowing them so a delete made elsewhere is not permanently masked. Separately, a FAILED read used to be indistinguishable from an empty document — it wiped every pin off the page; it now keeps them and says so.
• 🗑 THERE WAS NO WAY TO DELETE A NOTE — no route, no tool, no button. Post on the wrong element and it stayed there in front of everyone. Added as a TOMBSTONE (the store is append-only, like resolve), folded before replies so a late reply cannot resurrect a deleted mark, author-or-owner only — enforced at the route, the only layer that knows both who is asking and who owns the hub. Two-tap confirm, no bare window.confirm (house rule).
• 🔐 'SIGN OUT' ONLY CLEARED THE COOKIE — the server-side session stayed valid for its full 30 days, so the token remained a working credential. endSession now deletes the KV row; the cookie clear is still best-effort-independent, which is the one place a swallowed error is correct and now says so. Plus a Sign out control, which did not exist.
• 💥 A REVOKED SESSION EXPLAINED ITSELF AS Unexpected token '<'. Every shell fetch does an unconditional r.json(), and the gate served them an HTML page. API paths behind the gate now get JSON — fixing hubNote, hubApprove and every mark call at once — and mkPost parses defensively and writes a real sentence into the compose card, leaving the reviewer's typed note exactly where it is.
• 🔓 A RELOAD KILLED THE CODE ALREADY IN THEIR INBOX (blocker). The typed address lived in a plain variable, so any reload, back-navigation or phone app-switch sent them back to step 1 — and re-requesting OVERWRITES the code row, so the commonest possible action was silently the most destructive. Now sessionStorage with a TTL guard (NOT a cookie: never transmitted, tab-scoped, and restored when iOS Safari reloads a discarded tab). Past the 10-minute TTL it restores the address but stays on step 1, rather than dropping someone onto a dead code field. Plus a real 'Send me another code' with a 60s cooldown — previously the only way to resend was a button labelled 'Use a different email', so people used the destructive control to do the harmless thing.
• ⚠️ AN UNPUBLISHED HUB SILENTLY SENDS NOBODY A CODE. hub_reviewers and hub_invite_reviewer now say so up front rather than leaving it to be discovered by the reviewer.
🧪 Suite 65 files, 0 failures (+4 files, 33 assertions). ⚠️ STILL NOT VERIFIED: nobody has yet placed a pin on the live hub in a real browser and reloaded to confirm it returns to the same element, at desktop AND at 390px — the local rig cannot prove clicks reach the sandboxed frame through the real shell. That is the one remaining item, and it needs a signed-in reviewer session.
💡 THE TRANSFERABLE LESSON: four of these hid behind a test double that returned a CONSTANT rect. A double that cannot express the wrong answer cannot fail on it — right and wrong were literally the same numbers.)
HTMLRewriter, built as MARK_LAYER_SRC = "(" + markLayer.toString() + ")();" (lib/hubs.js:715). Wrangler bundles this worker through esbuild with keepNames, which rewrites every inner declaration of markLayer() into __name(post, "post"). __name is a bundler helper that lives in the worker module's scope and nowhere else — and toString() copies the CALLS but not the helper. So the text injected into the document referenced a function that does not exist there, the first statement threw ReferenceError: __name is not defined, and the whole layer died before registering a single listener. Every reviewer would have opened a document where nothing was clickable. ⚠️ WHY IT SURVIVED REVIEW: every check that does not EXECUTE the script passes. The bytes inject correctly (the served deck is 2,086,174 bytes against a 2,077,952-byte upload — the ~8.2KB layer is right there), HTMLRewriter works, and hub_reviewers returns mark_counts: 0, which reads as nobody has marked anything yet rather than nobody can. It was found only by loading the LIVE served bytes in a real browser and reading the console. THE FIX is one line: the injected source is now wrapped as (function(){function __name(f){return f;}( … )();})();, so the bundler's wrappers resolve to identity inside the frame. Immune to whether a future build has keepNames on or off, and costs nothing when it is off. 🧪 test/hub-marklayer-executes.mjs (4 assertions) RUNS the injected source in a stubbed frame via vm rather than reading it, asserting it executes and posts its act:"ready" handshake — plus a mutation check that the unshimmed form still throws __name is not defined, so the test cannot quietly stop testing anything. Suite 61 files, 0 failures. ⚠️ THE TRANSFERABLE LESSON: fn.toString() is not portable out of a bundled worker. Any source shipped to another execution context this way must either be a string literal or carry a shim for whatever the bundler injected — and it must be verified by EXECUTION, because byte-presence proves nothing.)NOW: the three sizes are a single segmented control reading Desktop · Tablet · Phone — plain words, the chosen one filled in the hub accent and carrying aria-pressed, each with a hover line saying what it does and the width it uses. Full screen reads ⛶ Full screen, and once you are in it the label becomes Exit full screen, because a toggle that always says the same thing is half a label and Escape is not obvious to everyone. ⚠️ A first attempt paired each word with an icon and was thrown away: tablet and phone had to be geometric shapes, which render as an empty box wherever the font lacks them — a control that looks BROKEN rather than clearer. Words only, and the suite now fails if a box glyph comes back.
TWO REAL DEFECTS FOUND BY ACTUALLY LOOKING AT IT (a rig serves the real shell to a browser — the doc bar lives in the SHELL, not the sandboxed frame, so unlike the markup layer it CAN be rendered here):
· ⛔ THE VIEWER WAS COMPLETELY DEAD FOR ONE COMMIT. Rewriting the buttons left parts[1] behind after the variable parts was removed. Every test stayed green — they all assert that STRINGS appear in the served HTML, and the string was there. In a browser it threw a ReferenceError inside devBtns, which route() calls, so opening ANY document did nothing at all. test/hub-shell-runs.mjs now EXECUTES the served shell script against a DOM double and drives it — load, open a document, switch size — so a reference error on the path from tile to rendered document fails the build instead of the hub. That test is the real deliverable of this version.
· THE BAR OVERFLOWED AT 760px — 855px of controls in a 760px bar, buttons off the right edge, document name crushed to zero, because the back button carries the hub title and never shrank. It truncates now (the arrow always survives), the name may shrink, and the toolbar scrolls rather than clipping. ⚠️ The tempting fix was stripping words off buttons to save space, which is the exact complaint being answered — so nothing is ever hidden; the row slides. The size switch hides below 900px instead, where an 834px tablet preview cannot be shown honestly anyway.
🧪 Suite 60 files, 0 failures; hub-review-flow 102, hub-shell-runs 16. Measured live at 1280 / 760 / 400: no overflow at any of them, no horizontal page scroll, switching sizes moves the frame to 390 / 834 / full and tracks the pressed state.)
A NOTE NOW CARRIES THE SIZES IT APPLIES TO. Three chips on the compose card, all lit by default. ⚠️ THE DEFAULT IS EVERYWHERE, DELIBERATELY — scoping a note silently to whatever happened to be on screen would hide most feedback from most readers, so narrowing has to be an act, never an accident. The view then shows only the notes that apply to the size you are in, and tells you how many it is hiding rather than letting you quietly see fewer. The thread says which sizes it covers and the width it was placed at. An unrecognised or non-list value is corrected SERVER-SIDE to everywhere — a note visible nowhere is a note lost, and the client does not get to decide.
AND A LATENT TRAP CLOSED WHILE PASSING THROUGH. The size buttons wanted an onclick with a quoted argument, and the only way to write that inside this file's shell script is with a backslash — which the surrounding template literal EATS. It happened to emit correctly, which is worse than breaking: the next person editing nearby would have no idea a swallowed escape was load-bearing. They are built with the DOM and closures instead. The file's own warning comment was the last thing still spelling out a backslash, so it now describes one in words, and test/hub-review-flow.mjs counts backslashes in the SERVED shell script and expects exactly zero — the rule that once made every tile on this page unclickable is finally an asserted invariant rather than a note asking people to be careful. 🧪 Suite 59 files, 0 failures; hub-review-flow 90, hubmarks 45.)
POST /api/review/invite needs an owner cookie a chat cannot produce. A complete feature nobody can start is not complete, so inviting now runs where he actually works — the connector. hub_invite_reviewer records the grant with their name and POSITION and sends the instructions email; hub_reviewers returns the roster and every note, per document, with who left it, which element it is pinned to, the replies and whether it is settled — so the review can be READ without opening the hub; hub_revoke_reviewer withdraws access, immediately, keeping their notes. ⚠️ An invite whose email fails reports ok:false and says the invitation was RECORDED but the message did not leave, with the sign-in URL to pass on by hand — the alternative is someone waiting for an email that never went, which is the precise failure the house rules name. Re-inviting the same address updates their details and, thanks to the idempotency key, does not mail them twice.AND ONE THING A TEST CAUGHT THAT READING NEVER WOULD: the invitation greeted nobody. RECIPIENT_NAME was threaded all the way through and then used in no field, so a message whose entire subject is "your name and position go on every note" opened with no name on it. There is now a greeting, in both the HTML and the plain-text twin, degrading to the address's local part and then to "Hi there," rather than ever rendering "Hi ,". 🧪 271 assertions across the six review files (hub-review-flow 78, hubinvite 47); suite 59 files, 0 failures.)
⛔ IT IS NOT AN APPOLIS ACCOUNT, AND THAT WAS A FINDING, NOT A PREFERENCE. The obvious build is /id/provision/grant then /id/otp/request, and it CANNOT WORK — provision mints the ID with disabled: true, pendingClaim: true (appolis/worker.js:987) because an app-minted identity is deliberately never usable on its own, while findByEmail does not filter disabled — so the code IS emailed, the person types a perfectly good six digits, and /id/otp/verify answers 403 "this Appolis ID is disabled". The only ways to clear that flag are /id/claim (which needs a PASSWORD, defeating the point) or a master admin. There is currently no machine-reachable path that creates an OTP-loginnable Appolis account, so building the obvious way would have shipped a review hub that mails six people a code that can never work. Filed for the Appolis lane. What shipped instead is the pattern Tyler already approved the day before and Hermes already runs for the fulfilment company (report-access.js, 2026-08-21): a revocable grant carrying name + position, an emailed one-time code, and a 30-day re-verify gate. Its hard-won checks are ported deliberately — the typed address must MATCH the invited address (without it, whoever holds the link verifies their own mailbox and walks in), the attempt is counted BEFORE the code is judged, the code is peppered so a KV dump is not a million-row rainbow table, and every session read joins back to the grant so revoking evicts every confirmed device at once, including the case where an admin CORRECTS a typo'd address — they believe they moved the access, and without that re-check they have not.
THE HALF THAT WAS ACTUALLY MISSING IS INSIDE THE PAGE. Uploaded HTML knows nothing about the hub, so there was nothing in the frame to point at an element. A layer is now injected into /d/{id}/raw by HTMLRewriter (streaming — the alternative is buffering a quarter-megabyte of lander per request to do a string splice). ⛔ It can only look and report. The doc frame is sandboxed WITHOUT allow-same-origin, so it has no origin, no cookie and no credentialed fetch — that sandbox is exactly what makes it safe to serve arbitrary tenant HTML on one shared host, and test/hub-sandbox.mjs pins the token list closed. Every write therefore goes up to the shell by postMessage and the shell, which holds the session, is the only thing that writes. The payload is byte-identical for every viewer so the existing cache stays honest. It is written as a real function and injected via toString(), not as a string constant, so node --check actually parses it — a big string literal proves nothing, and that gap is how a stray backtick has taken these pages down before. The shell's listener is a SECOND listener placed strictly after the pay one, because test/invoice-pay-bridge.mjs slices the first one out of the source by string index and executes it with six names in scope.
ANCHORS SURVIVE WHAT THEY CAN AND ORPHAN HONESTLY WHEN THEY CANNOT. These landers were re-uploaded six times in one morning, so a note must outlive an edit. Six signals are stored and resolved most-trustworthy-first: css path → tag+index+text → unique text, scoped by the data-sec section wrapper the Lander Builder stamps for exactly this reason (absent on the six hand-built FLIP 7 pages, so it narrows the search and is never a key). ⚠️ AMBIGUOUS IS ORPHANED, NEVER GUESSED — two candidates is a degrade, because a comment silently re-attached to the wrong element is worse than one labelled "this moved", and an orphan is parked in amber with its remembered words rather than dropped.
STORAGE: one KV key per entry, append-only — and the obvious reason is the WRONG one. It is not lost updates: saveHub already goes through the HubDoc durable object with a revision header and withHub replays on 409, which test/cas-hubdoc.mjs pins. Marks are kept OFF the hub record because that record is loaded on every single request to the hub, and a few hundred comments would make every visitor parse a conversation they did not ask for. Nothing is written to the hub record or the board when a mark lands, so six reviewers marking the same afternoon contend on nothing — this file's boardStore has no CAS at all, which is worth knowing before anything else writes there.
TWO THINGS FIXED IN PASSING. (1) A stored XSS on the shared hub origin is closed. DOCS, BASE and ME were interpolated into a <script> with bare JSON.stringify, which does not escape < — a doc title containing a closing script tag executed on hub.appolis.app, the one origin the whole sandbox design says no tenant string may ever hold. Adding a reviewer's typed name to ME would have widened it, so jsonInScript() now escapes < and U+2028/9 on all three. (2) ctx is threaded through the worker (fetch(request, env, ctx) → handleHub(request, env, ctx)), so the code send rides waitUntil — awaiting it made the response measurably slower for an INVITED address than for a stranger, which is an oracle no amount of body-matching closes. Appolis hit the same thing on its own OTP route. ctx stays optional; the sandbox test calls with two arguments.
🧪 246 new assertions across six files — hubmail 29, hubmarks 38, hubreview 50, hubinvite 43, hub-review-flow 55 (the whole journey through the REAL handleHub, with a fake HTMLRewriter installed because Node has none and the interesting branch would otherwise never run), hub-mark-layer 31 (the ACTUAL layer source executed against a DOM double). Suite 59 files, 0 failures; ratchet clean. ⚠️ WHAT IS NOT VERIFIED, said plainly: nobody has clicked this in a browser. The pane available in this environment refuses subframe loads outright (net::ERR_BLOCKED_BY_CLIENT), so hover outlines, where a pin physically lands, and whether a tap on a real lander picks the element a person meant are UNPROVEN and need one pass by hand on the live hub. The handshake — the layer answers hello as well as announcing ready — was added defensively against a real race (a message posted before the listener exists is not queued, it is gone), NOT because that race was observed; the observation was the blocked iframe, and saying otherwise would be inventing evidence.)
/mcp sees the 🏛 house rules and the owner's 📜 standing rules — those live in Kosmos's db — and saw nothing at all about compliance. That is exactly the session most likely to be doing Kosmos's work, i.e. the app that owes the directive. THE DESTINATION WAS BUILT FIRST, BY THE APPOLIS LANE (GET /id/compliance?app=kosmos, app-key-gated, read-only and self-only — an app may see what it owes and attest, never edit its own obligations). This is the caller. complianceFor(env) in worker.js reads it over the existing APPOLIS_ID service binding using our own APPOLIS_APP_KEY — not the legacy shared secret, not the master cookie — and both MCP doors hand the resolved answer to handleMcp on ctx. lib/mcp.js renders it on the same five paths the house rules already ride — the initialize handshake, overview, get_project, ai_worklist and the first-write echo — reusing that delivery rather than building a second one beside it, so a session that skipped the handshake still meets its directives on its next action. ⚠️ FAIL SOFT, NEVER FAIL SILENT — the criterion that actually matters. An unreachable Appolis must not stop the connector opening, but could not check and nothing owed are DIFFERENT ANSWERS, and an empty list on error reads as a clean bill of health nobody earned. Appolis answers 503 with unknown:true for exactly this; it is surfaced as "COMPLIANCE COULD NOT BE CHECKED", never swallowed. ⚠️ NO CACHE, DELIBERATELY. One fetch per request cannot outlive the session that read it, and a cached "nothing owed" can never survive a failed refresh — the staleness criterion is satisfied by construction rather than by an expiry someone has to get right. DIRECTIVES RENDER AS WORK, under their own compliance key with wording that calls them work and says compliance comes before new feature work — a session must not be able to mistake a mandate for the advisory rules sitting beside it. Nothing owed renders nothing at all. ⚠️ ONE THING THIS TURNED UP: overview, get_project and ai_worklist were declared (args, db, config) while the dispatcher has always passed callCtx as a fourth argument — so ctx was being handed to them and silently dropped. Declaring it is the whole fix, but it means any handler wanting request context looked impossible when it was already there. 🧪 test/compliance-delivery.mjs (12 assertions) drives the REAL handleMcp with a stubbed Appolis: with one open directive the handshake and overview both carry it; with Appolis down the connector still opens AND the session is told the check failed; nothing owed renders nothing. Mutation-verified three ways — dropping an attach point, collapsing unknown into an empty list, and un-wiring the handshake each turn it red. Suite 2229 across 53 files, 0 failures; ratchet 0.) The usage scan is the feature, not decoration. GET /api/lander/styles?usage=1 reads every config the account owns and tallies by cfg.lane.id first, then styleId, then the house default — the same precedence the renderer itself uses, because a lander built in a made-here identity carries its style on cfg.lane and its styleId alone would answer the wrong question. It is opt-in: the builder's picker asks for the cheap list, the manager asks for the scan, so the common path does not pay for it. DELETE without ?force=1 returns 409 with the pages named; the confirm lists them and re-sends. Not because the delete is dangerous — every lander holds its own COPY and genuinely cannot break — but because a count you can ignore silently is worse than no count.
🔁 AND THE SIDEBAR ✕ IS GONE, deliberately. Deleting now lives in exactly ONE place. Two delete paths drift, and the one without the usage count in front of it is the one that gets it wrong. The Style panel lists what you are offered, says what the lander is built in, and sends you to the manager.
⚠️ THE ONE HONEST LIMIT, WRITTEN INTO THE UI RATHER THAN HIDDEN: a style that ships with the app is source code, and a page built in one renders THROUGH that source. The app can take it out of every list it controls — which is what \"delete\" means from where the person is standing — but it cannot remove code from a running worker. The panel says so plainly: \"Removing their code entirely is a deploy — say the word and it goes for good.\" Flip 7 remains the live case: measured, zero landers use it, so its source can be deleted outright on request.
🧪 test/style-library.mjs 35 → 50 assertions, still executing the real route against a fake KV rather than grepping. Four new mutants, all killed: skipping the in-use stop, counting styleId while ignoring the lane id, making the cheap GET secretly pay for the scan, and letting a second delete path grow back in the sidebar. Suite 52 files, 0 failing; matrix green.
🚦 A DRIFT WORTH NAMING, NOT FIXED HERE: v8.59.0–v8.61.0 moved the main app's progress to a centred work window and removed its top bar; the lander builder still has the thin top bar v8.58.0 gave it, because it is a separate page with its own api(). Both satisfy the directive independently, but they no longer LOOK like the same app. Harmonising them is its own change and is filed rather than smuggled in here.)
progStart("Waking Kosmos…", { overlay: true }), then /api/me. For a SIGNED-OUT visitor that 401s, showLogin() renders the gate and THROWS — and the progDone() released it sat AFTER that line, so it never ran. The veil stayed up over the login form with pointer-events:auto, i.e. nobody could click the form at all; and once v8.62.0 taught the scroll lock to track overlays, the page behind it locked too. Caught live with a window reading "Waking Kosmos… 27.7s elapsed" over a login screen. THE FIX IS THE RELEASE MOVING INTO A finally — the same rule this driver enforces everywhere else and the exact failure it exists to prevent: a spinner outliving its work, on the one screen nobody can get past. AND A WATCHDOG UNDER IT. An overlay still up 30s after it started now closes itself. That is a NET, not the fix — a slow load losing its veil is survivable, a locked-out user is not, and the asymmetry is the whole argument for having it. ⚠️ WORTH SAYING PLAINLY: v8.60.0 through v8.62.0 all shipped this, and none of my verification caught it, because every check I ran was against /progress.html (which carries its own copy of the driver and never boots the app) or against the app WHILE SIGNED IN. The signed-out path was never exercised. A proof page that cannot reproduce the real boot is not proof of the real boot.)lockScroll() HAD EXACTLY ONE CALLER — the ☰ menu. All 31 overlay sites locked nothing, so the page behind an open modal kept taking the wheel. THAT IS NOT COSMETIC. A scroll aimed at the modal went to the board instead, so a grown textarea could not be scrolled AND the Add button below it could not be reached. The controls were never missing — the category row is above the textarea and the Add button is at the foot of the same modal; both were simply unreachable. A background-scroll bug read as a broken form. THE LOCK IS NOW COMPUTED, NOT TOGGLED. A menu and a modal can both be open, and whichever closed first would unlock while the other was still up; the state is now DERIVED from what is actually on screen. AND IT IS DRIVEN BY A MutationObserver, NOT BY 31 CALL SITES. Every overlay here is built ad-hoc and appended to <body>, so the lock watches the DOM instead of relying on each site remembering — a site added tomorrow is covered without anybody touching it. Same principle as the progress driver living in api(). ⚠️ THE TOUCH BLOCKER HAD TO WIDEN WITH IT. _blockTouch exempted .menupop alone, so extending the lock to modals would have stopped the modal scrolling ITSELF — trading one scroll bug for a worse one. It now exempts whatever owns the screen. ② THE TEXTAREA CAP WAS 240px AND SILENT. Past it the box stopped growing with no scrollbar of its own, so long text could only be moved by click-dragging a selection — exactly what Tyler described. It now grows to 45% of the viewport and becomes a normal scrollable field at that point. ⚠️ One guard worth keeping: the observer can fire mid-parse before <body> exists, and a throw inside an observer callback is SILENT — so it returns early rather than dying invisibly. Suite 2202 across 52 files, 0 failures; ratchet 0.)startDataPoll calls api() on a timer, so any poll slower than the window threshold opened the window — and finishing then demanded a click to dismiss a result nobody had asked for. Two changes close it, and both are the same principle from opposite ends: background work now passes { bg: true } through api() and is invisible by construction (it can never open the window and can never pop a result, because nobody asked it to run), and a result pops ONLY when the caller passes one — progDone() with no argument closes silently. Silence is the default; interruption is opt-in. ③ WORK THAT IS ALREADY DONE MUST LEAVE NO TRACE. The threshold moved to ~700ms and is now the only gate there is: finish inside it and there is no window, no popup, nothing to dismiss. Tyler's rule, and the whole point of the directive: "I don't want people to think that they are waiting for something when something is already loaded." ④ THE RIGHT SIZE OF FEEDBACK FOR A QUICK ACTION IS THE BUTTON ITSELF — busy() marks the control you pressed as disabled and visibly working, which answers "did my tap land?" without a window, a popup, or anything to close. A failure still always surfaces, even for work too quick to have shown itself, because the alternative is a silent loss. ⚠️ ONE THING THIS ALMOST BROKE SILENTLY: the window's own sweep animated on a keyframe named for the deleted bar (kprogslide), so removing the bar would have killed the animation inside the card with no error anywhere — CSS fails quietly. It now owns kwinsweep. ⚠️ And the patch that renamed it first inserted the new keyframe INTO THE MIDDLE of the rule it belonged to, splitting it and orphaning three declarations. Caught by reading the file back rather than trusting the edit — which is the only reason it is not live. Suite 2202 across 52 files, 0 failures; ratchet 0.)innerWidth / 2, which INCLUDES the 15px scrollbar, so a correctly centred card reported as off-centre. Measured against document.documentElement.clientWidth it sits 1px off dead centre — 371 against a 371.5 midpoint. THE LAYOUT WAS RIGHT AND THE TEST WAS WRONG, which is worth knowing before somebody "fixes" the centring and breaks it.)public/app.js's api() — the single helper every call in the app goes through — showed NOTHING between click and result. THE DRIVER LIVES IN THAT ONE HELPER ON PURPOSE, so a route added later is covered by construction rather than by somebody remembering to bolt a spinner onto a new call site. ⚠️ REFERENCE-COUNTED, NOT A BOOLEAN — two calls in flight and the first to finish would otherwise clear the bar while the second still runs, leaving the app looking idle while it works, which is the precise lie the directive exists to stop. ⚠️ ~120ms GRACE BEFORE IT PAINTS: 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 is unexplained, not that every call paints. Past ~600ms a labelled work card appears naming the step with a live elapsed timer, because a bare bar on a 4-second wait reads as a hang. A FAILURE REPLACES THE INDICATOR WITH THE REASON rather than letting it vanish or, worse, keep moving: api() now captures the error text, toasts it AND hands it to progFail, which turns the bar coral and says what stopped — a spinner that outlives its work is a lie that takes longer to notice than no spinner. Every start is released in a finally, so the failure path restores the control too. 🎨 STYLED TO KOSMOS, NOT PASTED FROM APPOLIS (criterion 9): this app's stylesheet opens by reserving calm mint for normal actions and warm coral for the destructive/negative case, so the bar is mint-to-sky and coral appears ONLY on the failure state. prefers-reduced-motion slows the sweep to 2.6s and stills the pulse — the feedback is never removed, because the feedback is the point. DETERMINATE ONLY WHERE THE TOTAL IS GENUINELY KNOWN, wired into a REAL loop rather than demonstrated on a fake one: uploadRefs() posts N reference images and now holds one indicator open across the whole batch, advancing per file — without that hold, api() cleared between files and the bar strobed once per upload instead of tracking the job the person started. 📄 THE PROOF PAGE IS THE ACCEPTANCE STEP, and it is at /progress.html — every pattern runs a genuinely slow fake operation you can click and watch, including the 40ms call that correctly paints nothing. It LINKS /style.css rather than restating it, so the indicators there cannot drift from the app's. ⚠️ IT IS ALSO HONEST ABOUT WHAT KOSMOS DOES NOT DO: there is no byte-level upload percentage (attachments post as a JSON data URL through the shared helper, so the truthful unit is files-completed, not bytes-transferred) and no progress on the AI cowork queue (it runs server-side on a schedule with no open connection to report against). A page claiming a pattern the app does not use is worse than an empty one. 🔎 AND THE GUARD THAT WOULD HAVE MISSED ALL OF IT. test/builder-parses.mjs — the test that exists because a stray backtick took the live page down twice — carried a HARDCODED list of three pages, so every page added since shipped unguarded, including lander-analytics.html, the fw- pages and this new one. It now enumerates public/.html; 4 assertions became 8, and mutation-verified by breaking the inline script in the new page. A guard with a manual list only ever covers what somebody remembered. ⚠️ test/share-ids.mjs slices api() out of the shipped file and runs it in a new Function harness, so it needed the driver stubbed alongside its existing showLogin/toast stubs — the test grew a dependency because the code did, which is the harness working as intended. Suite 2202 across 52 files, 0 failures; ratchet 0. ⚠️ NOT VERIFIED IN A BROWSER FROM THIS SESSION: dev servers cannot be started here, so the page was checked against the DEPLOYED URL rather than locally.)LANDER_STYLES — the three HAND-WRITTEN styles — and a generated identity deliberately rides on cfg.lane with \"no library, no lookup\", which is exactly what makes a published page immortal. So the delete he wanted was impossible without first building the opt-in keep he asked for on 2026-08-20 (\"some way that I add them to the theme pool once we are happy with them\"), which had been filed and never built. They ship together. SECOND, and I had it wrong in my own recon: Flip 7 — the style he actually wants gone — is not intake-made at all. It is hand-written source I shipped in v8.52.0. That distinction is the whole design: a lander built in a BUILT-IN style renders THROUGH that source, so deleting it would take the page's sections out from under it, while a SAVED style is data the lander holds a COPY of. Hence two behaviours behind one button — saved styles are deleted, built-ins are HIDDEN per account and restorable, source untouched. Measured before shipping, not assumed: zero landers use flip7 (the two Brody pages are House, flip-8 is Retro Pop, flip-8-v2 is a 404), read off the SERVED pages. 🎛 AND THE VOCABULARY IS NOW THE CODE'S, TOO. Tyler settled it: STYLE = the sections, the elements, the custom code; THEME = the colour scheme, and nothing else. So setStyleId() — the mid-edit style switcher — is deleted. It already refused whenever the page had sections (a style's sections do not exist in another lane), which meant it was an affordance that offered something and then declined it in nearly every real case; removing the control is the honest version of the rule it was already enforcing. Style is chosen once, at creation or by using one from the intake builder, and the panel now states what the lander IS. Colours stay fully editable, where they belong.
⛔ A LIVE BUG FELL OUT OF LOOKING AT THAT FLOW: only the FIRST TWO styles could ever start a lander. newLander asked askChoice(a, b) — a two-way question — so the moment Flip 7 shipped third, Retro Pop became unreachable from the new-lander flow entirely, and nothing noticed because two-of-three still looks like a working picker. Replaced with a real list (built-ins you have not removed, plus your saved styles). Now that style can only be chosen here, a truncated list was not cosmetic — it was the entire choice.
🚦 cmp_progress0001 IS NOW SATISFIED ON THIS PAGE, BY CONSTRUCTION. Every network call in the builder already funnelled through one api() helper — there is exactly one raw fetch( in the file — so the indicator went THERE rather than at each call site, which is criterion 2 and means a route added next year is covered without anyone remembering. It is reference-counted rather than a boolean (two calls in flight, one finishing, must not clear the bar — which this page does on every lander switch), waits ~120ms so a fast call never flashes, is removed in a finally so it cannot outlive a failure, honours prefers-reduced-motion by stilling the animation rather than dropping the feedback, and is painted in Kosmos's own mint on its own dark line rather than the Appolis reference's palette (criterion 9). A withBusy() wrapper disables the control that started the work and restores it on the failure path too (criterion 3). This closes the directive for the lander builder only — the rest of Kosmos is NOT done and the directive stays open.
🧪 New test/style-library.mjs, 35 assertions, which executes the route lifted out of worker.js against a fake KV and the real renderer rather than grepping for behaviour. Seven mutants, all killed — but two SURVIVED the first run and both were on the most important property. The safety test deep-copied the lane itself and then asserted the copy was a copy, so flipping the shipped line to cfg.lane = chosen.lane (a reference) left it green: it was testing its own arithmetic. It now lifts newLander's actual style block and runs it, which is the only version that can fail — and the same fix caught the second survivor, slicing the chooser back to two styles. A third mutant was my own prose: the fetch-count assertion counted the word fetch() inside the comment explaining it. Two real bugs found by testing, not by reading: the POST ?restore= branch was declared AFTER the POST-save branch and could therefore never run (the save branch matched first and answered 400), and the fixture theme used 3-digit hex the real validator rejects. Suite 52 files, 0 failing; matrix green.)
db.settings.standingRules, project.rules). So the clock-in/clock-out ritual — the thing that makes "enough" mean the same to every session — lived only in acc_root’s settings. Every other Kosmos account, and every AI session working one, began and ended its shift with no bookend at all; a brand-new account got nothing. A THIRD LEVEL NOW SITS BELOW THE TWO: HOUSE_RULES in lib/mcp.js, asserted by the platform rather than typed by anybody. Two rules ship. 🕐 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_work it) and 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, because an unclosed shift is a finding the next session reports. 🛑 The owner’s call stands — do not re-litigate it, added the same day after a session spent a section of a note arguing with Tyler that an emailed one-time code "is not really two-factor". It permits exactly ONE sentence, and only when being wrong would change the code, the security posture, the money, or a decision somebody else will rely on — so a real warning still lands and definitional purity does not. ⚠️ THEY ARE PREPENDED INSIDE globalRules() ON PURPOSE, not at each call site. Global rules ride FIVE separate paths out of this connector — initialize, overview, get_project, ai_worklist and the first-write echo — and merging at the source means a sixth path cannot be added that quietly forgets them. Each rule names itself 🏛 in its own text, so a session can still tell what the app asserts from what this owner asked for, and no consumer had to change shape; the four hint strings were reworded to stop attributing house rules to the owner. ⚠️ HOUSE_ECHOES dedupes an owner’s hand-typed copy. acc_root has carried its own wording of the time card as rule 5 since 2026-08-19; without this, every session on that board would be handed the same rule twice, in two wordings. It matches a distinctive fragment rather than the whole string, because nobody’s wording will ever be byte-identical to ours. 🧪 test/house-rules.mjs, 15 assertions. The load-bearing one is a db with an EMPTY settings object — the case the old code returned [] for, and the entire reason this shipped — and it is asserted on the handshake plus all four tool paths. Mutation-verified: emptying HOUSE_RULES fails every carries-the-rule assertion (7), deleting the HOUSE_ECHOES filter fails all four dedupe assertions, widening it to /rule/i fails the keeps-the-owner’s-other-rules assertions (8). ⚠️ test/standing-rules.mjs was UPDATED, not left to pass by luck — it asserted "a board with no rules adds nothing to the handshake", which is precisely the behaviour that changed, and it failed on the first full-suite run. Its counting assertions now count the OWNER’s share of global, and two new ones pin the new contract: clearing the owner’s rules removes only those, and the house rules are not the owner’s to clear. Suite 51 files, 0 failing.)runOnBoard (CAS + jittered replay) wrapped every write to your OWN board, but the cross-account branch at worker.js:3358 built kvStore(env, ownerId) directly — so when another writer committed between its load and its save, the save was rejected, nothing was written, and the collaborator got a 500. Nothing was ever clobbered, which is exactly why this sat around as a papercut instead of a data bug: the failure was honest, just useless. Now the whole branch runs inside runOnBoard(env, ownerId, …). No canReplay predicate is needed and that is not a shortcut — the rule stated at runOnBoard's own definition is that replay is safe for a handler touching nothing but the board, and this one calls lib/api.js, which the same comment names as qualifying by design (KV, R2, the hub DO and Stripe are deliberately kept out in worker.js). A contended board still gives up LOUDLY after 15 tries: 503 saying plainly that nothing was altered, never a success answer for a write that did not land. ⚠️ THE AUTHORISATION CHECKS MOVED INSIDE THE REPLAY DELIBERATELY, and the test exists to stop someone "optimising" that back out. The callback re-loads the OWNER's document on every attempt, so shareRole, roleAllows and the not-deleted project lookup must re-run against the fresh copy. Hoisting them above the wrapper — the obvious don't-repeat-work refactor — would authorise against a document that no longer exists, so a share revoked mid-replay would still be honoured. That is precisely the class of bug replay exists to prevent, so test/share-cas-replay.mjs asserts each check is INSIDE the callback and fails if it moves. 🧪 17 assertions, structural + behavioural: the wrapper is driven with a store double that conflicts N times, proving three lost races replay, that a permanently contended board stops at a bounded 15 and returns BOARD_BUSY rather than the handler's result, and that a caller with fired side effects stops after ONE attempt. Mutation-verified: unwrapping the branch fails 8 of them. ⚠️ One test detail worth keeping: the "no bare kvStore(env, ownerId) survives" check strips comments first — the comment explaining the fix necessarily quotes the old call, and the first version of the check failed on its own documentation. Suite 2160 across 50 files, 0 failing.)lander_revenue_status against production and the stored receipt came back with no lastError field at all. It should have had one (as null) if stampSuccess had run. That single missing key exposed a third writer of the receipt key that I had not touched: lander_revenue_sync in lib/mcp.js has its own sync implementation and writes the receipt at :1310, separately from the cron (worker.js:3605) and the HTTP route (worker.js:2117). Left as shipped, a recovery performed through the AI door would have stored an unstamped receipt, so a feed that had recovered would keep reporting "the most recent attempt failed" until something else wrote over it. THE REAL LESSON IS THE TEST, NOT THE LINE. Fixing the third writer is one line; the bug class is "a writer nobody remembered". So test/revenue-sync-health.mjs now asserts over the whole codebase rather than per-known-writer: it finds every .put() of revMetaKey in the shipped source and requires each to go through a stamp, so a FOURTH writer added later is covered the day it is written. ⚠️ It checks what is actually handed to the put — a balanced-paren read of the argument, accepting an inline stamp or a variable assigned from one just above — because the first version of this check merely looked for nearby text and passed the mutant, which stores the unstamped object one line under a stamp. Mutation-verified in both directions now. ⚠️ It also reads lib/mcp.js directly rather than through a grep tool: that file contains a NUL byte that makes ripgrep silently truncate it, so a grep-based version of this check would have been blind to the very writer that was missed. Suite 2123 across 48 files, 0 failing.)ID_SECRET move, by a completeness critic asking "what could return 200 with empty data rather than an error?" scheduled() did if (!orders || !orders.ok) return; — writing nothing, logging nothing, throwing nothing, and leaving the previous receipt untouched. So lander_revenue_status kept answering synced: true with a complete, plausible receipt (full scope list, order counts, wrote: 3) while revenue froze at the last good window. The only tell was a timestamp nobody was watching. ⚠️ NOT-WRITING IS STILL RIGHT FOR THE DATA and is unchanged — a failed pull must never overwrite good days with zeros (:3540's original comment is correct). The bug was never the write policy; it was that failure left no trace anywhere, so this is a detectability fix, not a data fix. ⚠️ AND THIS IS THE SUITE'S MOST FRAGILE PATH, which is why it matters more than one cron: it is the ONLY place kosmos sends the shared secret with no fallback (x-id-internal: env.ID_SECRET, no APPOLIS_APP_KEY second chance the way hubOk() has), and Hermes gates it by HIDING the route — 404 on mismatch, never 401. So any future key skew kills revenue first and silently while every other surface keeps working, which is exactly the combination that delays discovery. WHAT SHIPPED: three pure functions in lib/revenue.js — stampFailure (merges lastAttemptAt/lastAttemptSource/lastError onto the existing receipt, carrying every good field through untouched), stampSuccess (clears that trio, so a recovered feed stops reading as broken), and syncHealth (derives stale from the receipt's age — the cron runs every 24h, so >36h has missed a run, with slack so one late night does not cry wolf). They live in lib/revenue.js precisely so the behaviour is testable directly instead of being trapped inside scheduled(). Every silent return in the cron now stamps a reason, including the catch, which used to swallow the throw entirely. ALL THREE RECEIPT WRITERS WERE MADE CONSISTENT — the manual sync at :2098 also had to call stampSuccess, or a manual run that FIXED a broken feed would keep reporting "the most recent attempt failed" forever; a recovery the status refuses to acknowledge is its own bug. AND IT IS VISIBLE, not just reportable: the health rides inside revenueStatus (:1840) rather than as sibling fields, so there is exactly ONE shape describing sync state and a reader cannot consume one and miss the other; the 📊 Performance panel now shows a coral warning saying the figures are real but not current, and names the shared key as the usual cause. 🧪 24 assertions in test/revenue-sync-health.mjs, driving the real exported functions — including the case that actually bit: stale with NO error recorded, because a cron that never fires leaves no error either. Also asserts an unreadable or missing timestamp is treated as stale rather than healthy, that an Error object is unwrapped instead of stored as [object Object], and that a runaway message is capped so the receipt cannot become an unbounded blob in KV. Suite 2121 across 48 files, 0 failing. ⚠️ CAVEAT CORRECTED 2026-08-20, same day: this entry originally said the failure path "cannot be exercised without breaking the live handshake, so it is proven by unit test and by reading". That was true of PRODUCTION and I wrongly let it stand for the CODE too — the pure helpers were tested, but nothing proved scheduled() actually CALLS them on each of its exits, and a stamp helper that works alongside a cron that forgets to use it looks identical from the helper's own tests. test/revenue-cron-failure.mjs now brace-matches the REAL scheduled() out of the shipped worker.js and drives it in a vm against a mock Hermes that fails each way production can: 404 (the key-skew shape), missing binding, and a thrown error. 20 assertions; all three silent exits mutation-verified, each failing exactly its own. It also pins the data rule — a failed pull writes NO day buckets — guarded by a success case proving the check can actually see writes, so "no buckets" cannot pass vacuously. ⚠️ That guard earned itself immediately: the first mock used REST-shaped orders (created_at/note_attributes) when the real feed is GraphQL-shaped (createdAt/totalPriceSet/customAttributes), so it rolled to zero landers and the data-rule assertion was briefly meaningless. What genuinely remains unproven is only the PRODUCTION run: the next real proof is tomorrow's 07:20Z cron advancing the receipt past 2026-08-20T18:51:56Z.)setBoardSource() did await api('PATCH','/api/settings') → await refresh() → render(), and refresh() (app.js:155) is DB = nsSharedIds(await api('GET','/api/db')) — the whole board document, fetched, parsed and id-walked, to flip a three-state display toggle. THE FILTER IS PURELY PRESENTATIONAL AND THE DATA WAS ALREADY IN MEMORY: :583 gates projs off DB.projects, and :593 gates mirrors off agoraMirrors(). ⚠️ AND HERE IS THE PART THAT MAKES IT INDEFENSIBLE RATHER THAN MERELY WASTEFUL: agoraMirrors() reads the COMPANIES global (:484), which is loaded ONCE by loadCompanies() from /api/companies and cached — refresh() never reloads it. So the board refetch could not refresh the Agora mirror tiles the filter acts on. Its entire observable effect was reading back the one word we had just written and already held locally. THE FIX IS PAINT-FIRST: set it in memory, render() immediately, then persist. No extra request is needed to reconcile, because PATCH /api/settings already returns the merged settings (lib/api.js:511) — so the response is adopted as authoritative and only triggers a second repaint if the stored value actually differs (another device having won). Re-picking the current filter is now a no-op: no write, no repaint. ⚠️ FAILURE MUST NOT LIE. An optimistic update that silently loses the write is worse than a slow one, because the screen and the stored truth diverge until the next reload. A failed save now puts the pick back, repaints, and says so. setSecHidden() (🙈 hide a category) had the identical shape and got the identical fix — same per-account display pref, same needless full-board refetch. 🧪 THE TEST'S LOAD-BEARING ASSERTION IS NEGATIVE. test/board-source-filter.mjs brace-matches the four real functions out of the shipped public/app.js and drives them in a vm, asserting that no GET /api/db happens — so it fails the moment anyone reintroduces a refresh(). It also holds the save open on a deferred to prove the repaint does not wait on the network. 19 assertions; three mutants (refetch restored in each function, and the no-op guard removed) each fail exactly their own assertions and nothing else. Suite 2097 across 47 files, 0 failing. NOT CHANGED, deliberately: the ~20 other await refresh(); render() call sites — those follow writes that genuinely change board DATA, where refetching is the correct thing to do. Only the two display-preference toggles were wrong.)sandy@mmcflorida.com hit the sign-in gate twice (two request events, and pending stays empty so there was nothing to approve). Three faults in one panel, none of which produced an error, a warning, or a wrong-looking screen — which is exactly why they survived. ① THE MINTED LINK WAS CREATED, DISCARDED, AND UNRECOVERABLE. setAccess mints the unlisted token the FIRST time a hub becomes link-only — that branch fires only while !hub.tokenHash — and returns the one and only plaintext link, because only its HASH is stored. hubSaveAccess did await api(…) and threw the response away, then called hubReload() with no argument, so HUBWIZ.freshLink stayed null and the "copy it now (shown once)" box never rendered. The hash was now on the record, so the mint branch could never fire again. A working link existed for the length of one HTTP response and then could not be recovered by anything short of a rotate. From the outside this is indistinguishable from "the feature does nothing", which is what it looked like. Now the response is captured and handed to the panel, and the toast says the link is shown once. ② A TILE OPERATION ATE YOUR UNSAVED PICK. hubReload re-renders the whole panel from the STORED record, and it runs on EVERY tile operation — retype, detach, re-attach, add note/link/doc/file, delete, version rollback. So choosing a dropdown and then touching any tile silently replaced the choice with what was already stored, and "Save access" then wrote that old value back. ⚠️ THE REASON THIS READ AS A ONE-DROPDOWN BUG: whichever axis already matched storage re-selects itself after the repaint and LOOKS preserved, while the axis you actually changed reverts. Tyler reported it as "anyone with the link sticks but read-only does not" — one bug wearing two faces. There was never anything wrong with the action axis; setAccess handles 'off' correctly and always did. hubReload now captures the six controls before the repaint and restores them after. ③ NOTHING SAID THE PLAIN URL WOULD NOT WORK. The panel shows the hub url with a Copy button, and that url CANNOT open an unlisted hub — it needs ?k=<token>. A link-only hub with no fresh link on screen now says so outright, and names the two ways out: press 🔄 New secret link and send that, or switch to 🌐 Public. 🧪 THE TEST DRIVES THE REAL FUNCTIONS. test/hub-access-panel.mjs brace-matches hubSaveAccess and hubReload straight out of the shipped public/app.js and runs them in a vm against stubs — a grep for the fix would pass after the fix was deleted, because the words survive in the comment explaining it. 7 assertions, all three fixes mutation-verified: each reverted in turn fails exactly its own assertions and nothing else. Suite 2078 across 46 files, ratchet 0. ⚠️ ALSO CORRECTED HERE, pre-existing and not mine: APP_BREAKDOWN.md carried a SECOND - Build: line at v8.34.0, so the living doc claimed two current builds at once. Retyped to - Was:; no text changed.)What was wrong, and what the evidence actually says:
f7cards said "Two scoops in the shaker" and the FAQ answered "How many scoops is a serving? Two scoops." Every one of the five source pages says ONE — "A Complete Meal in One Scoop", "85+ Nutrients in One Scoop", "seven complete megablends in a single serving", "a complete, doctor-formulated meal in a single scoop". A dose is the one number you never infer, and I had it doubled and self-consistent, which is what made it read as deliberate.175 calories, which every page states.⚖️ One claim was deliberately NOT removed, and the distinction matters. "Free shipping over $75" is absent from the advertorials — because they never discuss shipping. It is on the approved figures list and already ships in the house defaults. Absence from a source is not evidence of falsehood, and deleting a true claim is its own kind of wrong. An audit that flags everything it cannot find teaches you to ignore it.
🎨 AND A FIDELITY GAP THE CLAIM AUDIT SURFACED ON THE WAY PAST: the port dropped #CB8966, the light brown the source uses ~20 times — it is the eyebrow and footer ink there (measured: font-size:12px; line-height:1.42; uppercase; letter-spacing:27%; color:#cb8966), and the eyebrow is one of the three devices this identity is built on. Now carried as --accent-l with a theme override, since the eight shared theme keys have no slot for a third brown. It is applied only to the dark and tinted bands, on purpose: #CB8966 on white measures about 2.8:1, which fails AA for 12px text — copying that everywhere would be reproducing a defect, not matching a reference. The tracking and the family, which are what the device actually is, are identical on every band. Eyebrow margin corrected 10px → the measured 15px.
🧪 test/flip7-lane.mjs 26 → 36 assertions, pinning the verified figures (175 calories, 20g plant protein, 85+ vital nutrients, one scoop, five flavours) and refusing the fabricated ones by name. Six mutants, all killed — each restores one fabrication exactly as it shipped, including reintroducing the two-scoop dose in either of its two homes and dropping flavours back out of the list. Suite 45 files, 0 failing; matrix green. Verified against the store record, not from memory: flipmylifenow.com/products/flip-7.json confirmed the five variants, the $54.98 price, and variant 51776853147949 — which is the exact id every CTA on all five advertorials points at.)
news.flipmylifenow.com pages (rights confirmed) rather than styled by taste: Arboria as three separate families (Book/Medium/Bold, all font-weight:400 — the weight lives in the family name, so asking for 700 gets you a browser-synthesised fake), no clamp() and no viewport font sizes because the source uses exactly one hard breakpoint at 1024px, and the signature device the pages are actually built around — section titles that are bold on a phone and flip to light 48px Arboria-Book on a desktop. 14 section types, 5 image slots, a seven-tab component that needs no JavaScript (hidden radios and sibling selectors). Renders 13 sections, 14 images, 0 empty src. ⛔ AND THEN THE ACCEPTANCE MATRIX REFUSED IT, WHICH IS THE PART WORTH READING. The page looked finished; control-matrix.js reported five dead or partial controls. Four of them came from one fact I had measured and not yet understood: every call to action on all five source pages points at the same product-page URL. There is no cart on those pages. This is not a style missing a buy box — it is a style whose selling mechanism is the outbound link, and the app had silently assumed every identity embeds commerce.
Three real defects fell out of that, none of which were visible by looking at the page:
#buyBtn, data-cta="buy-main", or .bb-go). My buttons carried none, so the clicks column would have been a permanent structural zero — which reads as "nobody clicked" rather than "never measured", and the first is a lie you would act on. This is the flip-8 tracking failure of 2026-08-07 arriving one lane later in a new disguise. On this identity the outbound click is the buy click, so it is now marked — and the ghost/scroll button deliberately is not, because counting a scroll as intent is exactly what you had removed on 2026-08-13.AddToCart and InitiateCheckout bind to .bb-pk, form.buybox and #buyBtn. None exist here, so they could never fire — but worse than dead weight, a page emitting AddToCart is a page claiming it can measure adds. It cannot; the destination product page owns those events with its own pixel. Both are now gated on the style declaring a buy box, with the reason written where the next reader will hit it. Verified per style: house emits them, Retro Pop emits them, Flip 7 does not.HEADING_SLOTS is what tells a brief where its "this line is the H1" can actually land; a lane missing from that table has no reachable slot at all, which the matrix reports as NO H1 SLOT. cfg.cta.label is a FALLBACK, not decoration — every other lane lets a section leave its own button label blank and inherit the page-wide one, and the matrix tests precisely that by clearing the section labels. My helper returned an empty string instead, which surfaced as PARTIAL rather than DEAD: the quieter failure, and the one I would have argued was cosmetic.
The two commerce knobs are now declared unused rather than left dead. commerce.domain and commerce.discount have nothing to configure on a link-out lane, so they are gated on a new buyBox style capability — the builder hides them, and the acceptance check stops reporting a knob that was never meant to turn. The matrix's two tracking rows became a matched pair: a style may have an on-page cart or sell by linking out, and both are legitimate — what is never legitimate is emitting the events without the control, or building the control without the events. Checking them independently is what would have let this ship green.
🧪 New test/flip7-lane.mjs, 26 assertions. Seven mutants, all killed — dropping the buy marker, marking the ghost button, un-gating the cart events, claiming a buy box that is not there, dropping the label fallback, rendering an empty button, and removing the h1 slot. Suite 45 files, 0 failing; matrix reports all controls live on every style. Still open and deliberately not faked: this lane has no on-page buy component. The source advertorials link out, so the port is faithful — but if a Flip 7 lander should ever sell in place, that section has to be built, not configured.)
# and #buy among the dead values. Then flip-8's own config turned out to read "cta": { "url": "#buy" } — deliberately, because that is how the entire commerce design works: every CTA on the page scrolls to the buy section, and the only exit is the buy box's own submit, which reaches checkout with the variant, cadence and discount already filled in. The page script says so in as many words. So the guard was refusing the reference implementation. Caught by reading that config while setting up the Flip 7 build, not by a test — every fixture I had written used an external URL, so the whole class was invisible. The honest rule is not is it an anchor but does it lead anywhere: an in-page anchor leads somewhere exactly when the thing it points at is on the page. It now accepts any in-page anchor on a page that HAS a buy section (retro-pop's rpbuy or the house shopcart), and still refuses one on a page that does not — which is the original bug wearing a plausible value, since both generated landers had no buy section at all. A bare # is refused either way. Three mutants verified red, including the regression itself. Suite 44 files, 0 failing.).buybox element, and a page built from a GENERATED style has none — measured, 0 elements with that class on both pages. So the block never ran, .bar-off was never applied, and the placeholder sat on screen for the whole visit. Without a buy box the bar has no scroll target, but it is still a perfectly good persistent call to action, so it now keeps the hide-until-the-first-CTA-scrolls-away behaviour and simply skips the is-the-buy-box-covered half. ② PUBLISH NOW REFUSES EVERY UNEDITED DEFAULT, not just the CTA link. Both pages went live with <title>New lander</title> — which is what a share card, a bookmark and a search result read — an empty meta description, and the placeholder bar above. None of those is visible to someone reviewing copy in the builder; the bar in particular is never seen until the page is public. The guard now lists every one it finds rather than stopping at the first, and a bar the owner has deliberately switched off is not judged. ⚠️ Publish only — every one of these is a normal state for a DRAFT. ③ AND THE STUDY IS WORTH RECORDING EVEN WHERE IT DID NOT CHANGE CODE. Both pages render zero images — every <img> has src="" (2 of 2 on v4, 4 of 4 on v5), so a visitor meets hollow bordered boxes or broken-image glyphs captioned with descriptions of photographs nobody supplied. Neither page contains one line of the creative team's copy — six supplied phrases, zero hits across both files. Only 4 of 13 approved facts appear on either; no price, no flavour, no guarantee, no allergen disclosure. Both invent customers ("Dana R.", "Marcus T." labelled as drinkers), which is exactly the shape the testimonial rule exists to prevent. v4 contradicts itself on the serving size within its own page (H1 "One Scoop", body "Two Scoops"), and headlines "Three Reasons" over four pillars; v5 headlines "7 Megablends" then indexes four. ⚠️ AND ~35KB of the 55KB served is shared JavaScript driving a buy box, flavour picker, quantity stepper and subscribe pricing that NEITHER PAGE USES. The renderer already supports the merchandising being asked for; the generator produced a text skeleton and never populated the parts that do the selling. That is the finding that decides the next step, and it is an argument about the PROMPT and the FLOW, not the renderer. ⚠️ FILED, NOT FIXED: the served bundle ships internal engineering commentary and Kosmos ticket ids (todo_1528, todo_1603, todo_1619) to every visitor. A naive comment-stripper over page JS is genuinely risky — regex literals and strings containing slashes — and getting it wrong breaks every lander, so it is recorded rather than attempted in the same pass as three other fixes. Suite 43 files, 0 failing; matrix green; three mutants verified red.)cfg.lane of the lander it was applied to, and lander_styles returns only the two hand-written identities. So there is no accumulation to worry about. The real risk runs the other way: a style he LIKES is destroyed the moment he deletes that lander, with no way to keep it, which is also why it never appears in the picker on the left. An explicit opt-in "keep this style" is the right feature and is filed rather than assumed. Suite 43 files, 0 failing; matrix green.)buildLanePrompt had no idea what the product may claim** — it received a name, a direction, some fingerprints and a voice, and then asked for "a realistic example value", which is an invitation to invent specifications. And a lane's defaults are not samples: "Use this style" stamps them onto the page and they publish verbatim. So the style step now receives the approved list, with a rule deliberately stricter than the copy step's — copy may use the approved figures; a STYLE has no business asserting product facts at all, because it is a shape that is filled with real copy in the very next step. It names the only permitted figures, forbids every other number outright, forbids numbers entirely when nothing is approved (silence there would invite exactly the invention it exists to prevent), warns in the specific that "One Scoop" is a numeric claim exactly like "1 scoop" — a general "be accurate" is ignorable, the sentence describing what actually went wrong is not — and forbids naming who formulated the product, which is a claim a style has no access to. The "realistic example value" instruction is replaced with one asking for text that demonstrates the LAYOUT at a realistic LENGTH. ⚠️ TWO OF MY OWN MISTAKES, RECORDED BECAUSE BOTH ARE INSTRUCTIVE. First, I wrote a JSX-style brace comment inside a template literal in the new reference row — which is not a comment there, it is characters, and would have rendered as visible text on the page. Caught by a test before it shipped. Second, and worse: the test I wrote to catch that contained the comment-closing sequence inside its own block comment, so the test file stopped parsing — and a mutation run against it reported all four mutants dead when the parser was simply refusing to load anything. A broken harness reports everything as red, which looks exactly like success. The run was redone from a verified-green baseline, with a check that distinguishes a parse error from a real kill. Suite 42 files, 0 failing; matrix green. ⚠️ AND IT SHIPPED BROKEN FIRST, WHICH IS WHY test/builder-parses.mjs NOW EXISTS. The HTML comment above contained a dollar-brace pair. An HTML comment hides text from the BROWSER, not from the JavaScript parser — the template literal is parsed first, so that empty interpolation was a SyntaxError that killed the entire builder script. The page loaded, drew its markup, and every control on it was dead. The comment was, with some irony, explaining that markup inside a template literal is not a safe place to write comments. NOTHING IN 42 TEST FILES ASKED WHETHER THE PAGE PARSES — every other test LIFTS a function out with indexOf and runs it in isolation, so a fault outside the lifted region is invisible, and this file is only ever parsed AS A WHOLE by a browser. That is now a test: every inline script in every page is compiled, with a guard asserting it really parsed something rather than silently finding none. Reintroducing the exact break turns it red. Second time this session a COMMENT has taken the live page down (the first was a top-level name collision), so the guard is the cheapest possible insurance against the most expensive possible failure.)$oninput writes state without re-rendering — so it was drawn while the row was empty, typing changed nothing on screen, and it only came back when something else happened to redraw the panel. That intermittency is why it read as "not showing up anymore" rather than as a consistent fault. ⚠️ NOT A REGRESSION: a diff against the commit before this batch shows the refs block byte-for-byte unchanged — a latent bug that surfaced the first time the feature was used in anger. ⚠️ AND THE OBVIOUS FIX WOULD HAVE BEEN A SECOND BUG: calling renderIntake() from oninput rebuilds the input and throws the caret to the end mid-URL — the exact trap the approved-numbers panel already carries a comment about. So the button is now always rendered and only its DISABLED state changes, updated in place, touching nothing else in the DOM — and it says WHY it is disabled ("Paste a URL above first") instead of simply being dead. A control that greys out tells you what to do; a control that vanishes tells you nothing and reads as a bug — which is exactly how this one was reported. Four mutants verified red, including the original bug itself and a handler that throws when the button is off screen (it runs on every keystroke, so an exception there would silently break typing). ⚠️ AND AN HONEST NOTE ABOUT HOW THIS SHIPPED: the code reached production BEFORE this entry existed. The changelog script was written inline in a shell command, the shell executed the backticks inside it, the script died — and wrangler deploy sat on its own line rather than in the && chain, so it ran anyway. The fix was live for a few minutes under the previous version number with no entry and an uncommitted tree. Caught by checking, not by luck. The lesson is the one this repo keeps re-learning: build any multi-step ship as a FILE, and never let a deploy sit outside the chain that is supposed to gate it. Suite 41 files, 0 failing.)NUM_RE matched digits only, so a dose written in words was prose to it; ② "scoop" was not in the unit list, so even "1 scoop" fell through to the bare-integer branch and was ALLOWED as a counting word. Both are closed: word-numbers one…hundred now count when attached to a unit (never on their own — "no one" and "one of the best" are English, and a gate that flags those is one people learn to skim past), normalised to their digit form so "One Scoop" collides properly with an approved "2 Scoops"; and the unit list gained the dose units it was missing. ⚠️ A FALSE POSITIVE NEARLY SHIPPED WITH IT: an early version also added blends/nutrients/ingredients as units, which turned the approved bare figure 7 into "7 blend" and flagged a number the owner had explicitly approved. Counting nouns stay OFF the list — the bare-integer rule already covers them, and "85+ nutrients" is still caught because 85 exceeds the bare-integer allowance. 🖼 AND THE FINDING THAT NO PROMPT COULD HAVE FIXED: a generated style could not declare a working image slot. Three files disagreed — parseLane built each section as {label, icon, band, defaults, template} and dropped schema on the floor, so laneAdapt's "use the lane's own schema" branch could never be taken, so every kind came from laneFieldKind (text/long/lines/rows only), so renderProps' kind === 'img' picker branch could never fire. An image prop rendered as a plain textarea holding a URL and the image pool was unreachable. That is why the first run shipped https://placehold.co seven times to a live advertising page: there was no picker to swap it with. parseLane now carries a whitelisted schema (an unrecognised kind degrades to inference rather than reaching the props editor, which renders nothing for a kind it does not know), and a declared image prop gets the in-house placeholder the hand-written lanes use — a self-contained data: SVG reading "Your image · click Choose to swap" — REPLACING whatever URL the model wrote, because a live ad lander must never hand a third party the request log of every visitor. ⛔ AND A BUY BUTTON THAT GOES NOWHERE NOW BLOCKS PUBLISH. Both test landers went live with cta.url still the literal "https://" — every call to action on the page linked to nothing. Saving a draft is untouched; this is only the moment a page becomes public. 🎨 THE PROMPT REWRITE, and it deliberately does NOT just ask for more sections. An adversarial pass measured the owner's own A/B and overturned that premise: he rated v3 better, and v3 has FEWER sections and LESS CSS system than v2 — so more is not better, and a role list is an information architecture, not a design spec (all fourteen roles are satisfiable with a hairline rule and a Georgia heading, which is exactly what the failing lane did). The evidence ordering is: does the page SHOW THE PRODUCT · does it look FINISHED · does it have a SIGNATURE DEVICE. So the prompt now names images concretely (with the schema shape, and an explicit refusal of placeholder services and of captions describing photographs that do not exist), demands one signature device used in at least three places, requires bands that genuinely differ rather than three near-identical off-whites, states that all motion must be CSS because script is stripped, requires three separate calls to action, adds a testimonial section to the floor — which no lane in the product could previously express — and forbids HTML in defaults, after a published headline read Zero <em>Guesswork</em>. in 84px type. The seven-role list is now stated as a FLOOR rather than a specification; it previously read as one and the run returned exactly seven. Suite 40 files, 0 failing; matrix green; six mutants verified red.)<div class="band band-X"><div class="wrap">, then emits the sticky CTA bar and the footer line. Those class names come from the RENDERER, and buildLanePrompt requires every class a lane writes to be prefixed with its own id, so a lane author cannot style them even in principle. All of those rules lived in landerCss — the branch a lane never takes. So a published lane page had no gutter, no max-width, no section padding, an unstyled fixed bar and an unstyled footer — and SIX builder controls (band tint, mobile and desktop scale, spacing, hide-on-device, section alignment) that were offered, looked live, and did nothing at all, because nothing consumed the variables they set. laneShellCss() supplies exactly that frame and nothing else: gutter, measure, rhythm, and the furniture the renderer emits. It sets no type, no colour and no ornament — those are the lane's whole job. ⚠️ PORTED VERBATIM, NOT PARAPHRASED, and that mattered: a first draft reused the MOBILE 2.3em figure inside the desktop media query, which would have given lane pages ~36% tighter desktop rhythm than House at the same slider value — invisible on a phone, wrong on every desktop, and attributable to nothing the owner could see. ⚠️ AND THE BUTTON INSIDE THE BAR COUNTS: the bar emits <a class="btn btn-primary"> and .btn also lived only in the hand-written sheets, so styling the bar alone would have left a dark fixed bar containing a bare underlined link — broken in a more confusing way than before, because it now looks deliberate. ⛔ ORDER IS LOAD-BEARING: the lane's own stylesheet lands AFTER the shell, so it can still override its own frame; reversed, the shell would silently win every conflict. TESTS: the assertions ask whether a RULE CONSUMES each variable, never whether the markup sets it — bandAttrs emits --sm and hide-m whether or not any stylesheet reads them, so a test written against the emitted markup would go green for all six inert controls. Five mutants verified red, including the desktop-rhythm drift and the shell/lane ordering swap. Suite 39 files, 0 failing; matrix green.)high effort, because a whole visual identity is the one job here where the reasoning earns its cost. A full 14-section page of copy measures only ~2,200 tokens: its answer always fitted, and it failed because reasoning ate the allowance. That makes effort: low the actual fix there and the budget a backstop — a change that only raised the ceiling would have left the cause in place, which is the trap this split exists to avoid. Vision (a small JSON object from up to 12 frames) and beats (a handful of short lines) stay small: they gain nothing from a big ceiling and would only inherit longer runs. ⏱ AND THE PROGRESS COPY HAD TO MOVE WITH IT. The hints read "usually takes 60-120 seconds" and "30-90 seconds", both written against a budget four times smaller. Ship a bigger one without touching them and the screen tells the owner the run has failed while it is working correctly — and the elapsed timer beside it counts real seconds, so the two visibly disagree. Both are honest again; both still warn it cannot be cancelled, which is more true now, not less. ⚠️ TWO UNRELATED TESTS WENT RED MID-SESSION AND WERE NOT MY CHANGES — the cause is worth recording. test/invoice-doc.mjs and test/invoice-publish.mjs both carried a fixture due-dated 2026-08-19, and the clock passed midnight UTC. The invoices correctly became overdue, the tile subtitle correctly changed from due 19 August 2026 to past due, and display correctly turned from sent to overdue — so two assertions about FORMATTING and RESPONSE SHAPE failed for a calendar reason, with nobody having touched any code. A fixture pinned to a wall-clock date is a test with a hidden expiry. The state and the formatting are two different claims, so they are asserted separately now: a far-future date pins the wording, and a long-past one pins the overdue wording. Both stable forever. TESTS: new test/lane-budgets.mjs (16 assertions) reads the SHIPPED page, because a budget is only real if the caller sends it; three mutants verified red (lane back to 8000, copy keeping high effort — the actual cause — and a small caller handed a pointless big budget). Suite 39 files, 0 failing; matrix green. ⚠️ THIS DEPENDS ON PHANTASIA v0.7.4, deployed first for that reason: the budget, the effort parameter and the streaming that makes a large budget safe to ask for all live there. ⚠️ AND NOTHING HAS BEEN THROUGH THE LIVE API YET — the streaming reader is proven against a faithful SSE mock, not against Anthropic, and the wall-clock cost of a 32000-token run through the Kosmos→Phantasia→Anthropic chain is unmeasured. The first real run is the measurement.)(st && st.sections) || Object.keys(LANDER_TEMPLATES), so pointing the style id at the lane — which is what anyone would try first — hits the fallback, because a generated lane id is in no registry, and offers every hand-written template in the app instead of seventeen. THE FIX: templateFor(type) and typesNow(), one resolver that consults the lane first and never mixes the two sets — deliberately the same order the renderer itself uses ((laneT && laneT[s.type]) || LANDER_TEMPLATES[s.type]), because the editor and the page must not disagree about what a section is. The lane is read from cfg on each call, never cached in module state, per the renderer's own standing refusal to hold a "current lane". Seven panels now go through it: the props editor, per-field controls, the section rail, + Add section, adding a section, the copy catalog and the gate's catalog. newLander deliberately does not — it builds from a hand-written style chosen in its own dialog, before a lane can exist, and a test pins that so a future sweep does not "fix" it. THE SCHEMA NEEDED REAL WORK, NOT A RENAME — three layers. ① lane entries are keyed k while every consumer reads key; ② parseLane never carries a schema at all, so the derived one is the ONLY shape a real lane ever has; ③ nothing carried kind, and the props editor dispatches entirely on kind and returns '' for anything else — so with the key alone fixed, every field of every generated section would still have rendered as nothing. 🎯 AND THE KIND HAS TO ASK THE TEMPLATE, NOT THE DEFAULT. Inferring from the default VALUE looked right and is wrong for the commonest repeater a lane writes: {{#each rows}} whose default is the single string "Creatine|5,000 mg" — legal, and the shape the suite's own reference lane uses. A value-only guess called it a plain text field, so the panel would have offered one line where the section renders a list. The template is the authority on how a prop is USED; the default is only a sample of it. Found by the test, not by reading. Related, from the same family: the props panel's lines branch did (v || []).join() and threw TypeError on exactly that string default — the panel this resolver exists to fix would have died on a real lane's own value. It normalises on read now. ⚠️ WHAT THIS DOES NOT FIX, said plainly: laneCss still emits none of the .band / .wrap / .bar / .foot rules the renderer's own wrapper markup needs, so a published lane page has no gutter, no max-width, no section padding and an unstyled sticky bar — six builder controls are offered on a lane page that look live and do nothing. That is the next build, and it is written down here rather than left implied. Suite 38 files, 0 failing; matrix green; four mutants verified red (lane stops resolving, catalog falls back to House, kind guessed from the default only, schema back to the k-only key).)laneCss interpolated lane.css raw into the page's <style> block on the shared public lander origin. validateLane does reject markup there — but it ran only in the builder browser, at generation time, and a repo-wide search confirms neither the publish route nor the connector ever called it. So a cfg carrying </style><script> in lane.css, POSTed straight at the API, reached the page unchecked and executed on the same origin as every live ad lander. That is precisely the failure the publish route's own comment records about trusting a posted html field, re-opened through a different door — which is the argument for putting the guard where the bytes are emitted rather than at each caller. THE FIX IS TWO LAYERS ON PURPOSE: a floor at the render boundary (laneCssBody strips <, so browser paint, worker publish and connector are covered by construction rather than by remembering), and a loud refusal at publish (checkCfgLane, which names its reasons rather than quietly publishing a repaired identity). > is deliberately left alone — it is a child combinator, and a fix that silently mangles valid output is its own bug. Lane templates were already safe (laneTemplates sanitises every one); the hole was CSS-only, and that asymmetry is now pinned by tests so a refactor cannot widen it. 🚪 AND THE INTERPRETER WAS NEVER LOADED IN THE BROWSER. public/lander-lane.js had no <script> tag — the worker bundles it, so publishing a lane worked while the builder could not preview, validate or edit one, and "Create a new style" would have failed with LanderLane is not defined the moment the token budget was fixed. ⚠️ The tag had to go before lander-templates.js, which captures the interpreter once at module evaluation — appended after, the markup looks right and nothing works. A test asserts the ORDER, because that is what a well-meaning tidy-up breaks. 💥 TWO HANDLERS CALLED FUNCTIONS THAT DO NOT EXIST, and both mutated cfg BEFORE dying. inUseLane called closeIntake() and render() — neither exists in this page — so Use this style applied the lane, changed styleId, replaced the section list, then threw: modal open, nothing repainted, no toast, and the page you were editing already gone. Reproduced live before fixing. intakeLoad called renderAll(), which has one hit in the entire repo — its own call site — so 📥 Load as draft, the payoff of the whole content intake, threw after replacing the global cfg and closing the modal, leaving the rail, props and canvas showing the PREVIOUS lander. ⚠️ That one was broken on House and Retro Pop too — nothing to do with generated styles. Both now use the repaint set their siblings already use, and intakeLoad gained the pickerHold(slug) that newLander's own comment says is needed or the dropdown names the previous lander. 🔬 MUTATION TESTING EARNED ITS KEEP TWICE. A mutant that neutered the publish gate to if (false) passed — because the assertions were source-text greps, and a grep proves presence, not effect, on the one guard keeping author-written CSS off a public origin. The gate was extracted into checkCfgLane so a test can execute it; five mutants now die, including "the gate always passes" and "the gate refuses lane-less cfgs" (which would have taken every hand-written lander offline — a far worse outage than the hole it closes). And a second mutant exposed a flaw in the test's own frame: slicing to the FIRST </style> let the attack string move the boundary the assertion measured, so the payload sat just outside the window and the check passed while the page was compromised. An attack can redefine the frame you inspect it through — the window now uses the LAST closing tag, and a script-count assertion sits beside it that cannot be framed away at all. ⚠️ WHAT IS NOT FIXED YET, said plainly: the copy catalog still offers House sections on a lane page — Tyler's actual question. intakeCatalog, catalogProps, renderProps, addSec and the three LANDER_STYLES helpers all still assume a section type is hand-written (47 consumers mapped: 6 throw, 12 silently empty, 21 silently wrong), lane schema entries are keyed k while every consumer reads key and carry no kind at all, and laneCss emits none of the .band/.wrap/.bar/.foot rules the renderer's own wrapper markup needs. Those are the next build, and they are listed here rather than left implied. Suite 37 files, 0 failing; matrix green.)5000 mg / 1600 mg / 25 %. This list is the only thing standing between a language model and a live page making numeric claims about a supplement, so approving it should be the best-informed decision here, not the blindest. WHAT THE REVIEWER NOW SEES, per number: an editable note saying what it is for, and beneath it the document's own evidence — the line it was found on, which line of the read text, and how many times the document states it (a figure repeated three times is a spec; one stated once is as likely to be a page number). A figure the loaded document does not contain is labelled ✍ typed by hand and counted in the footer, because the digits-unreliable path REQUIRES typing numbers off the label and a reviewer should never have to guess which of his rows the document actually backs. ⚠️ THAT PROVENANCE IS DERIVED EVERY RENDER, NEVER STORED — looked up against the loaded extract, so it cannot go stale against the document actually open, and typing "line 4 · straight out of the document, honest" into a note cannot forge it. ⛔ THE SAFETY PROPERTY, WHICH IS MOST OF THE WORK: A NOTE IS CONTEXT, NEVER PERMISSION. Storage stays one value | note line each — so every list typed before this keeps working — and everything after the first pipe is discarded for gate purposes. That rule exists because notes are prose and prose is full of numbers: a seeded note reading "Serving size 1 scoop (21.10 g), 30 servings per container", if the line were parsed whole, would silently approve 21.10 g and 30 servings without anybody deciding to — the exact failure this list exists to prevent. validateDraft now reads approvedValues, the builder's two call sites go through it, and a test asserts no raw newline-split of IN.numbers survives anywhere on the page. AND THE MODEL GETS THE NOTES, SCRUBBED. A bare list is permission without meaning — told 5000 mg and 1600 mg are allowed and left to guess which ingredient each belongs to, a model can write "Beta-Alanine 5000 mg" and pass the gate while being flatly false. The prompt now renders · 5,000 mg — Creatine Monohydrate, per scoop, with any unapproved figure inside a note replaced by […] — otherwise the prompt itself invites a violation the validator then strips, costing whole sections to a fault we caused. TWO REAL BUGS, both found by the new tests. + Add a number did nothing at all: the blank row was written out as an empty line and filtered straight back out by the reader — an editor and a gate want opposite things from a blank line, and only the gate's reading existed (approvedRows(raw, {keepEmpty}) now serves both; blanks still approve nothing). And "nothing approved yet" counted rows rather than values, so drawing one empty row to type into hid the one warning that matters most — that the page is about to be written with no numeric claim at all. ⚠️ A THIRD FIX IS A TEST, NOT THE CODE: the SEO-deck cap asserted a whole-prompt length under 6000, a proxy that goes red whenever any other sentence in the prompt grows — as it did here. It now measures what the deck itself contributes, which is the actual rule. TESTS: new test/lander-numbers-panel.mjs (39 assertions) executes the real panel functions lifted out of the page — not a copy, because a copy passes while the page is broken — plus new context/line-number/repeat-count assertions in test/doc-intake.mjs. Five mutants verified red, including the dangerous direction (a gate that approves every figure on the line) and the silent one (a line counter that never advances, invisible on a four-line fixture and drifting worse the longer the document runs — a 200-line fixture exists for that reason alone). One mutant survived first time and was recorded rather than waved through: first-occurrence-wins was asserted by nothing, because every fixture stated each figure exactly once. Suite 36 files, 0 failing; matrix green.)cfg.lane, so the page carries its own identity and a published page keeps working even if the lane is later changed. 👁 AND THIS ONE WAS BUILT WITH THE BROWSER OPEN, which is now the rule (Tyler: "for any test that requires opening a page should we not have your browser open and then look at it" — the memory rule already said exactly that and v8.40.0 had shipped behind a caveat instead). Looking immediately caught a structural break in this very change: an edit had left the Generate copy button inside a display:none wrapper, so the primary action of the whole panel would have been invisible while every test still passed. Suite 35 files, 0 failing; matrix green.)flex:0 0 auto on the spinner so it never shrinks, and min-width:0 on the text beside it so the copy WRAPS instead of forcing the row wider. THE LESSON IS NOT "WRITE MORE ASSERTIONS": node has no layout engine, so no test in that file could have caught it — layout has to be looked at. What the two new assertions can do is stop the fix being undone, and they are honest about being presence checks on the emitted style, because in a string-built component the style IS the mechanism. Verified live afterwards at a 319px container and with a 400-character label. Tests 19 → 21; suite 35 files, 0 failing.)POST /api/lander/intake/ref fetches a page AND up to four of its linked stylesheets and returns a measured fingerprint, so "a style based on this site" stops being guesswork wearing that site's name. ⚠️ THIS TAKES A URL FROM A BROWSER AND MAKES THE SERVER FETCH IT — the textbook server-side request forgery shape, and every guard exists for that: https only; private, loopback, link-local and .local hosts refused before any request leaves; redirects NOT followed, because a public URL that 302s to 127.0.0.1 defeats a hostname check that ran once, which is precisely how these bugs survive review — the hop is reported instead so the owner can use the final URL; plus a 1.5MB cap and an 8s timeout, because a caller-chosen URL can serve forever. 169.254.169.254 is called out by name in the tests: on most clouds that address hands credentials to whoever asks from inside, and it is the single URL an SSRF usually aims for. Stylesheets that could not be read are DISCLOSED in the fingerprint's notes rather than silently skipped, and fetching them removes the "partial palette" warning so that warning never decays into noise. THE INDICATOR (Tyler: "when you hit generate there should be an indicator that things are being waited for, not just nothing and then something happens after"): a disabled button relabelled "Generating…" is technically feedback and practically none on a call that can run past a minute. The banner now names what is running, states how long it usually takes, and ticks — a moving number is the only thing that proves the page is alive. The elapsed text is patched into one node rather than re-rendering, because a full re-render every second would destroy focus and caret position in whatever field was being typed in; the interval is cleared on both start and end so runs cannot stack timers; and it never offers a Cancel, because nothing here can actually cancel — the request is already with the Studio and stopping the spinner would only lie about it. Seconds are zero-padded so 1m 05s cannot be misread as 1m 50s on the one screen whose job is answering "is this still going?". TESTS: lander-ref-door 26 (driving the REAL handler through a recording fetch, so the assertions are about what never left the box) + lander-workbar 19 (executing the REAL functions lifted from the page). Four SSRF mutants verified red, each confirmed to have actually changed the file first. Suite 35 files, 0 failing; matrix green. ⚠️ NOT SEEN IN A BROWSER: this session cannot start a dev server, so the banner's appearance is unverified — its formatting, live region, ticking node and wiring are proven, its looks are not.)buildLanePrompt + parseLane complete the chain: measured reference → prompt → parsed lane → the real validator → the real interpreter, asserted end to end. This is the MASTER tier of the two-tier ruling — the user tier composes from an existing lane, this one CREATES one — and what comes back is JSON, so the model never writes code and nothing it returns can break the builder or another lane's publish. ⚠️ REFUSAL ONE: references arrive as MEASURED FINGERPRINTS, never as links. The model cannot open a URL, so handing it "https://example.com — I like the type" and asking for a style yields an invented look wearing a real site's name. The caller reads each reference with lander-ref.js, and the ⚠️ gaps in those fingerprints are carried INTO the prompt beside the findings — a partial palette presented as a complete one is how a fabricated design gets attributed to a real brand. And when nothing could be read, the prompt says so outright and forbids implying any site influenced the result, rather than quietly falling back to the written direction. Tyler asked for "both or either"; which one actually happened is his to know. ⚠️ REFUSAL TWO: the buy section is a call to action, not a checkout. Pricing, subscribe-and-save maths, discount stacking and the cart URL are ~200 lines of logic a template cannot contain, so the prompt states the limit and forbids inventing a price or a "save 25%" badge — an unapproved number on a live page is the exact failure the whole compliance gate exists for. The style direction is quoted VERBATIM (paraphrasing a creative direction is how it drifts), every section key and class name is namespaced to the lane id so a generated lane can never fight the two existing stylesheets, and the prompt tells the model plainly that House and Retro Pop are different DOCUMENTS rather than two settings of one — if its result could be mistaken for either with different colours, it has not done the job. parseLane lowercases section keys AND the starter together, because matching one against the other is what validation does and a case mismatch would fail every lane for a reason unrelated to its design. Tests: lander-intake 113 → 129. Suite 33 files, 0 failing; matrix green. ⚠️ STILL NOT REACHABLE: nothing fetches a reference URL yet (that endpoint needs SSRF guards — https only, no private addresses, size and time caps) and there is no builder UI to run this, preview the result, or attach it. The pieces exist and agree with each other; the door does not.)public/lander-ref.js (todo_1534). The intake has carried reference sites since v8.20.0 as a URL plus the owner's note, and the model has never seen one of them. That is fine for COPY, where "clean and premium" is real guidance. It is not fine for a generated STYLE: a look built from a link nobody opened is invented and then attributed to a real site — which is exactly what the standing rule about loading the real reference rather than a description of it exists to prevent. styleFingerprint(html, {css, url, note}) is pure — html in, fingerprint out — so it is testable without a network and can never be the thing that hangs a render; fetching stays the caller's job. It reports palette (ranked by frequency, with near-duplicates deliberately NOT merged, because two greys 2% apart are a real design decision on a site that bothered to make it), type, radii, shadow count, content widths, the sectioning skeleton with what each element leads with, and heading text. ⚠️ THE HONESTY HALF IS THE POINT. A thin fingerprint that reads as a complete one is worse than none, because it looks like evidence: a page whose real palette lives in an unfetched stylesheet still yields SOME colours from inline styles and SVG icons, and those few would pass for the site's design system. Every gap lands in notes, and fingerprintBlock carries them into the prompt alongside the findings rather than as a footnote — and supplying the fetched CSS removes the warning, so it never degrades into noise people learn to ignore. TWO BUGS FOUND ON THE FIRST RUN, both confident-wrong rather than missing: the font pattern excluded quote characters and a family value usually STARTS with one, so font-family:"Playfair Display",serif captured nothing and the fingerprint reported "typography is unknown" about a page that plainly declares it; and — worse — the leads scan read the next 1200 raw characters after an opening tag whatever they were, so a <header> containing only a <nav> reported that it "leads with h1" because it was seeing the hero further down the page. Structure is precisely what a lane copies from a reference, so that would have pushed the model to build the wrong document while looking authoritative. Bounded to the next sectioning boundary now. TESTS: test/lander-ref.mjs, 28 assertions, four mutants verified red (font regex reverted, leads unbounded, gaps dropped from the block, not-fetched disclosure removed). ⚠️ One mutation initially "passed" and proved nothing — const stop = -1 && after.search(…) is truthy and returned the search result unchanged; a mutant that does not mutate is worse than no mutant, so it was redone by line replacement and confirmed red. Suite 33 files, 0 failing; ratchet green; matrix green. ⚠️ NOT REACHABLE YET: nothing fetches a reference, and the lane prompt that consumes these does not exist — this version ships the module, not the feature.)@ctaUrl, @ctaLabel, @code, @title — threaded through as an argument, never module state. ⚠️ AND THE HONEST BOUNDARY THIS DRAWS, stated in the code so nobody discovers it by shipping a broken buy box: full commerce is NOT expressible as a template and must not be faked. The hand-written buy section is ~200 lines of price arithmetic (unit, subscribe discount, stacked offers), a Shopify cart URL assembled from a live-store probe, token substitution and save-badge suppression. That is LOGIC, and logic is exactly what a lane may not contain — so a generated lane gets a working CTA and nothing more: it can send a visitor to the cart, it cannot price the offer. The right long-term answer is a hand-written commerce PRIMITIVE a lane may reference by name, not a richer template language; every token added to that list is a step toward templates becoming code. 🎯 ONE REAL BUG, found the first time a cfg token was used inside a conditional: the block-parameter group read ([\w-]*), which excluded the @ prefix — so {{#if @code}} never matched as an OPENING tag at all, the scanner never saw the block start, and the matching {{/if}} then reported itself as a stray close. An honest error pointing at the wrong end of the template, which is the worst kind of diagnostic. AND A TEST-QUALITY FIX WORTH RECORDING: reverting that regex made the render THROW, and an uncaught throw killed the whole test FILE — so the suite reported a crash and named nothing. Every render in that block is now guarded, and the same mutation produces three clean named failures instead. A test that dies tells you less than one that fails. Tests 53 → 58; suite 32 files, 0 failing; control matrix green. ⚠️ Still not user-reachable: the authoring step, preview/attach, and lane persistence remain.) /id/claim on Appolis takes its subject from an unsigned body email and WRITES that account’s password, returning a live session — the only write the legacy ID_SECRET could still reach, and a capability bypass besides (this app carries grant: [] yet its key could take over an invitee). Appolis was about to require a real per-app key there, so all three call sites in this repo moved first: lib/freewill.js and lib/hubs.js (via the provReq helper added in v8.35.0) and worker.js login-time enrolment, all now sending env.APPOLIS_APP_KEY.
Verified before Appolis tightened, and again after: the Free Will signup probe — an existing email plus a wrong password, which walks the whole path and writes nothing — returns 401 “that email already has an account” in both cases, proving the claim step is reached and answering. Appolis then shipped v0.9.3; the legacy secret on that route now returns 401 unknown app key.) v8.35.0 (2026-08-19 — 🚑 SIGNUP WORKS AGAIN. Both enrolment surfaces had been dead for three weeks and nothing said so. todo_1691, from the Appolis machine-door audit (note_1689).
WHAT WAS BROKEN. The Free Will lander signup (lib/freewill.js) and the Kosmos Hub join form (lib/hubs.js, both the join and the approve-a-request path) enrolled people by POSTing /id/admin/ensure with the shared machine header. That route became master-cookie-only in Appolis v0.8.0 — a deliberate security fix — and has answered this caller with a 401 ever since. Every new person got "could not create the account — try again". Exploitable by nobody; pure availability. It hid for three weeks because all three call sites swallow the failure into a generic 502, and the hub-approval one swallows it into a bare catch {} — so approving a join request silently enrolled no one.
THE FIX. All three now use /id/provision/, the narrow door built for exactly this. That door names the calling app from its own key, so it refuses the legacy shared secret by design — these calls therefore carry env.APPOLIS_APP_KEY (already set here) instead of env.ID_SECRET, via a second header set kept deliberately separate from the existing idReq. /id/check and /id/claim still use the legacy header; only enrolment moved. Appolis registered the matching capability in its v0.9.2 (kosmos: { grant: ['kosmos'] }) — bounded by construction: Kosmos may grant only kosmos, never '', and that door cannot reset a password, mint a connector, change a role, delete anything or read the registry.
VERIFIED BY A TEST THAT COULD FAIL, AND THAT WRITES NOTHING. POSTing /api/join on freewill.appolis.app with an already-existing email and a wrong password walks the entire path — /id/check fails, provisioning runs, /id/claim reports the account is already claimed — while writing nothing at all (the account exists, so no record is created, and a master account skips the entitlement write too). Before the deploy that returned 502 could not create the account; after it returns 401 that email already has an account — use its password. The status change is the proof that the provisioning call now succeeds.
⚠️ NOT verified: the hub join form. It is the identical change in the same file, but the hub is access-gated so the probe could not reach /api/join. Complete one real join on a hub in a browser to confirm — and note that path hides its own errors, so watch for the account actually appearing, not for an error not appearing.) v8.34.0 and earlier below.
worker.js driven by the Appolis lane. ⚠️ RECONCILED: this entry originally said "no version bump"; the v8.34.0 entry above independently records that its number ALSO covers these two commits (c8852b8, 9188c9a). Both accounts are true — the commits shipped unnumbered and v8.34.0 adopted them — but two entries describing the same commits differently is exactly the drift this doc keeps warning about, so: the number is v8.34.0, and what follows is the detail behind it.) Full write-ups: appolis/APP_BREAKDOWN.md v0.9.0 and v0.9.1, and Kosmos note_1689. ① appolisResolve() no longer pre-verifies the shared SSO cookie locally. It called acct.verifySession(idToken, env.ID_SECRET) as a "cheap reject" and hard-returned null before ever reaching /id/resolve. That made Kosmos a second, silent authority on a signature only Appolis may judge — and it pinned the whole suite to a single key: the moment Appolis signed sessions with its own ID_SESSION_KEY, every valid cookie would have failed here and SSO would have died without the request ever reaching /id/resolve, so the route would show in no log and the cause would look like anything but a key change. Removed and deployed before Appolis flipped its key. Behaviour is unchanged by construction — a bad token used to be rejected here, now /id/resolve rejects it; the cost is one subrequest for an invalid cookie. This unblocked the suite's biggest security fix: ID_SECRET, whose plaintext sits in this repo's git history, was the HMAC key signing every appolis_id SSO cookie. It no longer signs identity at all.
② /api/my-connector now forwards the person's own signed cookie. That route hands back a credential, not information: the amt_ token acts fully as the person across every licensed app, needs no header, and no rotation invalidates it (Appolis rotated its session key the same day and those tokens were untouched). Kosmos now sends x-id-token alongside the existing ?email=; Appolis prefers it and echoes proven: true. The email stays until Appolis's phase C, because a local (non-SSO) Kosmos session has no such cookie.
🔴 STILL OPEN AND USER-FACING — todo_1691: /id/admin/ensure has been master-cookie-only since Appolis v0.8.0, but lib/freewill.js:356, lib/hubs.js:913 and lib/hubs.js:1491 still call it with only a header. Verified 401 live on 2026-08-18 — so the Free Will lander signup and the Kosmos Hub join form are both broken today and return "could not create the account". Nobody can exploit it; it is pure availability, and it has been silently broken since the trust rewrite. Fix is to move enrollment onto /id/provision/grant (needs a one-line APP_CAPS entry on the Appolis side) and send env.APPOLIS_APP_KEY. Verify by completing a real signup in a browser — all three call sites swallow their failures, which is why this hid.
lander-templates.js; a generated lane is DATA — markup templates plus one stylesheet — carried on cfg.lane and rendered by the new public/lander-lane.js. 🔑 THE CONSTRAINT THAT SEEMED TO FORBID THIS HAD A PREMISE THAT NO LONGER HOLDS. "Nothing machine-written may land in the hot path unreviewed" was about unreviewed code affecting everyone; master-admin-only authorship of a reviewed draft removes both halves. What replaces it is stronger anyway: a template is not code. The interpreter never evals, never builds a Function, never interpolates into a template literal — so a bad lane cannot break the builder, cannot break another lane's publish, and cannot cost a stray backtick, which has broken every publish four times (most recently while writing a comment, earlier the same day). Four template forms — value, {{#if}}, {{#each}} with pipe-separated fields matching the repo's existing repeater convention, and index tokens. ⚠️ Deliberately NO raw/unescaped form: markup lives in the template, props are always text, or a lane author would have to reason about every value a builder user might ever type. WIRING: laneTemplatesFor(cfg) builds the pass's renderers and is threaded as an argument, never module state — a module-level "current lane" is exactly what breaks the day a render goes async. bandFor(s, laneT) resolves the band from the lane's own section. landerCss branches to laneCss(theme, lane), which emits the SAME font block and the SAME eight theme tokens as a hand-written sheet (that is what makes a theme mean the same thing in every lane) and then the lane's own rules. ⛔ A MISSING RUNTIME REFUSES LOUDLY rather than publishing a page with every lane section silently dropped — a blank page reads as a content mistake and would be debugged for hours. TWO REAL BUGS, both found by testing and both recorded in the code: a sanitiser rule ending (?:</\1>|>) matched the first > and stripped only the OPENING <script>, leaving its body in the page as visible text (paired and void elements now have separate rules); and the block scanner is a scanner rather than nested regex replaces, because a regex pass cannot tell {{/if}} from {{/each}} when they nest — an unbalanced template now THROWS, since a half-rendered section that looks plausible is worse than a refusal the author sees immediately. TESTS: test/lander-lane.mjs, 53 assertions including the full render path; four mutants verified red (escaping removed, sanitiser reverted to the buggy single rule, css requirement downgraded, starter requirement downgraded). The load-bearing pair asserts a retro-pop page still renders deterministically and carries no trace of the lane machinery — if that ever fails the design has failed, whatever else passes. Suite 32 files, 0 failing; control matrix green. ⚠️ NOT USER-REACHABLE YET: the builder UI, lane persistence, and the intake step that authors a lane from your references are all still to come; the render path is inert until a cfg carries lane. ⚠️ THIS NUMBER ALSO COVERS TWO appolis-lane COMMITS (c8852b8, 9188c9a — stop pre-verifying the shared SSO cookie locally; forward the signed cookie on /api/my-connector). They shipped under v8.33.0 without a bump; their APP_BREAKDOWN entries were written, so the record is honest and only the number was stale. Recorded here rather than silently swept, because a version covering two unrelated builds is the v8.0.1 problem this doc already carries a retrospective about.)authored field of mockup or model and say which in words. Provenance never excuses a violation and never rewrites a line. ⚠️ The upload branch deliberately does NOT seed the approved-numbers list — a creative mockup is full of numbers written for effect, and seeding them would put unapproved figures on the one list the gate treats as permission to publish (the same trap the SEO branch already avoids). TESTS: test/lander-intake.mjs 99 → 113, with five mutants verified red: block suppressed, storyline deference removed, provenance forced to model, cap removed, and the normaliser broken. ⚠️ That last mutant is why the suite gained one more assertion than planned. Breaking the whitespace normaliser passed EVERY test, because both sides of the comparison are mangled identically and a broken-but-consistent normaliser still matches identical strings. It only bites when the whitespace genuinely DIFFERS — which is the entire reason it exists — so a wrapped, double-spaced mockup line against a single-spaced draft prop now pins it. Suite 31 files, 0 failing; control matrix green.)server.js called server.listen(PORT, cb) with NO host argument, which binds the unspecified address — every interface — while the console cheerfully printed "http://localhost". Beside it, localAccount() returned a hardcoded role:'super' account whenever PASSWORD was unset, and .claude/launch.json starts the server with no env block, so the bypass branch is the branch that runs. The file it serves is data/db.json — 1,247,078 bytes of the real board, 22 projects, 672 notes, 671 of them plaintext. So while node server.js was running on any shared network, the board was readable AND writable by anyone who could route to the port: DELETE /api/notes/<id> needs no cookie. AND IT WAS NOT ONLY THE BOARD — /pf/<project>/<path> serves any file inside a linked project folder, and .dev.vars lives in that folder holding the live STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET. Three fixes, each independently sufficient for its own half: the bind defaults to 127.0.0.1 (HOST=0.0.0.0 still works but is now a deliberate act that prints a warning), the no-password bypass refuses any non-loopback peer, and /pf refuses dotted path segments outright — plus its boundary test now compares WITH a separator, because startsWith(folder) also matched a sibling folder whose name merely began with it. ④ A URL COULD MINT DURABLE STATE. lib/hubs.js loadHub() passed the slug straight off the URL into HUB_DO.idFromName(), and HubDoc persists on its MISS path too (storage.put('slug', …) when KV has no record) — so an unauthenticated GET of hub.appolis.app/<anything> created a Durable Object per unique string, from the open internet, unbounded. validSlug() ALREADY EXISTED and was enforced on the WRITE path only; the public read never called it. It is now applied inside loadHub, the one choke point all six callers pass through. ⚠️ The first cut of that guard copied validSlug's regex instead of calling it and silently dropped its 2-character minimum, so a 1-char slug still allocated — the test caught it, which is the whole argument for a test that runs the code rather than reading it. ⑤ MOSTLY ALREADY FIXED, AND THE ONE DOOR LEFT BEHIND. /internal/mcp and /internal/video-projects both demand a signed, audience-bound envelope (appolisIdentity), and APPOLIS_APP_KEY is confirmed set in production, so holding the shared secret does not let anyone act as a person there. /internal/bug-intake was the twin left raw: ID_SECRET-only with no hubOk() parity (so it could never migrate with the others), reporter taken verbatim from an unsigned body, and no length cap on the attributed fields — which land in the MASTER board document that is read whole on every request, making one request an unbounded write into the document the entire app depends on. It now shares hubOk(), prefers a signed envelope when one rides along, labels an unsigned reporter (unverified) rather than presenting it as identity, and caps every attributed field. ② AND ③ CAME BACK CLEAN, VERIFIED NOT ASSUMED. grep -c "internal" server.js returns 0 — every shared-secret door is absent locally or explicitly 501s, so there is no shorter local path to any of them. And /api/board/reload puts method === 'POST' inside the ROUTE CONDITION, so a GET never enters the handler at all; the guard is structurally ahead of the side effect rather than merely present. 🧪 THE TESTS RUN THE CODE. test/dev-server-exposure.mjs spawns the real server and makes real socket connections; test/hub-slug-allocation.mjs spies on the DO binding so that touching idFromName at all is the failure; test/bug-intake-identity.mjs drives the shipped worker.fetch against a mocked platform and reads the stored board back. Every one was mutation-verified — each fix reverted in turn, each time failing exactly its own test and nothing else. A test that greps the source for its own fix passes after the fix is deleted, because the string survives in the comment explaining it. ⚠️ NOT FIXED, AND IT IS A LIVE OUTAGE, NOT A HARDENING TASK (todo_1669). The envelope cutover was half-completed: the doors were hardened and the keys set, but the CALLERS were never taught to mint. Phantasia's 🎥 Video Projects area gets a 403 from this app and shows an EMPTY LIST; this app's own Agora panel gets a 403 and shows "unavailable". Both swallow the refusal, which is why it went unnoticed for months. It cannot be fixed in any one app: per-app signing keys live only in the appolis ID worker and mintEnvelope is reachable only from inside its own forwarding path — there is no door another app can call. What IS fixed here is the silence: the Agora call now logs a refusal and returns reason:'identity-refused' distinct from upstream-<status>, additively. ⚠️ Also untouched and outside the five: ID_SECRET, STUDIO_KEY and VAPID_PRIVATE_JWK are plaintext vars in wrangler.jsonc — the current working file, not merely history. appolis/ROTATION_RUNBOOK.md already inventories this and correctly records that no repo has a git remote, so exposure is local-disk only; rotation is an ops task with stated consequences and is deliberately not bundled into a code change. Suite 1614 across 31 files, 0 failures; coverage-ratchet exits 0.)hub.appolis.app/mmc-security, doc_f84dbf0e8f65) and said it "does not look exactly like the one that we created". It did not, for two separate reasons, and only one of them was cosmetic. THE TYPEFACE. The hand-built file loads Inter 400–800 from Google Fonts; the generator shipped system-ui,'Segoe UI' because test/invoice-doc.mjs:92 forbids a <link> or a font CDN — a rule that exists for good reasons (the doc is served into a sandboxed opaque-origin iframe where an external request is a privacy leak and a blocked one silently reflows the page). So on Windows the whole sheet re-set in Segoe UI. Measured, not guessed: the same string at weight 800 renders 533.05px in Inter and 444.56px in the fallback — 20% narrower — which moves every wrap point in the document. THE FIX honours both constraints instead of picking one:** lib/invoice-font.js carries the Inter variable latin subset (48,256 B → 64,344 B base64) as a data: URI @font-face; one file covers the whole 400–800 range rather than five static weights. A data URI is not a request, so the assertion stays true rather than merely satisfied — verified in a real browser: document.fonts reports Inter 400 800 loaded while performance.getEntriesByType('resource') returns []. The document goes 11KB → 75KB against the 200KB inline ceiling at lib/hubs.js:68. Regenerate with scripts/build-invoice-font.js; the blob is committed, so no ordinary build touches the network. THE OTHER REASON WAS WORSE, AND IT WAS NOT COSMETIC: three visible blocks of the client's copy could not be produced AT ALL, because the fields holding them were readable by the renderer and writable by nobody. engagementNote (the Engagement sub-line, "Emergency remediation & site restoration") was rendered at invoice-doc.js:115 and set by the test fixture at test/invoice-doc.mjs:25 — but was never in PATCH_ALLOW, so the suite had been proving a document the product could not build. scope (the Scope paragraph) the same. retainer.priceNote — the sentence after the price, i.e. what the retainer actually buys — had no field at all, and the retainer sanitiser rebuilds the object wholesale, so adding it to the renderer alone would have silently done nothing. All three now have a door, a default and a length cap. ⚠️ THE LESSON WORTH KEEPING: a green suite over a fixture the UI cannot produce is not coverage, it is a second implementation. SMALLER DELTAS, all measured against the real file: the retainer box uses a DARKER companion green #25603a for its heading and price inside its own #2f7748 border — the template's only use of it, and what gives the box a tonal step; the generator had flattened all three to one tone (now accentDark, keyed to the default accent so a brand colour still renders in its own hue, with .toLowerCase() load-bearing because okHex accepts #2F7748). The Engagement sub-line ran dates-first and spelled a two-day job "Work completed 3 August 2026 – 4 August 2026"; it now reads "Emergency remediation & site restoration / Completed 3–4 August 2026" via dateRange() — tight en dash, month and year once. A fourth Due meta row printed the same fact as "Net 15", suppressed now when the terms label genuinely encodes the interval (netDaysOf), so "Due on receipt" plus an explicit date still shows one. Base size 15px → 16px, matching the template's inherited default. The grey .foot line went — the template has none, and it repeated the number and date already in the header. And a five-line HTML comment about PATCH_ALLOW was being emitted INTO the client's document: invisible on screen, in View Source on every invoice a paying client receives. Moved into the source, where it belongs. VERIFICATION WAS A BROWSER, NOT A DIFF. Both documents served side by side and measured through getBoundingClientRect: .sheet, .brand, .totals, .retainer, .terms and total document height 1576px — every box identical, zero deltas — plus computed colours compared. Worth recording why geometry alone was not enough: it passed while the retainer green was still wrong, because colour does not move boxes. Suite 41/42 files green and invoice-doc 61/61; the one failure, test/move-note.mjs, fails identically on a clean tree and passes when run alone — a pre-existing runner flake, not this change.) v8.30.0 (🔪 THE AD ID WAS BEING CUT IN HALF, AND THE HALF WAS BEING BUDGETED AGAINST (todo_1603). LANDCTX handed the engine window.location.search cut at 400 CHARACTERS. A Meta landing URL is longer than that, so the cut landed inside whichever value straddled character 400 — on flip-8, the ad id. Measured live before anything was touched: 45 of 364 sessions over two days attributed to ad ids that do not exist — 12, 1202550, 1202550126319, each a prefix of a real 18-digit id — with a phantom bucket named 12 outranking nine genuine ads. The VIEW path parses the real request URL and was never affected, which is exactly why the signature was rows with sessions and zero views, and why KV's view-only tallies held nothing but whole ids while Analytics Engine held fragments. THE FIX: q is rebuilt from a 13-parameter allowlist — precisely what the engine reads out of it — first-occurrence-wins, with one wire budget applied at pair boundaries and an over-budget pair dropped WHOLE. Nothing outside the allowlist is forwarded, so an email, a phone or a session token sitting in a landing URL is now dropped instead of shipped to analytics. ⚠️ AND THE PART WORTH READING, because the first version of this fix was worse than the bug. It decoded each value, capped it, and re-encoded it; a 12-agent adversarial audit returned 20 confirmed findings and every one traced to that single decision. slice() on a DECODED string splits a surrogate pair → encodeURIComponent throws URIError → the throw escapes the IIFE → the ~9,900 characters of page script below it, every beacon and the whole Meta pixel, never run. The page renders perfectly and the visit disappears from analytics; there is no window.onerror on these pages, so nothing would have reported it. A fix for a mis-attributed session that silently deleted whole sessions instead. Three more from the same root: decodeURIComponent throws on a malformed escape (a bare % in a promo tag) and blanked a value the view path decodes leniently and keeps; capping ran BEFORE the engine's unexpanded-macro guard, and MACRO.test does not commute with truncation, so a value with a {{macro}} tail past the cut arrived looking like a real 40-char id; and lower-casing the key made the beacon report ad ids the view path never sees — re-creating the very signature this to-do was found by. THE RULE THAT REPLACED ALL OF IT: forward the RAW pair, never a reprocessed one. The page sends the pair exactly as the URL had it, keys matched decoded-but-case-sensitive (ad%5Fid IS ad_id; AD_ID is not), and the engine parses it with the same URLSearchParams the view path uses — the two cannot disagree, not because they were checked but because they are the same bytes through the same parser. The per-value cap table was deleted outright: the wire budget already bounded the payload, so all the caps added was a second, lower threshold at which the two paths start disagreeing — channelOf is the one reader with no cap of its own and matches with UNANCHORED regexes, so a 60-char cap on utm_source flipped …facebook from Organic Social to Direct. TESTS: new test/lander-beacon.mjs, 70 assertions, executing the REAL shipped ES5 pulled out of a rendered lander — and it deliberately CROSSES THE REPO BOUNDARY to import lander-engine, because the design's central claim is about two repos and the audit found five divergences while both suites sat green. Six mutants verified red (the original 400-char cut, lower-cased keys, raw-key matching, decode+cap+re-encode, break-instead-of-skip, last-wins duplicates); the engine's two beaconDims assertions were found VACUOUS — a 1164-char fixture against a 1200 ceiling, asserting a disjunction that accepted the blank an over-cut produces — so the original bug could be pasted back in and the suite stayed green; both are now load-bearing. Suite 28 files, 0 failures; revenue still 81, so the money key is provably untouched (adKey reads the full unsliced search and never goes through q). Engine side is lander-engine v1.8.1. ⚠️ THIS CHANGES NOTHING UNTIL EACH LANDER IS REPUBLISHED — pages are rendered at publish time and served as stored bytes, so deploying alone fixes zero sessions, and the three live landers keep emitting fragments until then. Also filed todo_1619: qp keeps the LAST duplicate parameter while the engine keeps the FIRST; it feeds adKey, so it was deliberately left alone.)public/audio-measure.js). ⚠️ Preparatory commit: its subject is "audio-measure: the Studio 7 vocal measurement pass, ported in-house" and carries NO version — config.js is untouched, so the file shipped under the already-set v8.28.0. Per Tyler: "if it's not something that required ai or an API service to facilitate then we need to utilize anything in house we can. the more in-house the better. but never to sacrifice functionality." Hearing WORDS needs a speech model; measuring WHERE THE VOICE IS does not — it is signal processing, and Studio 7 already solved it (analyze_audio.py, 2026-07-31, five tracks measured). This is that pass in JS: pure functions over two Float32Arrays, no DOM, no fetch, no key, nothing leaving the machine. The chain is carried over intact — centre mask (1-|L-R|/(|L|+|R|))^3, band limit 200–4000, HPSS harmonic mask, per-bin median floor at ALPHA 1.3, RMS envelope, hysteresis at peak−13dB with 5dB of lag. Every constant came out of the original's grid sweep, not from ear; they are copied, not re-tuned. Plus align()/timedLines(), the syllable-weighted monotonic DP that fits KNOWN lyrics onto MEASURED phrases — with the lyrics pasted, timed lines need no transcription at all, and each line comes back marked measured / merged / proposed so the one place a boundary is NOT read off the audio says so. ONE EXACT OPTIMISATION: the original takes four STFTs per frame (mid, L, R, L−R); the DFT is linear and the window precedes it, so mid and side are derived from L and R by arithmetic — two transforms, identical output. ONE DELIBERATE DIVERGENCE — digital silence: the envelope is normalised to its own peak, so silence normalises to a flat 0dB and every frame clears the gate; the original would report singing across the whole track, and it never saw that case because it was only ever fed real songs. A browser gets whatever is uploaded, so silent is the distinction and measure() returns no phrases. 33 tests, and the three separation stages are each proved by a signal they must REJECT — that took two passes, and the first pass is the lesson: a lone bass tone and a lone snare pattern on an otherwise empty track both "passed", and mutation testing showed that deleting the band limit and deleting HPSS left them GREEN. The floor was rejecting them, not the stage under test, because a probe spanning the whole track is stationary by definition. The probes now sound only in the GAPS between sung phrases, where the floor cannot reach them; mutation-verified in the real suite, removing the centre mask fails the pan test alone, while removing HPSS or the band limit collapses three measured phrases to one and fails only their own tests. ⚠️ Shipped here UNWIRED — nothing imports it, nothing in the app can reach it, and the commit argued that meant no served behaviour changed and no version bump was needed. The next commit (a8a33d3, v8.29.0) records that reasoning as wrong: public/ is served, test/version-guard.mjs went red, and "nothing imports it yet" was called a rationalisation, not an exemption. ⚠️ No control-matrix result, deployment, or browser verification is mentioned. —— PART TWO, WIRED IN (a8a33d3, the bump itself). 🎧 THE EARS COME IN HOUSE — measured here, transcribed only if we must. Wires public/audio-measure.js into the intake. The default path for hearing an ad is now local: no key, no upload, no per-run cost, nothing leaving the machine. Whisper stays as the fallback, because it is still the only thing that can supply words nobody wrote down. THE DECODE WAS THE WHOLE PROBLEM. inHearStage rendered OfflineAudioContext(1, ..., 16000) — one channel. WebAudio's stereo-to-mono downmix is 0.5(L+R), exactly the sum that annihilates |L-R|, so the centre mask would have been mathematically unrecoverable the instant that render completed. It now renders 2 channels at 32000, and both halves matter: the median windows are calibrated in FRAMES AND BINS, so 16000 would have measured at the wrong scale while looking entirely plausible. measure() now throws on any other rate rather than coping, and a test pins the refusal. RUNS IN A WORKER, falling back to the main thread if one cannot be constructed — a 3-minute track is ~3s of arithmetic and freezing the page for that is a real cost. It cannot be chunked instead: the noise floor needs every frame of every bin before a single envelope value exists, and making the pass async would cost the purity that lets node test all of it. Worker or main thread, never a half-measure that yields mid-pass. toHeard() emits the SAME {text, chunks:[{t0,t1,text}]} shape the hosted transcriber produces, so heardAtTimes and all three prompt consumers are untouched — a different producer, not a different contract, and lander-intake.js with its 99 assertions is unchanged. .text is a GATE: both timed prompts read an empty one as "never heard anything" and silently drop to their blind branch, so toHeard falls back to the raw sheet rather than returning an empty string that looks like success. A CORRECTED LINE NOW RE-TIMES FOR FREE. The old textarea wrote .text only, so text and chunks drifted apart the moment a typo was fixed; the phrase map now rides on the heard object, the DP is sub-millisecond, and an edit re-fits every boundary. ⚠️ Only possible on the local path — a hosted transcript has no phrase map, so heardPhrases is nulled there and the textarea stays text-only. THE APPROVAL GATE STAYS BUT CAN NO LONGER QUOTE A COST, because the default path has none: it quotes elapsed time and states plainly that nothing is uploaded. The old copy — Whisper, the fal key, "a cent or two per minute", the 2-hour self-destructing link — is now true only of the ⚡ Transcribe button and says so there. And the honesty surface is the point: the note reports how many lines were read straight off the audio versus divided by syllable weight inside a shared phrase, ⚠️ warns when a mono track meant the voice isolation never ran, and says plainly when no phrases were found rather than implying a measurement happened. The version bump exists because public/ is served: the previous commit shipped audio-measure.js under an already-set v8.28.0 and test/version-guard.mjs was right to go red — "nothing imports it yet" was a rationalisation, not an exemption. The commit reports a suite going 33 → 41 and labels it the intake suite, ⚠️ though the only test file it changes is test/audio-measure.mjs and it separately states lander-intake.js's 99 assertions are unchanged. All other suites unchanged, control matrix green, builder inline script syntax-checked — ⚠️ no deployment or browser verification is stated, so the Worker-vs-main-thread fallback is described but not stated to have been exercised in a browser.) v8.28.0 (💣 THE PUSH THAT COULD ERASE THE BOARD, AND THE INVOICE THAT WASN'T YOURS. Two fixes, both found while chasing a missing invoice — and ⚠️ the commit states plainly that NEITHER of them is that bug. The missing invoice itself is not fixed here and the commit does not say what became of it. scripts/push-db.js wrote the account's board key DIRECTLY via wrangler kv key put, going around BoardDoc, with two silent holes. First, a WARM Durable Object never cold-starts, so it kept serving its own cached copy and overwrote KV with it on the next save; BoardDoc's /reload handler says in as many words that any script writing the KV key directly MUST call it, and /reload had zero callers in the whole repo. Second, nothing compared the local mirror against the live board first, so pushing a file pulled days earlier erased everything created since, with no error. Now there is a pre-flight diff that REFUSES the push and reports every live record it would destroy — full counts per collection, listing record ids (up to twelve shown per collection, then an ellipsis) — checked against the real data/db.json, which is 8 days stale and would have deleted the whole MMC Florida project — plus the mandatory /reload poke afterwards, which fails loudly if it cannot land. PUSH_DB_FORCE=1 is the deliberate escape hatch for a genuine rollback. ⚠️ The poke runs AFTER the KV write and needs KOSMOS_PASSWORD: if it is missing, or sign-in or the reload fails, the script dies having ALREADY written KV, and its own error text says the live board is still serving its cached copy and will overwrite the push on its next save. POST /api/board/reload is the door for that poke: owner-session gated like every other write, resolved from the CALLER's own account id so it can never touch another account's document, and idempotent because adoption is just a re-read. Deliberately not a new admin-key scheme. THE INVOICE WITH SOMEONE ELSE'S NAME ON IT. In lib/api.js, the Worker passes actor as a bare {tier} for basic-tier accounts so usage caps enforce server-side — but that object carries no identity, and actor || ctx.account therefore never fell through to the session. A basic-tier user's invoice lost its by: stamp AND fell all the way past the from.name/from.email fallback to the hardcoded 'Spartan Studios' sender — someone else's business name on their invoice. Fixed by merging rather than choosing: Object.assign({}, (ctx && ctx.account) || null, actor || null) — identity from the session, tier from the caller — so a future cap check inside an invoice route still sees .tier, and every non-tiered caller is unchanged. Master accounts were never affected. The commit states it was verified across all four call shapes. ⚠️ No test counts, suite totals, control-matrix result, deployment, or live exercise of either the fixed push path or /api/board/reload is stated.) v8.27.1 (🎯 CLOSE THE HEAD GATE'S LAST INVARIANT — scanned once, not once per section. ⚠️ Test-only change plus the version bump: the two files touched are config.js and test/lander-intake.mjs, so no shipped behaviour changes here — this closes a gap in what the tests could catch, not a live defect users had hit. The head-copy assertions counted DISTINCT locations through a Set, which catches the head being folded into a section's strings but stays green if the head is scanned once PER SECTION — a Set collapses the duplicates. That regression would emit N identical title flags, and the test file's own header names why that is the expensive kind of wrong: noisy violations teach the reviewer to ignore the list, which is worse than the noise it adds. Mutation-verified by moving the head scan inside the section loop: it fails the new assertion and exposes a second failure mode the old assertions also missed — with zero sections the head would not be scanned at all. Also adds the assertion the v8.27.0 SEO-brief feature is actually about: an agency writes the exact title tag it wants, that title is MODELLED through the contract rather than pinned, so it lands in the gate like any other generated claim — "90 Servings, $49.99" raises two number violations while the product's own "8" rides free as an allowed bare integer. Intake 96 → 99; all other suites unchanged, control matrix green. ⚠️ No deployment or live verification is mentioned.) v8.27.0 (📐 THE SEO BRIEF — the ARRANGEMENT, and the three firewalls it needs. Per Tyler, an SEO brief here is not a keyword list: it is an external agency document proposing page STRUCTURE, TAGGING and heading placement — "a nice guideline to kind of help build" — and its styling notes are explicitly not wanted ("we don't really take too much styling notes from them because they are not meant for that"). So it becomes a fourth ingredient role alongside the three that already exist: facts = WHAT may be asserted, voice = HOW it is said, style = how it LOOKS, brief = ARRANGEMENT, where the copy sits and what it is called. ⚠️ Its weight is ADVISORY, not authority — the precedence is printed INSIDE the block so the ruling is visible in the staged prompt: approved facts > approved numbers > the style's lane > storyline > the brief, and the block itself tells the model the brief never adds a claim, never adds a number and never adds a section. THREE FIREWALLS, and only one had a precedent. CLAIMS is inherited — the block sits above the approved facts, is capped at 3000 chars, and everything that comes back is scanned by the head gate and the section gate that shipped in v8.26.0. STYLING is new, and the firewall is the separation, not a sentence: o.seoBrief is read in exactly one place, and neither dirBlock nor themeRule can see it, so an agency's "warm cream background" has no path to the palette. It is asserted as an identity — the document appears in the prompt exactly ONCE — and verified by mutation: merging it into styleDirection fails that test with "got: 2". LANE is existing, one string widened — a section the brief invented is dropped and now says WHY, but only when a brief actually rode along. HEADING LEVELS ARE WELDED IN, AND THE UI SAYS SO. 24 literal heading tags in the renderer and zero prop-driven ones, so "make this line the H1" can only ever mean "put that line in the prop whose template emits an h1". A brief's tagging table therefore splits three ways, and the new panel says which is which against the chosen style: section order is honoured as written; heading placement is honoured as a SLOT (naming rphero's two-plate split and rpscrubhero's first-beat-only rule); and the kickers, FAQ questions, stat numerals and guarantee headline cannot be tagged at all. Roughly a third of a real tagging table lands in that last bucket — swallowing it silently and reporting success is exactly the dishonesty the rest of this intake exists to prevent. Styling lines in the brief are FLAGGED, never stripped: "cream" is a colour in one sentence and a flavour in the next, and a false strip costs a sentence the owner never knew he lost. HEADING_SLOTS is a second copy of renderer truth, so scripts/control-matrix.js now locks it — the claimed props must still exist, the lane must be able to carry an h1, and the rendered page must emit EXACTLY ONE. rpscrubhero's beats 2..N are paragraphs styled to 168px, and promoting them would silently give every scrub page four h1s. The lock caught a wrong assertion on its first run (the commit does not say which one or what it claimed), and mutating a prop name to a stale one fails it with "GONE: headline". Also: intakeCatalog now carries a line prop's label through, so the model is finally told that rpscrubhero's beats are "eyebrow | headline | subline | align" — the difference between the slot advice being real and being a lie. The brief uploads as its own fifth ingredient card, deliberately NOT beside the style direction box, because physical separation is part of the firewall. And empty title/desc violations now render coral, not gold: the builder substitutes "New lander" at load, so that flag is the only warning before a placeholder name is published. ⚠️ Judgement calls made rather than blocking, all stated as reversible: storyline outranks the brief on order; the agency's title tag is modelled and gated, never pinned verbatim; the brief is session-only like every other ingredient; cap 3000. Intake 79 → 96, all other suites unchanged, control matrix green, builder inline script syntax-checked — ⚠️ no deployment or browser verification is stated.) v8.26.1 (🧱 A CORRECT ANSWER WAS BEING SHREDDED, SILENTLY. Four props default to arrays of objects*, not strings: stats.items {n,label}, cards.cards {ic,title,text}, faq.items and rpfaq.items {q,a}. The prop coercion asked only Array.isArray(def[k]) and then ran v.map(String) — so a model that answered an FAQ correctly had its rows stringified into a single "[object Object]", with zero violations and zero fixes. A perfect response became a blank FAQ and the gate reported a clean bill of health. Found by REPRODUCTION, not by reading.⚠️ The suite could never have caught it: the fixture catalogue held only flat strings and string arrays, so the case was literally inexpressible. rpfaq is in the catalogue now — it is the one row template the fixtures cover; stats, cards and faq are fixed in the code but still not represented in the test catalogue.
coerceRows(v, shape, at, fixes) takes the shape from the template's own default row — the same principle the catalogProps comment already states, the template is the truth and never a second copy of it. Junk rows are dropped and SAID, unknown sub-keys stripped and SAID, missing cells emptied rather than left undefined, and a non-array offered where rows belong is dropped with its type named.
The half that had to land with it: the claim scan now reaches INSIDE row props. Before, that loop was safe by accident — rows had already been shredded into a string that carried no claim. Now that a real FAQ answer survives coercion it must be scanned: an answer reading "clinically proven 40% faster" is exactly the sentence an FAQ block invites, and fixing the shape without widening the scan would have quietly routed the most claim-dense copy on the page around the compliance gate entirely.
buildPrompt now announces a row prop's sub-keys via propShape() — items (array of {q, a} objects) — reading a new shape field that intakeCatalog() in public/lander-builder.html lifts off the template's default row. Only lines props were ever annotated, so the model was shown a bare items and had to guess whether it wanted strings or objects; when the caller supplies no shape the annotation degrades to silence rather than to a wrong guess.
The commit records verification by mutation: disabling coerceRows fails exactly the three row assertions and reproduces the shredded string verbatim, with all other suites unchanged. Two assertions that probe inside a row now check it IS a row first, so a regression fails cleanly instead of throwing and hiding every test after it. Intake 71 → 79 (both counts verified in this repo).) v8.26.0 (🏷 THE HEAD COPY IS GENERATED COPY, AND NOTHING WAS CHECKING IT. The model is required to write title and desc; they become the live page's <title> and its meta description and ship straight onto the page. They were the only strings in a draft that reached a visitor without passing a gate — and they are the two a stranger reads FIRST, in a search result or a share card, before the page itself. On an FDA-line supplement brand that is exposure, not an oversight to file. Both compliance guards had only ever run over section props.
The number gate and the spokesperson heuristic are hoisted out of the section loop into scanClaims(strings, at, approved, violations) in public/lander-intake.js, so the same judgement applies to any copy under any label — scanClaims([title], 'title', …) and scanClaims([desc], 'meta description', …) now run beside the per-section pass. The two spokesperson regexes are lifted to module constants SPOKESPERSON_ACT / SPOKESPERSON_OBJ, and the comment records why that is safe: neither is /g, so .test() carries no lastIndex state between calls. A claim is a claim wherever it is written.
Blank is raised as a VIOLATION, never an auto-fix. Everywhere else in that file a fixable problem gets fixed — but the only honest way to fill a title is to write one, and inventing either is precisely what this gate exists to prevent. A new empty violation kind carries it. Both strings are trimmed before anything else looks at them, and validateDraft now returns the trimmed values rather than the raw ones, so whitespace-only is judged AND returned as the blank it is and cannot ship as spaces that read as measured.
⚠️ Left standing, and said so in the code: the renderer still emits content="" for an empty description instead of omitting the tag (public/lander-templates.js — <meta name="description" content="${esc(cfg.desc || '')}">), so a blank publishes as though it were a deliberate choice. The comment names that as the renderer's half of the fix and it is not in this commit.
The commit records verification by mutation: commenting out the two head-copy scans fails exactly the four claim assertions and nothing else, and all other suites unchanged. Intake suite 61 → 71 (both counts verified in this repo). The new empty kind renders through the existing generic violation row in public/lander-builder.html, which prints every violation regardless of kind — no consumer drops it.) v8.25.0 (🎧 THE EARS — the ad is heard, and the heard voice outranks the paper one. Tyler: "Audio is necessary, build it now. Lyrics or script should just be a backup pairing with the audio analysis." One version after v8.24.0 filed transcription on the roadmap, it ships. inHearStage() decodes the uploaded video's audio in the browser and re-renders it through an OfflineAudioContext to 16kHz mono — speech-recognition rate — then hand-encodes it to WAV in inWavEncode(). Nothing leaves the browser unapproved: the stage panel names the resulting file's actual size, the destination (Whisper, through your Studio's fal key), the rough cost ("a cent or two per minute of audio"), the 2-hour self-destruct and the fact that it never enters the media pool, and only ⚡ Transcribe it starts the run. Cap is 10 minutes of video.
The transient URL, and why it is public. worker.js gains POST /api/lander/intake/audio — base64 in (413 above 28MB of base64), stored at intakeaudio:{id} in the LANDERS KV with expirationTtl: 2 60 60 — and a public GET /intake-audio/{id} matching [a-z0-9]{30,50}, registered above the account resolution because the transcription provider must fetch it without our cookies. The comment states the doctrine plainly: the unguessable id IS the secret, same as an unpublished lander slug; the TTL is the cleanup. Transcription runs through the Studio — per the commit body phantasia gains /api/studio/ai/transcribe + falTranscribe (submit → poll with backoff, bounded 50s, spend recorded in the same ai_jobs ledger), none of which appears in this diff — returning full text plus timed segments.
**🕐 HEARD AT, NOT HEARD SOMEWHERE.** heardAtTimes() in public/lander-intake.js collects the segments overlapping each scene's window [this scene's start, the next scene's start), so both analysis prompts say what the track sings AS each shot plays — 1. [12s] then I flipped the switch — and a wordless window renders (no words — music/silence) rather than silently borrowing a neighbour's line. That is the difference between "the ad says X somewhere" and "as THIS shot plays, the track says X", and only the second is usable as a beat.
📄 THE SHEET DEMOTES TO BACKUP. With a transcript present, the uploaded lyrics/script is re-labelled in the UI as the backup sheet and re-framed in the prompt as the mishearing-corrector — "where they disagree, prefer the sheet's spelling and the track's timing." The heard transcript is editable in place in a textarea, and intakeBuildPrompt() now sends IN.heard.text in preference to IN.script.text as the page prompt's voice source. Without ears, every previous honesty line from v8.24.0 stands unchanged.
⚠️ Stated in the code's own failure path, not hidden: some codecs cannot be decoded by the browser, and the error note tells the owner so ("a .mp4 with AAC audio works"). test/lander-intake.mjs up to 61 assertions (verified in this repo); the commit also records phantasia's 26 still green, which was not run here.) v8.24.0 (👁 REAL EYES ON THE AD — the frames go to the model, and the honest limit goes with them. How the Brody landers were made — watching the ad — is now a button. inVisionStage() in public/lander-builder.html grabs one JPEG frame per picked scene (inFrame64: Math.min(768, video width) wide, quality 0.8, first 12 picks only — the tool's frame cap, and the note says so when more were picked) and stages them alongside buildVideoAnalysisPrompt() from public/lander-intake.js. The staging is the whole point: the frames themselves render as thumbnails beside the exact editable prompt, the Studio tool and model are named, and one ⚡ approve button is the only thing that fires the run — inVisionRun() POSTs to /api/studio/ai/generate with an images array. Per the commit body, phantasia-engine's anthropicCopy accepts images as of that day's deploy — data: URLs become base64 blocks, https pass by reference, capped at 12, plain-prompt callers byte-identical; that change lives in another repo and none of it is in this diff. parseVideoAnalysis() reads back a JSON object with four fields and each lands under a rule: beats fill empty lines only — a human's beat is never overwritten; sentiment (the whole ad's emotional arc, ≤400 chars) rides into the storyline prompt so the page keeps the feeling; onscreen transcribes text visible in the frames — titles, supers, lyrics on screen, packaging (≤30 entries, ≤160 chars each); styleDirection seeds the style box only when it is blank.
🎬+📢 THE HYBRID VOICE (Tyler's correction). buildBeatsPrompt previously opened "ANALYZE LIKE A DIRECTOR READING FOOTAGE, not a marketer summarising it." It now reads the ad two ways at once — a DIRECTOR for the timing (what is physically on screen, the action as behavior, real materials, explicit scale, the shot's energy in texture words) and a MARKETER for the telling (the line written in language a customer feels — this page exists to sell). beautiful, stunning, amazing, epic, incredible stay banned by name; ≤16 words a line; "never explain what a scene 'represents' — sell what it SHOWS." Both the script-only drafter and the new vision run carry the same instruction.
🔇 ⚠️ AUDIO, HONESTLY — stated in the prompt, not papered over. The model sees, it cannot hear. With a script uploaded, that script is named to the model as "the audio you cannot hear, in words"; without one, the prompt orders the model to say when something can only be guessed. ⚠️ True transcription — actually hearing the track — is explicitly deferred to the roadmap in this version, not built.
test/lander-intake.mjs up to 54 assertions (verified in this repo). The commit also records phantasia's 26 assertions still green with the plain-prompt path untouched — that suite is not in this repo and was not run here.) v8.23.1 (🎥 BEAT DRAFTING READS FOOTAGE LIKE A DIRECTOR — the analysis discipline, lifted from the house footage-VFX method. A prompt-only change to buildBeatsPrompt in public/lander-intake.js, plus one test assertion. The old instruction — "one short beat line per scene … present tense, under 12 words" — is replaced by an explicit ANALYZE LIKE A DIRECTOR READING FOOTAGE, NOT A MARKETER SUMMARISING IT block: use the script's words to locate the moment, then write what is physically happening there; name the subject and the ACTION as behavior — what they do, not what it means; be physically precise about real materials, real movement, explicit scale; note the shot's energy (pace, light, motion) in texture words where the script implies it; present tense, emotionally controlled, terse and kinetic, ≤16 words a line. Five hype adjectives are banned by name — beautiful, stunning, amazing, epic, incredible — with the rule that if a scene is striking you say WHAT is striking about it; and a beat may never explain what a scene "represents", only what it shows. The rationale is recorded in the source: this is the transferable half of the house footage-VFX method, and it is what makes a beat line strong enough to hang a page section on. The commit body says "both draft modes share the one prompt" — in the shipped code there is one beats prompt and one draft path (inBeatsRun), so whatever drafts inherits the discipline by construction. The prompt is still staged verbatim for approval before anything runs, human-written beat lines are still never overwritten, and the claims/numbers rules are untouched. One new assertion in test/lander-intake.mjs checks all four markers at once (the director framing, the banned-word list, ACTION-as-behavior, and the "represents" ban), taking the suite to 46. ⚠️ config.js was NOT bumped in this commit — version still reads '8.23.0', so the ☰ menu keeps reporting the previous release and the new-version refresh prompt has nothing to fire on. ⚠️ Prompt text and one assertion are the whole change; nothing here shows the new prompt run against a model.) v8.23.0 (🎤 THE AD'S OWN WORDS — the script slot, and beats that draft themselves. SCRIPT / LYRICS UPLOAD joins the 🎬 section (the file input accepts .pdf,.md,.txt,.markdown) through the same /api/lander/intake/doc extraction door the brand bible uses, but stored whole as IN.script rather than distilled — the commit calls this the selling-language half of the video scan that has been on the roadmap since the Brody build. In buildPrompt the new scriptBlock lands under "THE VIDEO'S SCRIPT / LYRICS" and tells the writer to weave the ad's phrases, hooks and refrains through the page so a click-through continues the ad's voice, then draws the line hard: it is VOICE and STORY, not evidence — product claims stay bounded by the approved facts, numbers by the approved numbers list. The text is .slice(0, 4000), so a full lyric sheet cannot crowd the rules out of the prompt.
✨ BEAT DRAFTING adds buildBeatsPrompt(opts) and parseBeatLines(text, n) to the exported LanderIntake surface. The beats draft from the SCRIPT plus the scene TIMES — no vision model is needed for a scripted ad, because the words locate the moment — and the prompt carries the duration, the rounded pick times, the script (or an honest "No script was provided — infer only pacing from the timestamps"), a ban on invented product claims and numbers, and a demand for a JSON array of exactly N strings. Drafting is a GENERATION, so it stages like one: inBeatsStage() puts the exact prompt in an editable textarea, names the Studio tool and the model, and offers one ⚡ Draft them button next to Cancel — nothing fires on its own. inBeatsRun() never overwrites a line a human wrote — if (p.beat.trim()) { kept++; return; } — only empty beats fill, and the status line reports both halves ("Drafted N beats", plus "— M of yours kept as written" when any were). A wrong-count response is tolerated but never silently misaligned: parseBeatLines pads with empty strings or trims to the pick count and caps each line at 120 chars, so beat 3 can never quietly become the label for scene 4.
⚠️ The commit body advertises a three-way choice — type it yourself, AI-draft and edit, or AI-draft and replace — but the shipped code has no replace mode: inBeatsRun() fills empty beats only, so "replace" means the operator clears their own line first. test/lander-intake.mjs is at 45 assertions (counted at this commit), 9 of them new here. ⚠️ Unit tests only — no browser run and no live model call is recorded, and the commit says the first real run (the FLIP 7 music video) is still ahead.) v8.22.0 (🎬 THE VIDEO SLOT — one ad, two renditions, and the scan that finally has a decoder. The 🎬 section of the intake builder stops being a dashed "next build" placeholder and becomes wired-up UI. video-scan.js moves lib/ → public/ because the browser is its decoder — the module does the reasoning, the page does the one thing a test runner cannot, which is decode frames. The move carries its guard with it: test/coverage-ratchet.mjs had a loop over ['doc-intake','video-scan'] asserting lib/<m>.js is exercised; that is now two explicit checks, lib/doc-intake.js and public/video-scan.js, with a comment saying the named check follows the file so the guard cannot be dodged by relocation — the exact way a rename normally launders a covered module into an uncovered one. test/video-scan.mjs repoints its require to ../public/video-scan.js.
The browser side (all of it new in public/lander-builder.html): an off-DOM <video> element plus a 96×54 canvas samples the primary rendition at dt = Math.max(0.5, dur / 480) (≈0.5s, stretched on long videos so the scan stays bounded), feeding VideoScan.histogram and VideoScan.diffScore; planScenes + grabPoints({ perScene: 2 }) return the cut plan and the grab points. Landing each grab just inside the shot rather than on the transition frame is the moved module's pre-existing behaviour, not new logic here. Each seek carries a 1500ms fallback timer so a stuck seek cannot hang the scan. The method is reported honestly: plan.method === 'cuts' reads "N cuts detected — a frame grabbed just inside each shot"; anything else is labelled as exactly what it is — "Few or no cuts found (smooth footage) — swept evenly instead" — and rendered in gold, so an even sweep is never dressed up as detected scenes. ONE AD, TWO RENDITIONS: horizontal and/or vertical upload, cuts detected ONCE on the primary (it is the same edit) and both orientations grabbing the same timestamps, so desktop and mobile imagery pair up. The scrubber gives a horizontally scrolling strip of picks, a live player with 📸 Grab this frame for the operator's own picks, a per-pick BEAT line, and remove buttons. The storyline toggle wires into the prompt contract the commit says shipped two versions earlier: storyline hands the beats to buildPrompt in order so the page walks the ad's narrative, style takes tone only, and neither is a real third state — the frames simply go to the pool and the prompt ignores the video. 💾 Save to pool re-seeks each pick per orientation onto a canvas capped at W = Math.min(1920, rec.w), encodes WebP at 0.85, and POSTs to /api/lander/pool (an endpoint that already existed in worker.js before this commit) as {base}-scNN-{h|v} — the only write in the whole section, and it goes to the media pool, never onto a page.
⚠️ Nothing in this commit tests the browser wiring. The only test changes are the relocation (public/video-scan.js path in the ratchet, and the require). The 28 assertions in test/video-scan.mjs are the pre-existing scan suite that moved unchanged — they cover the planning module, not a single line of the new UI — and the commit records no live run. The commit body's claim that the section "works end to end, in the browser" is the author's; nothing here verifies it.) v8.21.0 (🎨 THE STYLE DIRECTION BOX — and a new look that cannot leave its lane. Two more of the intake requirements land in public/lander-builder.html + public/lander-intake.js (plus the config.js version bump). First, OVERALL STYLE DIRECTION: a free-text box (IN.styleDirection) whose contents ride into the prompt verbatim, in quotes, under "OVERALL STYLE DIRECTION, in the owner's own words" — because paraphrasing a creative direction is how it drifts. No direction typed means no direction block at all. Second, ✨ NEW LOOK: with the toggle on, buildPrompt appends a "theme" slot to the JSON contract asking the model for an eight-token page palette — bg, bg2, paper, ink, ink2, accent, accentD, heroTint — hex only, derived from the style direction, with an explicit contrast rule ("ink must read comfortably on bg and paper"). The SECTION SET still comes from the chosen existing style: one-style-one-lane is load-bearing here, because todo_1534's constraint is that nothing machine-written enters the shared renderer — a palette is data, a section is not. The gate treats the palette exactly like data. validateDraft walks only the eight known keys against /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; unknown keys never make it into the rebuilt object and are reported in a fix note; each bad token raises a kind: 'theme' violation; and then if (Object.keys(theme).length !== THEME_KEYS.length) theme = null — one bad token voids the entire palette rather than shipping half a look. A theme that arrives when none was asked for is dropped outright ("the model proposed a theme that was not asked for — dropped"), which is precisely the surprise the approval flow exists to prevent; and a requested new look that comes back with no theme raises a violation saying the base style's palette will be used, rather than silently falling through. On the result step the proposed palette renders as swatches with the line "applied when you load; every token stays editable in the rail", and intakeLoad prefers r.theme over the base style's st.theme. ⚠️ This is the token half only — full new-theme-with-new-SECTIONS (genuinely new element sets) remains the todo_1534 design call and did NOT ship. test/lander-intake.mjs is at 36 assertions (counted in the file at this commit; 8 of them new here), covering verbatim direction, the theme contract, the void-on-one-bad-token rule, the unrequested-palette drop, and the no-proposal violation. ⚠️ Unit tests only — the commit records no browser run and no live model call.) v8.20.0 (📖 ONE INTAKE BUILDER — facts, voice and references each in their own lane, and the video contract built before the video UI exists. Tyler's reframe: this is an INTAKE BUILDER, not a document builder and a video builder standing separately, so the modal became the ingredients panel. 📄 CONTENT PACKET = the FACTS — as before, and still the only thing the page may assert. 📖 BRAND BIBLE = the VOICE (optional) — the same extraction door with a deliberately different ROLE: the raw document NEVER enters the prompt. The extraction seeds a distilled profile into an editable textarea the human owns (todo_1341: a machine summary of the brand's own voice must be reviewed by a person), and buildPrompt in public/lander-intake.js emits it under a heading that constrains HOW the page sounds, with the rule attached inline — "nothing below may become an assertion about the product that the approved facts do not make". This is the load-bearing distinction: a cheat sheet says what may be ASSERTED, a bible says how it may be SAID. 🔗 REFERENCE SITES (optional, repeatable) — your own site, the product page, or anyone whose style or function you like, each with an optional what-we-like note, rendered into the prompt as "for feel and structure only, never for claims". 🎬 VIDEO — ⚠️ an honest dashed placeholder, not a shipped feature. It names exactly what lands next (vertical + horizontal upload, cut detection + frame scrubber, the storyline toggle) and ships NO dead controls. The prompt engine, however, already speaks the contract: story.mode === 'storyline' orders the page around the video's beats, sections walking the same beats in the same order, so a click-through lands inside the story the ad was telling; 'style' keeps the page's own selling order and lets the video's tone and energy flavour the copy only. Built now and tested now so the video UI plugs into a finished contract. test/lander-intake.mjs up to 28 assertions covering all three new blocks and both story modes. ⚠️ All three new lanes are PROMPT TEXT: this commit adds blocks to buildPrompt and does not touch validateDraft, so the voice, reference and story rules are instructions to the model, not gates in code the way the number, lane and disclaimer checks are.) v8.19.0 (🧬 THE DOC INTAKE DOOR — a cheat sheet goes in, a staged copy generation comes out, and the honesty gate is CODE, not prompt hope. The content-packet → lander path (todo_1279) is wired end to end. "From content" in the builder opens an intake flow that uploads a PDF/MD/TXT to a new POST /api/lander/intake/doc in worker.js, which runs lib/doc-intake.js — a PDF reader that had been built and tested a week earlier and was connected to nothing. The route is EXTRACTION ONLY: nothing is generated, saved or published there. PDF parsing runs in the worker because DecompressionStream lives there; .md/.txt/.markdown (and anything whose first bytes are not %P) pass through as text; oversized bodies are refused at 11 1024 1024 base64 chars with "keep the document under 8MB"; an unreadable document comes back 422 rather than a silent empty extraction. The response carries the text, its structure, warnings, the numbers the DOCUMENT claims, and a digitsUnreliable flag. 🔢 APPROVED NUMBERS ARE HUMAN-OWNED. The document's own numbers only SEED the approved list, and a digits-unreliable PDF seeds it EMPTY — subset encoding drops digits, so the sheet cannot be trusted about its own numbers; they come from the label, typed by a person. 📝 THE PROMPT IS THE APPROVAL SURFACE. New file public/lander-intake.js builds it and the builder shows it VERBATIM in an editable textarea, with the tool, the model and the estimated cost sitting beside the one button that fires — nothing generates without that click. Generation rides the existing Studio route /api/studio/ai/generate on the account's own Anthropic tool and key. 🚧 THE GATE. validateDraft drops sections outside the style's lane, strips unknown props, flags numeric claims that are not on the approved list (small bare integers up to BARE_INT_ALLOWED_MAX = 12 are deliberately let through, so "3 simple steps" does not teach the reviewer to ignore the violations list), flags personal-consumption spokesperson claims, and appends the FDA disclaimer when the footer lacks it. Flags are for REVIEW — the copy itself is not auto-rewritten (the appended disclaimer is the one automatic edit, and it is recorded as a fix), and loading a draft only fills the EDITOR. ⚠️ THE TWO HOLES THE TESTS FOUND BEFORE FIRST USE: test/lander-intake.mjs (20 assertions) attacks the gate rather than confirming it, and caught two real defects in the guard whose entire job is catching unapproved numbers — "%" never sits on a word boundary, so "8%" degraded to a bare 8; and normNumber strips "$", so "$9" slipped through as a small integer. Both fixed; lane, disclaimer and spokesperson rules mutation-verified. ⚠️ The commit records the gate being exercised by its test suite only — no end-to-end run against a real cheat sheet, and no deploy, is claimed.) v8.18.0 (🖱 THE CLICK THAT CAN CONVERT IS THE ONLY CLICK THAT COUNTS — generic CTA taps stopped being beaconed, and CTR became the to-cart rate. Until now the click beacon in public/lander-templates.js fired for every a.btn-primary,.bar a,button.bb-go,[data-cta] hit and tagged it k=buy or k=cta. A hero tap that scrolls the page down to the buy box is NAVIGATION, not intent — and summing it with the real thing muddied the add-to-cart click rate with clicks that cannot convert. The listener now computes isBuy (a.id==='buyBtn', data-cta="buy-main", or a bb-go class), sets LENG.acted=true FIRST so a generic CTA click still marks the session engaged, then returns early — only ?k=buy and the pre-existing AddToCart mirror ?k=atc in the pixel-only block ever reach the engine. The engine keeps accepting the old cta kind from pages published before this. The console rewrite that followed (public/lander-analytics.html): the Clicked tile is gone, replaced by a To cart tile (buy clicks only, tooltip stating outright that generic CTA taps are deliberately NOT counted since 2026-08-13) and a new CTR tile computed as one((toCart / views) 100) — to-cart clicks over views, both from the same store, one decimal. The funnel drops its Clicked step entirely and reads Views → Sessions → Engaged → To cart, its cta + buy click sum reduced to buy alone. The breakdown table loses its Clicks column and its per-row CTR switches from (cta+buy)/views to buy/views, matching the tile definition. Sessions is promoted into the headline tile row with its tooltip rewritten ("unique visits — one per browser until 30 minutes idle"); Unique visitors stays in the minor row with the distinction spelled out ("the same person on a phone and a laptop counts twice"). "Where they stop — section timing" gets a ▾/▸ heading toggle persisted to localStorage under _lasect, the same pattern as the chart toggle. test/lander-analytics-ui.mjs up to 59 assertions, including rendering a real config and proving the emitted page carries only ?k=buy / ?k=atc. ⚠️ Live landers keep sending the cta kind until they are republished — harmless, because the console no longer reads it.) v8.17.0 (🧭 THREE THINGS TYLER SPOTTED ON THE LIVE CONSOLE — page chrome is not a place anyone stops, a facet button that answered a different question, and a derived percentage leaking binary floating point. (1) CHROME IS NOT A SCROLL POSITION. rpchrome renders the announce bar, header and sticky buy — all position:fixed, on screen from the first frame to the last — so "where they stop" drew a permanent 100%-reached bar ABOVE the hero: a step no visitor can fail to reach. public/lander-templates.js gains CHROME_SECTIONS = { rpchrome: 1 } and the renderer no longer stamps a data-sec id on those types, so the beacon never reports them and the engine never tallies them; the console additionally hides chrome ids it has already collected, so the panel reads correctly before the live pages are republished. Chrome still renders exactly as before — it is unmeasured, not removed. ⚠️ The exclusion is a hand-maintained type list carrying only rpchrome today, so any future fixed furniture has to be added to it by hand or it will be measured as a section. (2) A FACET BUTTON NOW FILTERS THE DASHBOARD. "+ device" used to retarget the breakdown table's dimension and scroll you to the bottom of the page — answering a different question from the one the button asks. It now opens a picker of that dimension's real values (scoped to the current window and any filters already applied) and adds a chip, so the whole dashboard re-reads through the filter. The breakdown dropdown at the bottom stays independent; blank values are still not offered, because an empty filter is dropped by the query layer and would relabel unfiltered numbers as a slice. (3) A DERIVED PERCENTAGE LEAKED BINARY FLOATING POINT. The Engaged step read "22.200000000000003% of sessions" (100 − 77.8). That one derived percentage — the only arithmetic-on-a-percentage call site the diff changes — now rounds through a new one() helper; a sweep confirmed rather than assumed that every other percentage on the page arrives already rounded from the API. test/lander-analytics-ui.mjs is up to 49 assertions (verified by count); all three guards are mutation-verified, and the renderer change is proven by rendering a config and reading back the ids it stamps. ⚠️ Live landers still send chrome section ids in their beacons until they are republished — the console-side hide is the stopgap that makes the panel honest in the meantime, and the engine keeps tallying that chrome data behind it.) v8.16.0 (📊 THE ANALYTICS CONSOLE REBUILT — the tree IS the filter, and seven ways it could have lied. public/lander-analytics.html is now "combination 1": a 212px rail of everything you own, each lander node whose data loaded drawing a sparkline (the All-landers node has none), with the selected lander's ads nested under it on the campaign bucket behind a paid/cart pill — and SELECTING IS FILTERING, so the old scope <select> and the separate filter bar are gone. Facet chips live in the console header; below it sit the tiles, "Where they stop", Channels, the trend, the funnel and the breakdown. Under 900px the rail collapses into a swipeable strip of pills with no visible scrollbar. Headline counts now come from the KV truth back to 2026-08-08 — the commit's own characterization is that the old page read the event log for everything and under-reported roughly tenfold; no measurement or comparison run is shown for that figure. Section labels are resolved from the lander cfg, so a bar reads "The Four Levers" instead of an id, and the biggest drop is stated in words. ⚠️ THE SEVEN DEFECTS AN ADVERSARIAL PASS FOUND OVER THE FINISHED PAGE, BEFORE DEPLOY — every one a confidently wrong number rather than a missing one: (1) the ad pill joined on a field the API never returns, across two keyspaces that can never match — the analytics-events ad is the numeric ad_id URL param while the revenue join is keyed by the "source / medium / campaign" bucket, so every ad would have read "no orders data" forever; (2) adTotals() in lib/revenue.js dropped abandoned/abandonedValue out of the roll-up, making the "N cart" pill unreachable — an ad whose carts WERE measured read as a confident "0 paid"; (3) "Today" fetched seven days, putting a week of money and section shares beside one day of views; (4) clicking a "(not tagged)" row set a blank filter the query layer discards, then labelled the whole account's unfiltered numbers a "FILTERED view"; (5) the funnel divided unfiltered orders by a filtered denominator; (6) All-landers summed whatever happened to be cached, reading a failed or unfetched lander as zero — the fix now shows an em-dash instead of a confident under-count whenever any lander's data did not load; (7) a non-super owner got a dead error card with no rail and no way to select the landers they DO own. Plus a stale-response race that painted one lander's numbers under another's name, now fixed with a SEQ ticket where only the newest run may touch the DOM. All fixed; the four highest-value guards are mutation-verified (the commit does not name which four). test/lander-analytics-ui.mjs (40 assertions, verified by count) runs the real page in a vm against fixtures copied field-for-field from the endpoints — the first draft invented shapes the API never returns and passed while the page was broken, which is how two of these got through the first time. ⚠️ LIMITS THE PAGE CARRIES: the money steps are DROPPED from the funnel while a facet filter is on, because the order join carries no device/country/variant dimension and filtering the top of the funnel but not the bottom would invent a conversion rate; ad spend is not wired into the worker at all (it needs a Meta system-user token, appolis todo_1361), so the Spend tile is unmeasured — not $0 — and CAC cannot be computed; and every filtered or cross-tab view reads Analytics Engine, which only reaches back to 2026-08-12.) v8.15.0 (📏 SECTION TIMING WIRED THROUGH — stable ids, a visible-time clock, and the denominator that would have deflated every share. renderLanderDoc() in public/lander-templates.js now stamps every section wrapper with data-sec="{type}-{nth}" — a STABLE id derived from the section TYPE plus its nth-of-type (non-alphanumerics stripped), so moving the buy box leaves it rpbuy-0 wherever it lands and a reorder no longer splits one section's history across two ids; the positional sec-${i} anchor the builder preview scrolls to survives alongside it. The page's tracking block gains the two clocks todo_1528 demanded be fixed together. Dwell is now accumulated VISIBLE time: LENG carries vt/vs instead of Date.now() - t0, so a tab parked in the background for ten minutes no longer reports ten minutes of "reading" — expect the average dwell to DROP from 2026-08-13, which the commit calls the old lie correcting, not a regression. A per-section IntersectionObserver (threshold 0) over the [data-sec] wrappers accrues visible time on that same clock, so the page total and the per-section times truncate together at the first tab-hide and always agree. hit marks ever-seen, so reached-with-zero-seconds and never-reached (absent from the payload entirely) stay distinct facts. Scroll depth now reads document.scrollingElement || documentElement; the code comment states this was confirmed equal to documentElement on the live page, so it changes the API used rather than the number reported — the commit records no separate verification of that. The one end beacon (still fired on the FIRST tab-hide; a second would double-count the session server-side, and time after a return to the tab goes unreported by design) appends sec=id:secs pairs, capped at 40. Server side, /api/lander/daily sums the engine's new per-shard sec buckets ({r: reached, t: visible secs}) and returns sections / sectionsSince / sectionsSessions, three-state honest — sections is NULL until a day in the window actually carries section data, and sectionsSince names the span so the UI cannot imply coverage of the whole window. ⚠️ THE DEFECT THE ADVERSARIAL PASS CAUGHT PRE-DEPLOY: the share denominator must be the engine's new secN (sessions whose beacon actually CARRIED sections), never the day's sess. On the cutover day, sessions from pages published before the instrumented renderer would have deflated every share — the worker comment's worked example is a section every measured visitor saw reading as 9% reached (an illustration in the code, not a measured production figure). (other), the engine's cardinality-overflow pool, is filtered out of the per-section array, where a flood of invented ids could have made it read >100% reached. ⚠️ STILL OUTSTANDING: requires lander-engine v1.8.0, and live landers must be republished to pick up the instrumented renderer — until they are, they report no sections at all; browsers without IntersectionObserver report no sections either (the block returns early); and no tests ship with this change — the commit touches only config.js, public/lander-templates.js and worker.js.) v8.14.0 (⏰ THE MONEY REFRESHES ITSELF — and add-to-cart stops being a structural zero. Tyler asked whether the data is live or whether the sync button is required. The answer was half and half, and the half that was "no" is now addressed. TRAFFIC — views, clicks, sessions, bounce, dwell, scroll, channel, ads — was already genuinely live per the commit: the engine writes it as it happens and the dashboard reads it on every load. MONEY is a pull from Shopify, so until now it only moved when someone clicked. A scheduled() handler in worker.js plus "triggers": { "crons": ["20 7 "] } in wrangler.jsonc are now in place to pull 14 days nightly over the HERMES service binding (/internal/lander-orders?days=14&scopes=1, then /internal/lander-abandoned?days=14), fold both through revenue.rollOrders/revenue.rollAbandoned, and write revenue.revKey(tenant, slug, day) at a 120-day TTL with a revMetaKey stamped source: 'hermes-cron'. Idempotent by construction — a day is recomputed and REPLACED, never merged, so a cron run and a button press cannot double-count each other. A failed pull returns before writing anything rather than overwriting good days with zeros, and the whole job is swallowed in a try because a reporting job must never be able to take the app down. Fourteen days rather than thirty also means the rolling window picks up late refunds and cancellations that change what a day was worth. ⚠️ The commit does not record the nightly cron having run successfully even once — the trigger is declared, not observed. 🔴 THE BUTTON SENT days=30 AND WOULD FAIL. ~12,000 orders in one service-binding payload does not complete; it failed SILENTLY three times before the cause was understood. syncRevenue() in public/lander-builder.html now asks days=14 (~5,600 orders across 23 pages, per the in-code note) and finally surfaces truncated — the button reads N+ orders (capped) and an askConfirm states outright that the 10,000-order ceiling was hit and the revenue shown will understate. ⚠️ The 10,000-order ceiling itself is not raised — the fix is to say so rather than to remove it. A total that looks complete and is not is the failure this whole build keeps guarding against. 🔴 ADD-TO-CART WAS A STRUCTURAL ZERO. The engine has accepted k=atc and the dashboard has rendered the column since v1.6.0, but the page never emitted it — so it read as "nobody adds to cart" rather than "never measured". The second is a gap; the first is a lie you would act on. public/lander-templates.js now beacons /e/{slug}/click?k=atc on the SAME gesture Meta's AddToCart fires on, inheriting its 400ms de-duplication, and is deliberately NOT wired to the retro buy link, which fires AddToCart and InitiateCheckout on a single click — counting that as an add too would double it against the buy. Verified live: 3,505 → 3,506 views from a single page load with no sync, and the atc beacon present on the republished lander. ⚠️ No test files were touched and the commit states no test count or suite total, so neither the cron handler nor the truncated-surfacing path is shown to be covered by tests.) v8.14.x (🔧 THE RATCHET CATCHES ITS OWN STALE ENTRY — landers is covered, so delist it. Per the commit, test/revenue.mjs began requiring lib/landers.js directly (the rev:-prefix boundary test — revision history must never be mistaken for revenue keys), which made the KNOWN_UNCOVERED entry for landers stale. That is exactly what check #2 of test/coverage-ratchet.mjs exists to force — "the allowlist has no stale entries — covered modules are removed from it" — the assertion that lets the list only ever shrink, so an allowlist cannot rot into a place where things hide. The diff is a single file: the landers line comes out of KNOWN_UNCOVERED and the removal is recorded in the comment ledger the same way shares (2026-08-08) and policy-import were before it, noting that the renderer half was already covered by scripts/control-matrix.js. Four lines added, one removed. Test-only change; nothing to deploy. ⚠️ The version string is v8.14.x exactly as the commit subject writes it — there is no version bump in config.js and this is not a distinct numbered release. Found by the lander-builder daily session 2026-08-13 (todo_1536) — per the commit, the red gate would have masked a real failure, though the diff contains no evidence of a specific failure that was masked. ⚠️ The commit states no test count and no suite total.) v8.13.0 (🧪 THE A/B ARM THE PAGE COULD NEVER READ — at a true 50/50 split, B looked like a third of A. Per the commit, this is the client half of lander-engine v1.7.0 (the engine half lives in another repo and is not inspectable here). The engine sets the A/B cookie under the requested slug (the A lander, or the branded domain's lander). When the split served B, the delivered page was the B lander, whose own slug differs — so the page looked for abv_{B} while the cookie in the browser said abv_{A}, never matched, and reported a blank variant on every click and session. The A arm accumulated clicks and sessions while B showed views only, so at a true 50/50 split B read as producing about a third of A's activity. An experiment that always declares A the winner is worse than no experiment. The diff confirms the fix: public/lander-templates.js drops the abv_{cfg.slug}= lookup and now takes the value of whichever abv_ cookie the visitor holds — a visitor only ever holds one. ⚠️ The commit records no live verification of the corrected variant reporting; the change is in the page code only. 📐 FOUR LAYOUT DIRECTIONS — the commit body states that four layout directions for the analytics page were built as a published comparison against real live data, and that the pick is Tyler's and the page is not rebuilt yet. ⚠️ Nothing of those four appears in this repo: the commit touches only config.js (version bump 8.12.2 → 8.13.0) and public/lander-templates.js, so where they were published and what they are cannot be established from this commit. ⚠️ THE VERIFIED CORRECTION, recorded because it was recommended and proved false before it shipped: the audit's first pass recommended moving Sessions/Bounce/Dwell/Scroll onto the KV rollups "because KV spans the schema boundary". The verifier proved that false — sess/eng/dwell/scrl all first appear in the SAME commit that shifted the Analytics Engine columns, so KV buys zero extra history for them; only views/CTA/buy genuinely go back further. Shipping that recommendation would have produced a new wrong number while looking like a fix. ⚠️ No test files were touched and the commit states no test count and no suite total.) v8.12.x (🔴 TWO SILENT DATA BUGS THE NEW CONNECTOR TOOL FOUND IN ITS FIRST MINUTE — both live, both producing plausible wrong numbers, neither visible from the dashboard. By the commit's own account the read-only analytics tool shipped in v8.10.0 paid for itself immediately. 1. THE SCHEMA BOUNDARY. lander-engine v1.6.0 INSERTED site at blob2, shifting every later column by one — blob2 slug→site, blob3 kind→slug, blob4 src→kind, blob5 utm→source, blob6 dev→channel. Rows written before that deploy are still in the dataset with the OLD layout, so they did not go missing: they reported their NEIGHBOUR's values. The tell was a breakdown by kind coming back listing google.com, l.facebook.com and instagram.com beside v/c/b/end, because blob4 in a pre-v1.6.0 row is the REFERRER. lib/analytics.js where() now pins blob2 IN ('lander','store') on every query, which excludes the old layout entirely — dropping roughly three days of pre-v1.6.0 events (low volume, and they carried no session, dwell or scroll anyway), which beats silently blending two schemas. Recorded in the commit as a miss worth remembering: test/analytics.mjs already warned in a comment that "every dimension would silently report its neighbour's values" — the column map was pinned against code edits, and the same drift happening across a DEPLOY boundary inside one dataset was not considered. 2. A BLANK IS NOT A SESSION. Only clicks and session-end beacons carry a session id; a VIEW is recorded server-side and has none, so its blob9 is ''. COUNT(DISTINCT blob9) counted that empty string as a session — every breakdown group containing any view gained a phantom +1, and a group with nothing but views reported "1 session" where the honest answer is none. Confirmed live before and after: the v row now reads 0 sessions instead of 1. ⚠️ Analytics Engine exposes a RESTRICTED SQL surface — uniqExactIf and the other conditional-aggregate …If variants do not exist there and are rejected outright ("unknown function call: UNIQEXACTIF"). So the correction is arithmetic: COUNT(DISTINCT col) includes the blank exactly once when present, so subtract it — (COUNT(DISTINCT col) - MAX(IF(col = '', 1, 0))), exact for some blanks, no blanks and nothing-but-blanks, built only from COUNT/MAX/IF, and with both IF arms integers so the type rule that killed an earlier query is satisfied. It is applied to sessions and visitors in totalsSql, and to sessions in seriesSql and breakdownSql; a new test walks all four query builders (totals, series, breakdown, values) rejecting any bare COUNT(DISTINCT. Also: lander_revenue_sync on the connector (master-admin only, days clamped 1–60, default 30) — the order sync, which has now failed silently three times from the button, can be RUN and its per-step status read back in one move instead of a round trip per attempt. It calls the same Hermes routes the button does over the service binding and pushes a {step, status, ok, count, truncated, error, hint} record per step, with the 404 hint spelled out (ID_SECRET mismatch, or Hermes not deployed with the endpoint). Idempotent: a day is recomputed and REPLACED via kv.put with a 120-day TTL, so running it twice cannot double-count; and if the orders step fails, nothing is written and the previous sync still stands. The body reports 34 assertions, both faults pinned and both stated in the code as what they cost. ⚠️ What is NOT claimed here: the sync tool is not shown to have been run successfully — the body records only that the sync has now failed three times from the button, and the new tool carries no test of its own (the test changes are all in test/analytics.mjs). The schema-boundary fix has a live tell for the bug but no stated post-fix live check; only the phantom-session fix is confirmed live before and after. ⚠️ Version string: the commit subject says v8.12.x while config.js reads 8.12.2.) v8.11.0 (🛒 ABANDONED CARTS JOIN THE FUNNEL — and the sync finally says WHY it failed instead of failing in silence. Per the body, Hermes v2.44.0 serves abandoned checkouts carrying the same lander/ad tags orders carry (the code notes Shopify's abandonedCheckout exposes customAttributes, so the tags stamped on the cart survive), so revenue.rollAbandoned() folds them into the SAME day buckets as orders: emptyBucket() grows abandoned and abandonedValue, byAd entries carry both, and mergeRev() sums them — a day now holds its orders and its abandoned checkouts together, and the rate is computable per lander, per ad, per day. This is the step the lander can never see for itself: its last owned event is the click on the buy control, and everything after that happens on Shopify. The decisions that keep the number honest (the body lists three of them under a heading that says two). (1) funnel().abandonRate divides by abandoned + orders — checkouts that STARTED — not buy clicks: a buy click is not a checkout, and dividing by it would fold in everyone who never reached one. (2) A checkout with completedAt is NOT abandoned. Shopify still returns it in that connection, and counting it would double it against its own order — inflating abandonment and deflating conversion simultaneously — so it is dropped and the drop is reported as rejected['later completed']. (3) "Not fetched" is null, "measured, none abandoned" is 0, and the two never render alike. abandonedMeasured rides on the sync receipt because it is a property of the SYNC, not of the day: a day with no abandonments and a sync that never asked look identical in the buckets and are opposite facts. worker.js gates the daily totals on it, reporting abandoned/abandonedValue as null unless the sync actually asked, and public/lander-analytics.html gains a "Checkouts" funnel step that renders only when abandonment was measured, plus an Abandoned-carts tile and an Abandon-rate tile whose copy states the denominator out loud. 🔎 THE SYNC NOW SAYS WHAT CAME BACK. It had silently failed to complete twice, and both times the only thing surfaced was a generic message that cost a round trip to diagnose. The Hermes call is refactored into an ask() helper that keeps the raw status and body; a bad response now returns 502 carrying hermesStatus and a 300-character hermesBody snippet, and calls out 404 specifically — that route hides itself rather than admitting it exists, so a 404 means ID_SECRET does not match between Kosmos and Hermes, or Hermes has not been deployed with the endpoint. Guessing between those two was exactly the wasted step. (An unreachable Hermes — a thrown fetch — still returns a plainer 502 without those fields.) Abandonment itself is fetched best-effort in a try that swallows its own failure — "abandonment is additive, never a reason to fail the sync" — so a store without read_checkouts still gets its revenue and abandonment stays honestly unmeasured. The body reports 81 assertions in test/revenue.mjs; the diff adds 50 lines there and gives no suite-wide total. ⚠️ This ships the diagnosis, not the cure: the body records that the sync had failed twice and nothing here claims it now completes, or that abandonment was ever measured against a live store.) v8.10.0 (🔭 THE CONNECTOR GETS EYES — two read-only lander tools, because until now every single number cost a human round trip. lib/mcp.js adds lander_analytics and lander_revenue_status, both registered in READ_TOOLS. lander_analytics issues SELECTs against the event log through analytics.totalsSql() and analytics.breakdownSql() — totals, analytics.rates(), and a breakdown by any allowed dimension, with filters combining across dimensions, days clamped 1–90 (default 7), and an unknown dim returning analytics.DIMENSIONS as the allowed list rather than a broken query. (The tool's own description advertises channel, source, campaign, device, ad, adset, campaign_id, variant, country and path; the DIMENSIONS array itself lives in lib/analytics.js, which this commit does not touch.) It writes nothing. lander_revenue_status reads revenue.revMetaKey(own.TENANT) out of KV and returns the sync receipt without running a sync; when no meta exists it answers synced: false with "No order sync has completed for this account, so revenue is UNKNOWN — not zero" and where the button lives — so "revenue is unknown" stays distinguishable from "revenue is zero" through this door too. Ownership is the same as every other lander tool: own.canTouch refuses a slug this account does not own, and account-wide (slug omitted) is gated on own.isSuper — per the platform rule, a person's AI gets exactly that person's permissions and no more. ⚠️ WHY THIS WAS OVERDUE, in the commit's own accounting: there was no way for an assistant to read any of these numbers at all — every check meant asking Tyler to open a URL in a signed-in browser and paste the result back. Three separate bugs each burned one of those round trips: a SQL type error that killed the whole query, a KV namespace collision with the lander revision history, and an order sync that had by then silently failed to complete twice — the third still undiagnosed for exactly this reason. This commit fixes none of the three; it only makes them cheaper to look at. ⚠️ listChanged is false on this transport by design, so the tools do not appear until Tyler refreshes his tool list. ⚠️ The commit touches config.js (8.9.0 → 8.10.0) and lib/mcp.js only — no test file is added or changed, neither handler carries an assertion, the body states no test count, and nothing here records either tool having been run against live data.) v8.9.0 (🔴 A NAMESPACE COLLISION THAT WOULD HAVE EATEN LANDER REVISIONS — caught before it ever fired, and only by the luck of a second bug. Revenue buckets were keyed rev:{tenant}:{slug}:{day} — which is EXACTLY the lander REVISION HISTORY prefix (lib/landers.js revPrefix = rev:{tenant}:{slug}:). Two things list that prefix: revisions(), which presents every key it finds as a restorable config, and snapshot(), which keeps the newest REV_KEEP (12) and DELETES THE REST. So a revenue bucket would have shown up in the revision list, and restoring it would have overwritten a live lander's config with a money blob. Worse, the buckets inflated the count snapshot() prunes against — and because a revision key starts '0' (a zero-padded epoch) while a day key starts '2' (the year), the buckets sorted LAST and the REAL revisions are what would have been deleted. The body states it never fired only because the order sync had not yet written a single day bucket — it was separately reading the wrong end of the window. Two bugs, and the second one hid the first.
The fix renames the namespace to money: in lib/revenue.js: revKey → money:{tenant}:{slug}:{day}, plus a new exported revMetaKey → money:meta:{tenant} that replaces the hand-written ` rev:meta:${TEN} string literal at both of its worker.js call sites, so the receipt cannot drift back into the revision namespace by hand. test/revenue.mjs now pins the separation from both directions — the money key is not inside the revision prefix, the revision prefix is not inside money:, the receipt key is in the money namespace and not under rev:, and even a lander deliberately named 2026-08-09` cannot bridge the two. The diff shows five new assertions replacing the single one that had asserted the old key. The body states the fix was proven by mutation.
⚠️ No migration ships with the rename. The receipt key changed too, so any sync receipt already written under rev:meta:{tenant} is simply no longer read — the freshness stamp starts over. Nothing in the diff moves or cleans up old keys.
🏷 THE "(none)" ROWS — three distinct causes, all real. lib/analytics.js gains a NOT_A_FACET set: session and visitor were offered as breakdown dimensions, but they are IDENTIFIERS — grouping by them yields one row per person, which is a list, not a breakdown; they exist to be counted DISTINCT, and they are. site went with them, having exactly one value ('lander') until a store beacon ships. variant was blank on every click and session event, per the body: the engine assigns the A/B arm server-side and pins it in the abv_{slug} cookie, but the page never read it back — so the one column an A/B test exists to produce was always empty. public/lander-templates.js lqs() now reads that cookie into vr= on every beacon. path was set on views only and the beacons never sent it, so a click landed in a different bucket from the view that produced it; lqs() now sends path= too (truncated to 80 characters). Finally, a blank is labelled "(not tagged)" with a per-dimension REASON, served from the new analytics.BLANK_MEANS map as blankMeans — "the ad URL carries no ad_id parameter" is a fact about the ad tagging, not a rendering failure, and saying so is the difference between "your data is broken" and "these ads are tagged this way".
📈 BAR / LINE TOGGLE on the trend, remembered per browser in localStorage. Bars compare days, lines show a shape; which reads better depends on the question. The body records both rendering (2 polylines, 14 points in line mode; bars gone) and the preference persisting — it does not describe how that was checked, so this is not characterised as a browser run.
⚠️ The second bug is named but NOT fixed here. The sync "reading the wrong end of the window" is cited as the only reason the collision never fired, but this commit's worker.js change — ignoring line-ending churn that inflates the diff to ~6,272 lines — is exactly three lines: the two key-name swaps and adding blankMeans. The window bug remains open as far as this commit shows. ⚠️ BLANK_MEANS.path states plainly that historical rows stay blank: "recorded before the page reported its path (before 2026-08-12)". ⚠️ No test count and no suite total are stated.) v8.8.0/8.8.1 (📊 ANALYTICS LEAVES THE POPUP — a marketing dashboard does not belong inside a modal. Tyler, 2026-08-12: "the current popup admin analytics hub and details view (which should be its own view) are hideous by the way." Correct on both counts. The modal was built to show two counters and a marketing dashboard had been stacked into it — a detail panel inside a card inside a popup.
public/lander-analytics.html (327 new lines) is now its own full-width page: a sticky top bar with lander picker, window, freshness stamp and refresh; a filter row carrying the break-down dimension plus a removable chip per active filter (with a clear all chip once more than one is applied); twelve tiles; the funnel as one proportional row — views → sessions → engaged → clicked → to cart → orders; a hand-rolled trend, because the commit body states there is no chart library anywhere in this suite; and a breakdown table where CLICKING A ROW ADDS IT AS A FILTER — that is the whole drill-down mechanic, and it needed no new UI. In public/lander-builder.html the per-lander button is relabelled and now navigates to /lander-analytics?slug=…, and a 📊 Open analytics button joins the sync bar; the body states the modal keeps Domains and Setup, which really are admin housekeeping.
📉 THE COMPARISON is new server-side too. worker.js now runs a fourth analytics.totalsSql over prevSince…prevUntil — the same window immediately before — and returns previous, previousRates and comparedTo alongside the current totals. Three rules keep it honest: no previous data shows nothing rather than "+100%", a previous of zero reads "new — nothing in the previous period" rather than an infinite percentage, and bounce is scored so DOWN is good — because direction is not the same as sentiment.
⚠️ The comparison does not reach every tile. Nine of the twelve pass a change badge; Orders, Revenue and AOV are rendered with no comparison argument at all, so those three show a figure and nothing about which way it moved.
🎯 null vs 0 is enforced in ONE place. Every value renders through num()/pct()/money()/dur(), all of which fall back to a shared em-dash span titled "Not measured — this is not the same as zero". A confident $0 beside real ad spend is the number someone acts on, and routing every field through those functions means it cannot be reintroduced by formatting one by hand.
⚠️ VERIFIED VISUALLY WITHOUT A LOGIN. The author states they cannot sign in, so the page was rendered against realistic fixtures in a throwaway harness and inspected: desktop and mobile, drill-down click confirmed to add the chip, no horizontal page scroll, the table scrolling inside its own container, no console errors. The harness and its launch entry were removed afterwards. No signed-in run against live data is claimed, and the commit states no test count and no suite total. config.js lands on 8.8.1.) v8.7.0 (🤝 THE ORDER BOOK COMES FROM HERMES — the revenue join gets its source without a second live-store credential. /api/lander/revenue/sync no longer needs its own Shopify token as its only path. It asks Hermes over the HERMES service binding — https://internal/internal/lander-orders?days={days}&scopes=1, authenticated with env.ID_SECRET in an x-id-internal header — because, as the commit records, Hermes is a Dev Dashboard app whose Shopify token is minted by the client-credentials grant and expires every 24 hours. A copy of that token in Kosmos would have worked for a day and then died, and Kosmos would have had to duplicate the whole mint-cache-refresh machinery to keep it alive. A second copy of a live store credential is also a second thing to rotate and leak — appolis todo_1059 exactly. The commit body states the service binding already existed, that nothing new had to be configured, and that there was nothing for Tyler to set up; the wrangler config is not in this diff, so that rests on the body rather than on anything shown here. The code comment records that Hermes returns a MINIMAL feed — no customer, no email, no address, only the lander/ad attributes and what the order was worth — but that shape is Hermes-side and is neither enforced nor visible in this commit.
The direct-Shopify path is kept for a tenant with no Hermes, and both sources now fold through one fold() closure in worker.js, so the two cannot drift in how they roll orders. rollOrders is unchanged — this commit touches only config.js and worker.js — so it still decides what counts and reports why it rejected anything, which is why Hermes passes test/cancelled orders through flagged rather than filtering them itself.
📋 THE RECEIPT GREW THREE HONEST FIELDS. The sync meta now records source ('hermes' or 'shopify-direct'), truncated, and the granted Shopify scopes. truncated is the one that matters: the comment states Hermes caps at 5,000 orders per pull and the direct path hard-stops at 20 pages × 250 — the same 5,000 — so a store busier than that inside the window would otherwise hand back a total that LOOKS complete. Surfacing it is the difference between a known limit and a wrong number, and surfacing scopes makes a scope gap a visible fact rather than an assumption. Failure is loud on the paths that are handled: an unreachable Hermes returns 502 with the exception text, and a Hermes response carrying an error returns 502 with that reason. The pre-existing rule that a failed pull must not overwrite good data with zeros is untouched. The no-source 501 message was rewritten to point at the binding and ID_SECRET first, and still ends with the line that governs the whole feature — revenue reads as UNKNOWN, not zero.
⚠️ One gap is visible in the diff itself: a Hermes response that is not ok and carries no error field falls through silently — the code then either uses the direct Shopify path or returns the 501, with no signal that Hermes was tried and failed.
⚠️ Nothing here shows the Hermes path actually returning orders. The /internal/lander-orders endpoint lives on the Hermes side and is not in this commit; no live or end-to-end run is described. A later commit in this same series (v8.9.0, fd93061) records that the sync had still never written a single day bucket. ⚠️ The commit states no test count and no suite total.) v8.6.1 (💥 THE FIRST LIVE ANALYTICS QUERY DIED ON A SINGLE CHARACTER — a bare 0 where ClickHouse demanded 0.0. The probe came back 422: "the 2nd and 3rd arguments to IF() function must have the same type but instead had Double and Integer". ClickHouse requires both arms of IF() to match, and double2 _sample_interval is a Double while a bare 0 is an Integer. Three expressions had it — engaged (double4), dwell (double2) and scroll (double3) — and each one rejected the ENTIRE query, so what shipped in v8.6.0 was dead rather than wrong (there is still no dashboard on top of it — the endpoint itself returned nothing usable). Fixed by a doubleIs() helper that pairs a double arm with 0.0, sitting beside kindIs() which pairs an integer arm with 0, so the choice is made in one place instead of at every call site (kindIs also sheds its vestigial ${N === '' ? '' : ''} prefix on the way). THE GUARD IS THE POINT. A string test cannot execute SQL, so the suite now WALKS every IF() in every builder — totals, series, breakdown and values — reads the false-arm literal, and requires 0.0 when the true arm multiplies a double and 0 when it does not; a companion assertion checks the walker found at least 8 expressions, so it cannot pass by finding nothing. The commit body records this was proven by mutation rather than by reading: reintroducing the exact bug into real generated SQL makes it go red, and the unmutated SQL passes. Worth recording separately: the good news in that 422 is that it was a 422 and not a 403 — the Cloudflare token permission Tyler added works, the query authenticated and reached the planner. The suite is now 32 assertions (counted in the file — no run output is committed). ⚠️ The commit records the fix and the guard, but NOT a successful live query returning data afterwards — the last recorded state of a real query against Analytics Engine is still the 422. ⚠️ The mutation itself is not committed to test/analytics.mjs — the walker and its two assertions are; the red-on-mutation proof is the body's account of work done during the session and leaves no trace that can be re-run.) v8.6.0 (🔎 THE FILTERABLE LAYER — reading the event log that has been writing since v1.4.0 and that nothing had ever queried. lib/analytics.js + GET /api/lander/analytics (todo_1505). This is the half of the marketing request the KV rollups cannot serve: filter across dimensions, drill by ad id or ad set, and count DISTINCT sessions and visitors. Rollups tally each dimension separately and throw the combination away at write time, so "mobile AND facebook" is not a harder query there — it is an impossible one, by construction. Cloudflare Analytics Engine keeps one row per event with every dimension on it, so a filter is a WHERE and a drilldown is a GROUP BY; the engine has been writing those rows all along and v1.6.0 widened the row. COLS maps the sixteen blobs the engine's record() writes (tenant, site, slug, kind, source, channel, campaign, device, session, visitor, ad, adset, campaign_id, variant, country, path) and is the only place that mapping is written down on this side. The endpoint serves totals, a per-day series, a breakdown on any allowed dimension (limit clamped 1–200, default 25) and derived rates(), plus a cheap probe=1 connection check so setup can be verified without pulling a whole dashboard of queries; a slug-scoped request goes through mine(), account-wide is master-admin only, and the window is capped at 90 days (default 7). TWO THINGS THAT WOULD PRODUCE CONFIDENTLY WRONG NUMBERS, both pinned by tests: SAMPLING — Analytics Engine keeps only a fraction of rows above a volume threshold and records the rate on each row, so COUNT() counts the survivors and under-reports precisely when traffic gets big enough to matter: a plausible number, not an error. Every count is SUM(_sample_interval), and the suite asserts that none of the four query builders contains a bare COUNT(); COUNT(DISTINCT …) for sessions and visitors is the one legitimate exception, asserted on the totals query. COLUMN DRIFT — the blob order is fixed by the engine's record(); if that changes and this map does not, every dimension silently reports its NEIGHBOUR's values, so the full mapping is asserted column by column. INJECTION: the SQL API takes a string and has no bind parameters. Filter values are escaped by q(); dimension NAMES are never caller text at all — a filter key that is not in COLS is silently dropped, and breakdownSql/valuesSql throw on an unknown grouping dimension rather than letting it reach the query. Tested with a real x' OR 1=1 -- payload, which survives only as inert quoted text. Bounce is engagement-based, not single-pageview — a lander IS one page, so the usual definition returns 100% every day and tells you nothing. Every rate returns null rather than 0 when its denominator is missing, the same three-state honesty the revenue join uses. A missing "Account Analytics: Read" permission on the CF_API_TOKEN Kosmos already holds (a permission on an existing token, not a new secret) returns a 200 with needsSetup and the two-click fix spelled out in words, never an opaque 403. The response also carries two plain-English notes, because both are easy to misread: counts are sampling-corrected and "unique visitors" means unique browsers; buy clicks are intent, since the add itself happens on Shopify. The new suite contains 30 assertions (counted in the file — no run output is committed). ⚠️ API only — no front-end file is touched (config.js, lib/analytics.js, test/analytics.mjs, worker.js), so nothing displays these numbers yet. ⚠️ Retention is ~90 days and the dataset is append-only: it answers "what happened", never "what is true now". ⚠️ Nothing here records a successful live query — the SQL is only ever asserted as strings; v8.6.1 records the first real run, and it came back 422.) v8.5.0 (📡 THE LANDER STARTS REPORTING ON ITSELF — sessions, engagement and channel, captured before anything exists to display them. Client half of lander-engine v1.6.0. Marketing asked for sessions, unique sessions, channel, bounce rate and drilldowns by ad id and URL parameter; none of it was being collected, and none of it can be backfilled, so capture ships first and the dashboard comes later. public/lander-templates.js mints a visitor id (localStorage._lv) and a session id (localStorage._ls), both first-party and deliberately CLIENT-side — the code's stated reason is that a per-visitor Set-Cookie would force Cache-Control: private and destroy the edge cache this page's TTFB depends on, and the same comment cites this page's own history of a 1,046ms TTFB that lost roughly half the clicks paid for. 30-minute idle window, held in localStorage and not sessionStorage so a second tab joins the SAME session rather than inventing one. Scroll depth and dwell are tracked, and one beacon at /e/{slug}/end carries seconds, max scroll percentage and an engaged flag (clicked anything, or ≥15s, or ≥25% scrolled) — fired on pagehide + visibilitychange, never unload, because iOS Safari does not fire it reliably and listening for it breaks bfcache for everyone else; it is guarded to fire once and never inside a frame. The click beacon now carries that same identity (sid/vid/src/utm/q), which is what finally makes per-ad click-through computable: until this, a click carried no campaign and its referrer was our own page, so only per-ad views existed. ⚠️ THE BEACONS SEND THE RAW LANDING QUERY STRING (qs, capped at 400 chars — the comment's reasoning: long enough for real ad tagging, short enough that a hostile URL cannot bloat every beacon), not a channel the page worked out for itself. Channel and the ad-parameter allowlist are derived ONCE, in the engine, by the same functions the view path uses. Letting the page compute its own would have been a third implementation of a shared rule — which is exactly how the ad key silently drifted into two incompatible forms and would have reported $0 against a live, spending ad. ALSO FIXED, found by an adversarial review of earlier work: /api/lander/daily costs TEN KV reads per day (the unsharded legacy key + 8 shards + the revenue bucket) plus three fixed, so days=120 was 1,203 reads in one request — over a Worker's 1,000-subrequest ceiling. The longest window would have failed outright rather than degrading. Capped at 90 days (903 reads), which is longer than the ad data sitting beside it is useful for anyway. Honest about the words: "unique" means unique-to-this-browser-profile, never unique-to-a-person, and analytics storage is first-party only; in private mode or with storage disabled the beacons simply carry no id. ⚠️ ONLY THE SENDING HALF IS HERE. Nothing in this repo receives /e/{slug}/end — the engine that consumes these beacons is not in this commit, so this diff alone cannot show the new fields landing anywhere. ⚠️ The commit body claims "verified live on all three landers" but names no lander and shows no output, so that claim rests on the body alone. ⚠️ No test file is touched — the change is config.js, public/lander-templates.js and worker.js, and the browser-side capture code ships with no assertions of its own.) v8.4.0 (⚖️ THE LOST UPDATE, CLOSED PROPERLY — the Durable Object now holds a REV, NOT THE BOARD (note_589 #78). v8.3.0 had the right concurrency design and the wrong storage: it kept the entire board in one DO value, and at 2,021,021 bytes the first cold start threw SQLITE_TOOBIG and failed every read. v8.4.0 keeps every property that made the CAS worth having and stops the document ever entering the DO. KV IS THE DURABLE STORE; THE DO IS THE SERIALIZATION POINT. Its storage is exactly three tiny values — key, rev, stamp — and a save goes: read the body, compare-and-claim the rev SYNCHRONOUSLY (no await between the comparison and the increment, or two handlers interleaving there both pass and both commit — the check-then-act race HubDoc documents), then KOSMOS.put, then persist the new rev. The claim happens before the first await ON PURPOSE: a second writer arriving during the KV put sees the claimed rev and conflicts, where putting KV first and bumping after would reopen the exact lost update this exists to close. A save is only reported ok once KV resolves — a failed put returns 502 and rolls the content back to the last durable board, never a success for a write that did not persist. The warm DO serves the doc from memory, so a reader can never observe a rev newer than the document it gets. 🛡 AND THE ONE THING THIS DESIGN OWED ITS PREDECESSOR — THE STALE-MIRROR GUARD. KV is eventually consistent, so a cold object in a colo that has not caught up could hand back an OLDER board than this DO already committed, and a writer would then save it forward over the newer one. The DO remembers the meta.updatedAt it last committed (a string, not a board) and refuses with 503 rather than serve a mirror that is behind — the same doctrine as kvStore's throw-on-load-failure: a degraded read is survivable, a silent one is how a stale board overwrites everyone's work. /reload remains the one door that accepts KV even when it moved BACKWARDS, because that is its whole purpose (a rollback, a restore, a manual fix), and it moves the rev so in-flight writers replay. 🔴 THE ASSERTION v8.3.0 DID NOT HAVE, AND THE REASON IT REACHED PRODUCTION: the DO mock has no size limit, so 41 assertions passed against a design that could not run. The suite now saves a deliberately oversized board and asserts that DO storage holds only key,rev,stamp, that everything in it is under 400 bytes, and that the board appears nowhere in it — this class of failure can no longer pass. test/cas-boarddoc.mjs → 53 assertions, its first three still reconstructing the pre-fix store so the rest measures a real change. Suite 1274/1274 across 22 files. BOARD_OFF is off; it stays as the one-line kill switch. Verified live by the original repro — two add_todo calls fired in a single message, both surviving with distinct ids.) v8.3.1 (🚨 v8.3.0's BoardDoc IS SHIPPED BUT SWITCHED OFF — it took the board down within a minute of going live, and this entry exists so the changelog does not claim a fix that is not running. BOARD_OFF=1. WHAT HAPPENED: BoardDoc stores the whole account board as ONE Durable Object storage value. Tyler's root board is 2,021,021 bytes — measured, not estimated — so the very first cold start tried to adopt it out of KV and threw SQLITE_TOOBIG. Every read goes through kvStore.load(), so every board read failed: the web app and both MCP doors, immediately. THE ONE THING THAT WENT RIGHT is the deliberate if (!r.ok) throw on load — the rule that a degraded read is survivable but a SILENT one is how a stale board gets saved over everyone's work. Because load threw instead of falling back to KV, no writer ever held a stale doc and nothing was written and nothing was lost; it was a clean read outage, restored by the kill switch. THE MISS, stated plainly: the size was never measured before choosing a design that requires the whole document to fit in one DO value. The ported code even carries a > 1048576 "plan to shard" warning inherited from Agora, whose comment promises "a year of notice" — this board was already at twice that threshold on the day the warning was copied in. A warning tuned for another app's data is not a measurement of yours. THE REAL FIX, and it is a small one: the Durable Object should hold only the REV, not the document. A rev is a few bytes and will never outgrow a DO value. The writer loads the doc from KV plus the rev from the DO; to save, it asks the DO to compare-and-increment (serialized, indivisible, tiny) and only the writer that wins the increment then writes KV. That keeps every property that made the CAS worth having — one write channel, a stale writer rejected, replay on fresh data — without ever putting a multi-megabyte value inside the DO. Sharding the doc is the bigger alternative and is not needed for this. ⚠️ UNTIL THAT LANDS THE LOST UPDATE IS STILL LIVE — v8.3.0's protection is inert while the switch is on. What v8.3.0 does* still deliver in production is the complete_todo project guard, which is independent of the store. ⚠️ Do not clear BOARD_OFF to "see if it works": the failure is deterministic and the board only grows.) v8.3.0 (⚖️ THE LOST UPDATE — TWO WRITERS, ONE BOARD, AND THE ROW THAT SILENTLY VANISHED (note_589 #78). Two kosmos_add_todo calls went out in one message. Both answered ok:true. One never reached the board, and the id it was handed already belonged to another session's to-do — so completing that id later closed a to-do on a DIFFERENT project. It was filed as an "add_todo ID race" and the name points at the wrong thing. Every door did load → mutate → unconditional put: two writers who read the same snapshot both landed, and the second overwrote the first's ENTIRE DOCUMENT. A collision-proof id would not have saved one byte of the lost row. nextId only made the damage visible — both writers scanned the same stale doc, so both computed the same max+1 and the survivor carried an id its twin was also using. THE FIX IS THE ONE THIS REPO HAS ALREADY SHIPPED TWICE: HubDoc's rev + compare-and-swap, raised from one hub record to the whole account board, in the shape Agora proved as TeamDoc + runOnBoard. BoardDoc is one Durable Object per account — /load returns x-kosmos-rev, /save rejects a stale writer with 409 and writes nothing, and runOnBoard throws that attempt away and replays the whole handler on fresh data with jittered backoff. Adoption from KV is STRICTLY-NEWER by meta.updatedAt, which is monotone, so a ~60s-stale edge read can never win but a manual fix or a rollback always can; KV stays the write-through read mirror so scripts and inspection keep working; BOARD_OFF=1 is the kill switch. ⚠️ ONE DELIBERATE DIVERGENCE FROM AGORA, and it is the difference between a fix and a new bug. Agora returns a bare conflicted flag because every write site there is inside runOnBoard. Kosmos has 26 stores and ~20 save sites — Stripe webhooks, pay routes, bug intake, invoice settle — and a flag nobody reads is a write that vanishes with a 200, strictly worse than the clobber, where at least one writer won. So an UNWATCHED conflict throws; only runOnBoard opts into the flag. ⚠️ AND THE REVIEW-BEFORE-COMMIT PASS CAUGHT THE REAL TRAP, which is that REPLAY IS ONLY SAFE FOR A PURE HANDLER. lib/api.js qualifies by design — KV, R2, the hub DO and Stripe are deliberately kept in worker.js — but the MCP handlers do not: hub_put_doc writes an immutable revision, lander publish writes LANDERS KV, putAttachment stores blobs, and pushCowork sends real web-push notifications to Tyler's phone. Replaying one of those duplicates it. An effectSensor now watches the single ctx those handlers reach the outside through, and a call that has touched it is reported, never repeated — BOARD_CONFLICT, with an error saying plainly that nothing was saved and why it is not being retried. It is a Proxy, not a getter, because handleMcp does { ...ctx, dirty: false } and a spread INVOKES getters — every call would have marked itself impure and replay would have quietly never happened, which is the silent-no-op shape this repo keeps paying for. A pinned assertion drives the real handleMcp and proves add_todo never touches ctx.env, so ordinary board work stays replayable. 🎯 AND THE SECOND HALF OF THE TICKET, which is a genuinely separate defect: complete_todo resolved a bare id against the WHOLE board, so a stale or mistaken id closed another project's to-do and answered ok:true — the only reason the original incident was ever noticed is that the response happened to echo the other item's text. Pass project and an id that lives elsewhere is now refused BY NAME, text-fragment matching is scoped to that project too, and the answer always carries the project so a wrong target is visible instead of inferred. A bare id still works — the guard is opt-in, not a breaking change. test/cas-boarddoc.mjs is new (41 assertions) and its first three RECONSTRUCT THE PRE-FIX STORE and demonstrate the lost update and the duplicate id, so everything after it measures a real change rather than decorating one (trap 17). It also exercises the worker's own exports, which trap 20 noted nothing here did. Suite 1262/1262 across 22 files. ⚠️ NOT DEPLOYED, and one path deliberately left loud rather than replayed: the cross-account write to an owner's board resolves permissions from the same snapshot it writes, so wrapping it in replay could authorize against a stale share — it now 500s on conflict instead of silently clobbering, and a proper wrap is its own ticket.) v8.2.0 (🤝🤖 YOUR AI CAN SEE PROJECTS SHARED WITH YOU — READ ONLY, WITH ATTRIBUTION (todo_1381). Until now sharing was invisible to the connector: mergeSharedProjects ran on GET /api/db alone and both MCP doors bound the caller's own store, so an AI session could not see a shared project at all. THE SAFETY PROPERTY IS STRUCTURAL, NOT A POLICY. sharedMcpStore() wraps the store for both doors: load() merges shared work in and namespaces every merged id <ownerId>~<id>, and save() runs stripShared(), dropping every row carrying sharedFrom before anything persists. A collaborator's AI cannot write to the owner's board and cannot absorb the owner's rows into its own — not because a rule forbids it, but because no code path exists that could. Tyler's rule, verbatim: a collaborator "can't have their AI come in and ruin what the actual project owner's AI has done". Writes are refused rather than silently dropped, because a silent no-op reporting success is the failure class this whole release train was built to remove. ⚠️ AND THE FIRST VERSION OF THAT REFUSAL WAS WRONG IN A WAY WORTH RECORDING. It resolved the call's target from an ALLOWLIST OF ARGUMENT NAMES by exact id — and adversarial review took it apart in four places, because the handlers resolve more richly than the guard did: complete_todo matches a to-do by TEXT FRAGMENT, import_notes carries its project PER ROW, set_cowork_focus hides refs in items[].ref, and hub_put_doc spells the key note_id. All four walked past and returned ok:true for a write that reached nobody's board — one of them planting a permanent orphan on the caller's own board (a note with no sharedFrom, so the strip could never remove it, rendering inside the shared project view and invisible to its owner), another writing a cross-account id into settings, which stripShared does not cover, then steering every later ai_worklist at work every write tool bounces. AN ARGUMENT-NAME ALLOWLIST IS UNGUARDED BY CONSTRUCTION against any handler whose resolution is richer than it. So the check now runs AFTER the handler and asks the only question that cannot be dodged: did anything belonging to someone else change? It compares a signature of every shared row, counts rows filed against a shared project, and scans settings for cross-account ids — then refuses AND rolls the in-memory board back, so a refused call leaves no residue even though kvStore.load() re-reads KV on every call today (a property of the store, not of this guard). The refusal also carries isError: true; without it MCP treats it as a protocol-level SUCCESS and a client branching on that flag records the refused write as one that worked. 🤖 AND WHOSE AI DID IT. The connector previously stamped NOTHING — a note it wrote was indistinguishable from one the person typed, which on a board several people's assistants can reach is the first question anyone asks. All 12 creation sites now carry by:{email,name,ai:true} — notes, to-dos, projects, calendar events, stage moves, retros and the cowork changelog — and byTag() renders ·🤖 Name with a tooltip naming the person whose assistant it was. Also fixed an own-board regression the merge introduced: findProject's fuzzy pool gained the shared rows, so someone sharing "FLIP 8 — Soccer" with you made your own add_todo({project:'soccer'}) answer "no project matches" — a third party could degrade your board by unilateral action, since granting a share needs no acceptance. Your own rows now win at every level of precision. test/share-boundary.mjs → 52 assertions; 7 of them fail with the containment check disabled, which is the difference between a test and a decoration. Suite 1221/1221. ⚠️ WRITES to shared projects are deliberately NOT supported — Tyler's call, 2026-08-09: "we don't need to let them add stuff right now." Read-only is the intended end state of this pass, not a stepping stone.) v8.1.0 (🤝 THE SHARE BOUNDARY, AUDITED AND CLOSED — four defects, one of them a hole in the cross-account trust boundary (todo_1380 / todo_1381 / todo_1382 / todo_1383). Tyler opened a project shared to him by raven woodring and reported it looked "thin — no doc hub, no AI cowork log, a lot of missing stuff". ⚠️ THE REPORT ITSELF WAS NOT A BUG, and measuring first is what saved the day: read live from his signed-in session, all 7 of Raven's notes arrive with 9,150 characters of real content intact, p.links is literally {} (no hub was ever published), and the project has ZERO cowork events (Raven's AI has never called log_work on it). The sharing layer was delivering everything that exists. THE REAL DEFECT WAS THAT THE PAGE COULD NOT SAY SO — a shared project rendered "the owner never made one" and "this was withheld from you" identically, as silence, which is precisely why a healthy share looked broken. A 63-agent adversarial audit of the whole boundary followed (57 claims mapped, 45 survived refutation, 21 distinct gaps — note_1379); these are the four that mattered, each verified by hand before being fixed and each proven against the pre-fix code. 🖼 IMAGES ON A SHARED PROJECT ARE STILL BROKEN — THE FIX WAS WRITTEN, REVIEWED, AND REVERTED, AND THAT IS THE MOST USEFUL THING IN THIS RELEASE. The /attachments/ route reads acct.attKey(account.id, …) — the REQUESTER's keyspace — while the bytes live under the owner's, so the logo, note photos and the 📎 Reference images strip all come back empty (todo_1380 stays open). The attempted fix qualified merged paths as attachments/@<ownerId>/<name> and re-checked the grant at read time via an attAllowed() helper. An adversarial review pass over the diff killed it, and was right. attAllowed decided "may you read these bytes?" by looking for a STRING REFERENCE — and every corpus it searched is writable by the requester: note.attachments is stored verbatim from the client (lib/api.js:240, .map(String).slice(0,10), no validation), grantShare needs no acceptance and no notification (lib/shares.js:49 — anyone can "share" a project with anyone), and /api/share/directory hands every account's id and email to any authenticated caller. So: grant yourself a share to the victim, write attachments/logo.png onto your own note, request /attachments/@<victim>/logo.png — and the worker serves it. A second variant needed no grant games at all: a contributor writes the reference onto the OWNER's note (contributor rank permits it) and then reads anything in the owner's keyspace. A REFERENCE IS NOT A CAPABILITY. The review also found the fix was inoperative anyway (uploads returned the qualified form, stored verbatim, while the check compared against the bare form, so no new attachment could ever match) and that it broke the connector on your OWN board (getAttachment sanitises @ and / away rather than parsing them, killing get_image/read_attachment and with them the handwritten-scan transcription pipeline). All of it is reverted, with the reasoning left in the code so it is not rebuilt from the same idea. The real fix needs provenance written at UPLOAD time — an attmeta:<ownerId>:<name> sidecar naming the project — authorized against a live grant on that project. A broken image is the safer failure. 🤖 THE CONNECTOR WAS ANSWERING ABOUT THE WRONG BOARD. lib/mcp.js contains zero occurrences of shares/sharedFrom/ownerId — sharing is invisible to it, and that gap remains (todo_1381) — but the dangerous part was never the blindness: an unresolvable project reference FELL THROUGH TO NO FILTER, so ai_worklist(project: <shared>) silently worked the caller's ENTIRE board believing it was in-lane, list_notes returned every note with count computed off the untruncated list, set_cowork_focus stored an inert focus and returned ok:true, and get_project offered the caller's own projects as authoritative alternatives. All four now refuse, in the file's own convention, with an error that says in words that a shared project is not visible to the connector and that no other project may be substituted. The ungated 🧵 AI lane button is hidden on shared projects. 🔒 THE CROSS-ACCOUNT PATCH BODY WENT STRAIGHT THROUGH, and lib/api.js's project whitelist accepts finance AND deleted — so owner-only DELETE was bypassed by the product's own delete (a soft delete, i.e. a PATCH), and "money never rides a share" turned out to be READ-only: stripped outbound by shareSafeProject, unstripped inbound, so a manager could blind-write books they are not permitted to see. Both are now stripped server-side, mirroring the outbound doctrine. requiredRank also stops handing DELETE to a CONTRIBUTOR on every non-project collection — destroying the owner's stage and cowork history is a running-the-project act, and lib/invoices.js reads those rows as billing evidence. 🔐 VAULT NOTES RODE THE SHARE (worker.js filtered !n.archived and nothing else): the owner's ciphertext listed in the collaborator's own Vault, where a single decrypt failure nulls the key and fails the whole batch — so their CORRECT passphrase reported "wrong passphrase" from then on. Excluded now — and the review caught that excluding it outbound while leaving the INBOUND direction open created something worse: vault is on the note PATCH whitelist at contributor rank, so a collaborator could seal the owner's note with their OWN passphrase, and the new exclusion would then hide that note from the only account holding the key. Unrecoverable, and manufactured by the fix itself. A cross-account write touching vault is now REFUSED outright rather than having the field dropped — dropping it would still let the rest of the PATCH overwrite the note while reporting success. AND THE HONESTY PASS, which is the one that answers Tyler's actual question: p.sharedFrom rides every merged row and was consulted exactly ONCE on the entire project page (if (!p.sharedFrom) loadFinance(p)). Now an empty panel on a shared project STAYS and states its reason — "the owner has not published a hub", "no AI cowork session has logged work here", "playbook patterns are personal to each account, so this can never fill in" — instead of being deleted from the grid; ▶ Next steps is retitled "Your next steps · your playbook, not the owner's", because it renders the VIEWER's methodology and unlabelled it reads as the owner's; and the share banner now names what is not shared, the connector included. On your own board an empty panel is still dropped — no new noise. Also fixes the deep-link regression v8.0.1 introduced (todo_1374): projById falls back to the owner's raw slug so pre-v8.0.1 bookmarks resolve again, exact match still first so a collision is deterministic — and viewProject now reassigns id = p.id, without which the fallback would render the header over empty panels, i.e. manufacture the exact "thin page" symptom this whole investigation started from. Six dead write affordances gated on the caps the page already computed. Two more things review caught in the fixes themselves, both now closed: body = clean reassigned a const, which would have thrown "Assignment to constant variable" on EVERY cross-account write — a total outage of collaborator writes, invisible to node --check and to a suite that does not exercise the worker's fetch handler (it never reached a deploy; the sanitised copy is passed as an argument now); and the connector sweep missed three handlers — open_items returned the whole board labelled scope:'workspace', add_event answered ok:true while filing the event with projectId:null, and import_notes silently sent the batch to the Inbox. Raising DELETE to manager also needed the three delete CONTROLS gated, or the change just converts a destructive click into a 403 — and todoToList is two calls at two ranks, so it now checks before writing anything instead of duplicating the item onto the owner's note and leaving the to-do behind. test/share-boundary.mjs is new (25 assertions: the role ladder, seven connector handlers refusing, and the reverted read staying reverted) and test/share-ids.mjs grows to 52; lib/shares.js comes off the coverage ratchet's allowlist. Suite 1194/1194. ⚠️ STILL OPEN: the connector cannot SEE shared projects (todo_1381's larger half), and none of this has been through a two-real-account pass.) v8.0.1 (🤝 THE OTHER HALF OF THE SHARED-ID COLLISION — PROJECT SLUGS (todo_1366). v7.99.2 namespaced shared notes, to-dos and events but deliberately left project ids alone, because a project id is not purely internal: it rides the hash route and the Phantasia video door. That left a real if rarer collision — project ids are name-slugs, so two accounts that each have a project called "Flip My Life" both produce flip-my-life, and DB.projects.find() hit yours first. Consequences: the shared tile could open YOUR project, and ownerFor() — which still resolved a create's target by looking up body.projectId — attached no owner, so a note you added to their shared project was created on your own board instead, and the owner never saw it. Proven against the shipped v8.0.0 blob before changing anything: the create went out with no ownerId. NOW: projects are namespaced like everything else, and every cross-reference inside a merged row (note.projectId, todo.projectId, event.projectId) follows its project, so a shared note can no longer land under your same-named one. A create carries its project in the BODY rather than the path, so unwrapBody() gives body.projectId the same unwrap the URL already got — returning a COPY, never mutating the caller's object. ownerFor() is DELETED: with every shared id namespaced, the owner always rides in the id, and a lookup that could resolve to the wrong row is exactly what caused both halves of this bug. The one place a project id genuinely leaves the app — the 🎬 Phantasia ?vp= door — now reads srcId || id, so what goes over the wire is always the owner's real slug. test/share-ids.mjs grew to 40 assertions covering both halves. Suite 1147/1147. ⚠️ RETROSPECTIVE, ADDED 2026-08-11 — THIS VERSION NUMBER COVERS TWO DIFFERENT BUILDS, AND THE ENTRY ABOVE DESCRIBES ONLY ONE OF THEM (todo_1384). Everything above is commit 2d46135. An hour later, from a different session, commit 751108b shipped under the SAME 8.0.1: it changed worker.js, lib/revenue.js, public/lander-builder.html and public/lander-templates.js — the phantom ad, and a lander-builder panel that looked stale because it was unlabelled — and touched neither config.js nor this file. So live served 8.0.1 containing BOTH changes while the doc and the deck both said 8.0.1 was the project-slug fix, and the new-version refresh banner never fired for the people who should have reloaded. The failure was not that session's carelessness so much as the absence of any guard: scripts/build-deck.js only compares the NEWEST deck row against config.version, so a second change riding an EXISTING number passes cleanly, and the built-stage checklist's "config.js bumped?" is a prompt, not an enforcement. test/version-guard.mjs now closes it (2026-08-11, 22 assertions). It deliberately does NOT ask "did this commit bump the version?" — that is too loud (every docs and test commit) and too quiet (an amend rewrites the commit it inspected). It asks the only question that matters: is the current version number older than the current code? — finding the commit where the live version was set and failing if anything version-bearing landed after it. Landing code first and bumping last therefore stays legal, because nothing shipped under a stale number in that order. Its two load-bearing assertions replay this incident: checkRef('751108b') must detect the collision and name worker.js, and checkRef('2d46135') must come back clean — without those it would be a test that has never seen the bug it is named after. The generated deck (assets/ and public/assets/) is excluded because 59a7be1 is a legitimate docs commit that touched nothing else, and a guard that is red on the day it ships gets ignored within a week. ⚠️ It reads COMMITTED history only: uncommitted edits are exempt so work in progress does not fail the suite, which leaves deploy-from-a-dirty-tree to the daily session's clean-tree rule. ⚠️ No version bump accompanies this entry ON PURPOSE — a test and a doc change alter nothing that is served, so config.js stays 8.4.0 and live stays correct. That is the guard's own rule applied to itself.) v8.0.0 (💰 THE REVENUE JOIN — spend → lander → real Shopify money, and the join key that would have made it lie. Every lander click since v7.98 puts lander and ad onto the Shopify order as cart attributes. v8.0.0 turns that into money on the dashboard: lib/revenue.js rolls orders into rev:{tenant}:{slug}:{day} keys sitting beside the engine's st: ones, POST /api/lander/revenue/sync pulls the order book (Shopify Admin, SHOPIFY_ADMIN_TOKEN+SHOPIFY_SHOP as SECRETS — never vars), and /api/lander/daily/{slug} returns orders, revenue, AOV, CAC and per-ad money beside the traffic it already reported. ⚠️ THE DEFECT THIS FOUND, before a single number was displayed: the join matches a string the ENGINE writes into its rollup against one the LANDER writes onto the order — two independent implementations of the same rule, and they had drifted. lander-engine campaignOf slices EACH utm part to 40 chars; LANDCTX sliced only the joined string, at 120. So the live ad Raven_Flip8_Basketball_P&S_Final_NewEnding_v2 was recorded as …P&S_Final_NewEndi by one side and in full by the other and could NEVER have matched — the join would have reported $0 against a real, spending ad, which reads as "this creative sold nothing" rather than "we could not match it". adKey() is now the one definition and test/revenue.mjs executes the actual shipped LANDCTX out of a rendered page and demands engine === lander === lib on seven cases including that ad. normaliseAdKey() re-keys orders written by pages published before the fix, so nothing already banked is lost. THREE STATES, NOT TWO: never-synced is null, synced-with-sales is the money, synced-with-none is 0 — found by running the join on the real order book, where "sold nothing" and "never measured" rendered identically. A confident $0 beside real traffic is a number a person would act on. ALSO FIXED: a bfcache back-navigation re-fired InitiateCheckout with a fresh eventID while the cart still carried the old one, desyncing the very dedupe key the design exists for — the retro buy href is now rebuilt from a pristine copy on every click. Money is integer cents throughout. 56 new assertions; suite 1132, all green.)
schema.js nextId() counts the highest id in ONE board, so every account independently mints note_1, note_2 … note_589 — unique within a board, colliding freely across boards. worker.js mergeSharedProjects() folds a shared project's notes/todos/events into the SAME arrays as your own keeping the owner's ids, and every lookup in public/app.js is a bare first-match DB.notes.find((x) => x.id === id) with your own rows earlier in the array. So THEIR note_589 resolved to YOURS — and because both boards seed the same starter lists ("🐛 Bugs", "⚙️ Function changes"), the wrong note looked plausibly like the right one. Where you had no note of that number, find returned undefined and the click did nothing at all. Project TILES worked throughout because project ids are name-slugs, not counters — which is exactly why the project opened and nothing inside it did. ⚠️ THE HALF THAT WAS NOT COSMETIC: ownerFor() chose the write DESTINATION with that same colliding lookup, so a contributor editing a shared note resolved to their own note, sharedFrom was undefined, no ownerId was attached, and the PATCH landed on their own board — silently overwriting their own note of that number with an edit meant for someone else's, with no error shown. Proven against the pre-fix blob before fixing: the write went out with no owner attached. THE FIX: merged ids are prefixed with their owner the moment the board lands (acc_1f2e3d~note_589, unique on both sides, real id kept in srcId) and unwrapped again inside api(), which every write already funnels through. The prefix never leaves public/app.js — the wire format is byte-identical, so the worker and the owner's board are untouched. sourceNoteId on shared to-dos is re-pointed so a to-do lifted from a shared note still points at that note. Deliberately NOT namespaced: project ids, which are name-slugs riding external URLs (the hash route, Agora, the Phantasia video door) — two boards can still own the same slug, which is a real but rarer bug filed separately. test/share-ids.mjs pins it at 27 assertions, including the two that matter: a shared edit carries the owner, and an edit on your OWN note of the same number does not. Suite 1078/1078.) v7.85 → v7.99.1 — RECONSTRUCTED 2026-08-08 from the shipping sessions' own commit bodies (todo_1367: this doc had stopped at v7.84 while the app ran on to v8.0.0, and docs.appolis.app renders this file at build time — so the published page was faithfully serving a fifteen-version-stale history, the harder failure to notice. Same shape as the deck drift that build-deck.js now guards. Nothing below is invented; each line is distilled from that version's own commit.) v7.99.1 (📈 READ THE SHARDED ROLLUPS. lander-engine v1.4.1 spreads each day's rollup across 8 keys so a hot key cannot silently undercount at ad scale; the dashboard sums the shards plus the unsharded key written before sharding shipped — without that last part the day it deployed reads as empty.) v7.99 (📊 THE REPORTING SIDE OF THE ROLLUPS — a funnel, a 30-day trend, and where the traffic came from. GET /api/lander/daily/{slug}?days=30 reads the engine's daily rollups STRAIGHT FROM THE SHARED KV, no engine round-trip; kept deliberately separate from the list endpoint, which is one cheap number per lander.) v7.98 (🧭 THE CART CARRIES WHERE THEY CAME FROM, not just who they are — user-agent and referrer on both identities, on top of v7.97's fbp/fbc/event_id. ⚠️ THE TIMING IS THE WHOLE POINT and getting it wrong would have looked fine: document.referrer and the ad click ids exist only on the FIRST view, so any in-page navigation loses them. Captured on arrival, not at click.) v7.97 (📊 THE LANDER RUNNING LIVE ADS REPORTED 2 OF 4 META EVENTS AND ZERO CLICKS. Tyler put pixel 736335381829795 on flip-8 and brody-b-a-preview and asked whether it was really tracking; it was not. Verified by watching the network on flip8.flipmylifenow.com rather than by reading the code: PageView and ViewContent were firing, AddToCart targeted .bb-pk — a HOUSE selector — and InitiateCheckout was bound to form.buybox submit when rpbuy is an <a>.) v7.96 (🎠 A CARD RAIL COULD BE DRAGGED VERTICALLY until every tile had revealed. Two causes, one per symptom, measured at 390px with the transition disabled so computed style reports the target rather than a frame of it — including that giving an element only overflow-x makes the spec compute the other axis to auto.) v7.95 (🤖 POLICY IMPORT SENT NO USER-AGENT, AND SHOPIFY READS THAT AS A BOT — "Import failed: that page returned 403" on Tyler's own privacy policy, a URL that opens fine in a browser. A Cloudflare Worker's fetch() sends NO User-Agent unless you set one and v7.94 set only accept. Proven against the exact URL: no headers → 403, accept only → 403.) v7.94 (📄 POLICY SHEETS — a footer menu on BOTH identities, opening from the bottom. Tyler: "clickable model boxes that open up when you touch them from the bottom, nothing crazy." cfg.policies is PAGE-level, because a policy belongs to the page and both identities' footers must read the same list.) v7.93 (📱 THE STICKY BAR STOPS DOING ARITHMETIC AGAINST A VIEWPORT THAT BREATHES. The gate read buyTop > window.innerHeight * 0.6; on iOS Safari innerHeight GROWS as the toolbar collapses and SHRINKS as it expands, so the threshold moved with SCROLL DIRECTION and the bar changed state purely because of it. v7.91 had fixed WHICH element was measured and left the thing that actually breaks it.) v7.92 (🏷 THE RETRO CHECKOUT LINK NEVER APPLIED THE DISCOUNT. rpbuy built /cart/add?…&discount=NEW25 and Shopify ignores discount on /cart/add — undocumented there, dropped silently. The link looked right, the page advertised $47.24, and the customer arrived at checkout owing $62.98.) v7.91 (🛒 THE RETRO BUY BOX GROWS UP — quantity stepper, two disclaimers, both save bubbles, and a sticky bar that leaves at the right moment. The server still writes an honest quantity=1 into the href and the script swaps that one substring, so with JS off the button adds exactly what the page shows, and at qty 1 the markup is unchanged.) v7.90 (💲 THE STICKY BAR'S PRICE IS A CONTROL, NOT A HARDCODED SPAN (todo_1294). Two causes, either alone fatal: rpchrome rendered <span id="stickyPrice"> unconditionally with no prop behind it in any schema, and the retro buy wiring rewrote it on EVERY change — so even with a prop the choice was gone within a frame.) v7.89 (♻️ A CONFIG WRITE IS NO LONGER DESTRUCTIVE, and landers can be duplicated. Republishing a full config over a live lander threw away whatever had been changed in the builder with NOTHING to recover from — the builder and the AI connector write the same single key, last writer wins. Every write now snapshots the PREVIOUS config from all four write paths, a ring of 12 per lander.) v7.87 (📌 THE STICKY BAR GETS ONE OWNER; the buy box catches up to the house one. Its fields did nothing but the on/off toggle: rpchrome carried its OWN stickyTitle/stickyCta and a section prop always wins a prop || cfg fallback, and cfg.cta.barSub was written into #stickyPrice, which the buy script rewrites with the live price on every change.) v7.86 (🧪 EVERY BUILDER CONTROL, MAPPED AND PROVEN PER STYLE IDENTITY. Tyler: "all of the settings and adjustments need to be freshly mapped to each theme style… so they all work for each theme and any new theme that is created." Patching one at a time was never going to hold, so scripts/control-matrix.js renders a lander, flips ONE control, renders again and asserts the output actually changed — a control that produces byte-identical output is a dead knob and the script exits non-zero.) v7.85 (🎛 THE BUILDER'S CONTROLS WERE INERT ON EVERY RETRO POP SECTION. Tyler: "most of the controls for each element or section are working" — they were not, and it was systematic: Retro Pop REPLACES the house stylesheet, and the house sheet is where all of the builder's control machinery lives. The rp band emitted class but not ${style}, so the size and spacing variables never reached the DOM at all — every one of those sliders was moving a value nothing could read.) v7.84 (🚧 THE ONE MECHANICAL GUARD AGAINST CONCURRENT SESSIONS WAS HALF DEAD — found auditing whether the scan-first rule is actually enforced anywhere (2026-08-06). The write-path concurrent_activity echo filtered (e.kind === 'cowork' || e.kind === 'stage') && e.ts — but ONLY log_work writes a ts (mcp.js:935). Every stage event (project created, stage moved, shoot day reached — mcp.js:304/317, api.js:153/367/390) carries date alone, so the 'stage' branch could never match: the live board had 36 stage events, 0 with a ts, 2 of them inside the 72h window. The guard could therefore only ever report sessions that had VOLUNTARILY called log_work — a session that built all afternoon and never logged was invisible — while initialize told the next session to treat that echo as a stop sign. Now falls back to date. 📜 AND THE RULES DID NOT BIND A HUB-DOOR SESSION: the Appolis hub answers initialize itself and never forwards it downstream (appolis worker.js:340-344), so v7.83's global rules — which ride the handshake — never reached anyone on the unified connector; they arrived only if the session happened to call ai_worklist or get_project, and overview, the tool the house rule says to start with, carried nothing. Rules now ride overview AND every write response, so a session is bound whichever way it comes in and even if it skipped the scan entirely. Found by a 6-agent read-only audit of every AI door in the suite; the remaining gaps are other lanes' (see note).) 📜 STANDING RULES FOR AI SESSIONS — global AND per-project (todo_1277). Tyler: "a standing rule section for the ai coworker — any session connected to this connector must do xyz first or whatever you want it to be." Two levels, both by his call. GLOBAL rules (db.settings.standingRules) bind every session on the board and ride in the connector's initialize response, so a new session is bound before its first tool call — which is what makes "must do xyz FIRST" enforceable instead of aspirational; they repeat in ai_worklist so a session that was already running when a rule was written picks it up on its next scan. PER-PROJECT rules (project.rules) travel with get_project, which the scan-first rule already forces a session through before it builds. Both surfaces state that the rules OUTRANK the assistant's own defaults and that a rule which blocks something must be reported, not routed around. Written from either door: in-app (⑤ 📜 panel on 🤖 Cowork for global, a compact 📜 row on each project page — a row, not a grid panel, because rules are reference material and a panel would drag the arrange/layout system in for nothing) or from chat (set_standing_rules, and rules on update_project). Kept as plain strings: a rule the owner can read back in his own words is one he will actually maintain. Nothing renders and nothing is injected when no rules exist. ⚠️ rules had to be added to lib/api.js's project-PATCH WHITELIST — a field missing from that list is dropped silently, which looks like a broken UI with no error. test/standing-rules.mjs pins delivery at all three doors, both levels, plus clearing — 13 assertions.) anchorHere() records the clicked header's viewport offset and restoreAnchor() puts it back after paint and again next frame (heights settle after fitPGrids); board sections and project grid panels both carry data-anchor. Verified in-browser: mid-page collapse AND expand pin at 0px drift on both surfaces. Collapsing at the very bottom still shifts — the document genuinely got shorter and there is nothing left to scroll into, which no fix changes. (#74 an action closes the list popup) patchNote always dropped the overlay, so pinning a list threw you back to the board; it now takes keepOpen and rebuilds the popup in place. ⚠️ THE FIRST FIX LOOKED CORRECT AND STILL FAILED: openNote begins with dropOverlay(), which consumes the kmodal history entry via history.back() — that pops ASYNCHRONOUSLY, landing after the fresh modal was built and tearing it straight back down. A _swapModal flag preserves the entry across the swap, so back still closes the popup exactly once. Archive and move still close, correctly — both remove the note from the view you came from. Second half: the open-in-popup ↗ was a bare glyph at .85em with 2px padding (~10px of tap target) and is now a bordered 34×34 chip. (#75 no way back) the lander builder is a full page on its own URL with no route home — added a ← Kosmos link (label hidden under 640px). (#76 the deck went stale) the __VERSION__ token kept the deck's STAMP honest, which hid the real problem: the changelog rows are hand-curated prose and none had been added since v7.07, so a deck labelled v7.81 listed features from 74 versions earlier — worse than an obviously old deck, because the stamp made it look current. scripts/build-deck.js now FAILS the build when the newest row trails config.version, naming the gap; the rows stay hand-written on purpose (showcase copy — generating them from this doc's dense entries would make the deck worse). v7.08 → v7.82 written in as grouped arcs.) scrub toggle on Retro Pop's hero: a pinned 200vh track playing a 126-frame sequence off the media pool, frame config on DATA-ATTRIBUTES rather than a fetched manifest (no round-trip, nothing to 404) and scrub-on stamped server-side so there is no first-paint flash. 🐛 It also fixed a real bug that still exists in the source site's flip8.js: sizeCanvas() ran ONCE on first decode, so a box still settling at that moment (a pane resizing, a font swap, a scrollbar appearing) locked the canvas at the wrong backing size forever and rendered the frame massively upscaled — caught live at 40×560 inside a 1335×900 box; sizing is re-checked on every draw now. v7.78 then SPLIT the toggle into two hero types on Tyler's call — rphero (product image) and rpscrubhero (the pinned track with COPY BEATS layered over the film, any number, one per line as eyebrow | headline | subline, the primary CTA riding the LAST beat so the scroll ends on the buy). Progressive enhancement per the house rule: unarmed the beats simply stack and stay readable, and reduced-motion unstacks them. v7.77 carried four real bugs from Tyler's mobile pass, three fixed in the SHARED flip8.css at source so the site and the lander stay one truth — the mobile header parked left (a .hdr__nav + .hdr__right sibling rule kept matching because the hidden nav is still in the DOM), the hero's second CTA popping in when the phone URL bar collapsed under a max-height rule, the footer centring, and carousel centre-snap. Scrub sharpness is capped so the canvas only ever DOWNscales; genuinely sharper needs upscaled frames, which is Tyler's call. ⚠️ Both notes flag the same limit honestly: the scrub ANIMATING has never been seen by a human, because requestAnimationFrame is frozen in a headless pane — everything static verifies, the motion does not.INV-2026-0001, 2026-08-06) — "it's not letting me delete the invoice because I have to refresh the invoice connection to get latest refund. It's behind… There should be a way to re-sync the invoice or whatever with the stripe one in case something like this ever happens again." Invoice inv_1255 had been paid, refunded, and paid again; Stripe then refunded the second charge in full, delivered charge.refunded / evt_3U1FYuI3HDwwvFyJ0SGSNh5w, and Kosmos banked nothing. The event was signed, verified, resolved to the right invoice and written into seenEvents — it simply returned {applied:true} having lost $75. (1) THE CATEGORY ERROR. charge.refunded compared obj.amount_refunded — one charge's lifetime refund total — against every negative payment on the whole invoice. Those are not the same kind of number. It is exactly right for the first payment+refund cycle and silently destroys every later one whose amount is ≤ the running invoice-wide refund total, so nothing announces itself: the record read paid, claimed to be holding money Stripe had given back two hours earlier, and v7.64's delete correctly refused it on money_held. The fix scopes both sides to the charge, through a new shared calc.chargeLedger / refundChargeOf in the dual-homed money module — beside deleteBlockers, so lib/, the Worker and the browser share ONE copy. It reads records already in KV with no migration: the old branch wrote stripe.objectId = the charge, so every legacy refund row already names its charge, which matters enormously, because a fix that only helped future invoices would not even compute the right number for the invoice the owner was stuck on. Refund ids (re_…) itemise and label; the per-charge delta always governs the amount, because charge.refunds caps at 10 with has_more and legacy rows carry no refund id — id-only dedupe would re-bank money the moment either appeared. An unsucceeded refund banks nothing, and a refund the owner typed in by hand is soaked against the Stripe one and then adopted onto that charge, so it can never soak a second. (2) THE SILENT DROP ITSELF. Every money branch now returns an explicit verdict — recorded / adopted / duplicate / none / unexplained / rejected — instead of {applied:true} for both "banked $75" and "banked nothing". An unexplained gap (including the ledger running ahead of Stripe, which previously fell into a false branch without a word) writes stripe.reconcile plus the stripe.lastError the ⚠ banner already reads, naming the re-sync; a legitimate redelivery stays quiet, because a red banner on every Stripe retry is the silent drop wearing a different hat. updatedAt moves only when the ledger did, so an event that banks nothing can no longer make the next republish believe the owner is mid-edit and skip the client's copy. (3) TWO AUDIT FINDINGS ON THE WAY. A subscription checkout.session.completed banked the first cycle on the PLAN while invoice.paid minted the child receipt and banked it again — a $2,500 retainer recording $5,000 across two records, the plan reading paid, refusing its own delete and publishing "Paid in full" for an ongoing subscription. It never surfaced because incomeLines skips recurring plans; the file's own mintChild comment already stated the rule the code contradicted. And bank() deduped on object identity when one payment produces a cs_, a pi_, an in_ and a ch_ — the same category error on the other side of the ledger — so it now also refuses a PaymentIntent already banked under a different object. (4) 🔄 RE-SYNC WITH STRIPE, the second half of the ask, and not optional: the fix is forward-only, because three independent gates (seenEvents, the 35-day KV event marker, pending_webhooks=0) mean the lost event can never be reprocessed — a fix alone leaves the broken record broken. New lib/invoice-reconcile.js + POST /api/invoices/:id/stripe-sync asks Stripe what it actually holds and repairs the ledger. Read-only at Stripe: every call is a GET and there is no entry point that can refund, capture, cancel or expire anything, which is what makes it safe to offer one click from a refused delete. Discovery is a union of metadata search and the stored-id walk, always both, because each is blind to what the other sees — search cannot see subscription-cycle charges (metadata goes on subscription_data, never payment_intent_data) and its index lags a minute, while the walk cannot see money Kosmos never recorded, which is the entire drift case. The session/charge asymmetry — payments keyed on cs_, refunds on ch_ — is closed by an identity set per charge, anchored on the PaymentIntent. Received means succeeded && paid && captured, at amount_captured, so a partial capture banks what was taken and never the authorisation; refunds are enumerated from GET /v1/refunds and never from charge.refunds, which newer API versions stopped expanding by default. A pending ACH, an uncaptured authorisation, a dispute (not a refund — funds move at the balance level and a won dispute returns them), a foreign currency (no FX rate exists, so it is not a number this app may add) and an unsettled refund are all quarantined and reported, never banked. It is append-only by construction: no code path deletes a line, edits an amount or reduces one — the only in-place write is filling an absent feeCents — so every reason a line goes unmatched is a reason to say "I could not see it", never to erase it. Hand-entered cash and checks are keep_manual and untouched, discriminated on stripe.objectId and never on method, which any caller can set. Mirror → shadow → flip: the report is its own round-trip, and the apply must carry back the rev and the planId it was shown. Phases run S → A → B+C — every Stripe read with nothing pending, then a narrow board write with nothing awaited between load and save, then the republish — because the board is plain KV with no compare-and-swap. Idempotence is object identity re-derived against the freshly loaded ledger, never rev: addPayment does not bump rev, so a webhook landing mid-run is invisible to a rev check and is instead reported as raced. The guards refuse a mode mismatch with zero outbound requests (a test key searching a live invoice finds nothing, and "Stripe has no record of these payments" is the most dangerous false statement this feature could make), a void, a truncated collection, and a permission failure that must never look like "Stripe has no record of this money"*. A draft that Stripe took money for is numbered and marked sent, and the report says so first. The styled report modal reuses the draft report's own shelves and states the consequence in plain language before Apply — including that the Pay button legitimately comes back in front of the client — and a delete refused on money_held, the only blocker that is a claim about somebody else's ledger, now offers 🔄 Check Stripe right there. lib/api.js answers 501 naming the deployed door rather than 404ing, since the local server has neither Stripe nor hub bindings. New test/invoice-refund-scope.mjs and test/invoice-reconcile.mjs; the inv_1255 regression was written from the production ledger and verified failing before a line of the fix was written.)INV-2026-0001, charged $75 and refunded $75 and then impossible to get rid of: "I should be able to delete that invoice because I have that test invoice that is sitting there that is useless." The app only offered void, and void KEEPS the record — worse, a voided invoice had no destructive control in the toolbar at all, so voiding the useless one made it permanently unremovable. (1) A HARD DELETE, GATED ON MONEY IN HAND RATHER THAN ON STATUS. One shared predicate, calc.deleteBlockers(list, inv) in the dual-homed money module, refuses exactly three shapes — money currently held (paidCents > 0, the identical line voidInvoice already draws), an ACH debit still clearing (where paidCents is 0 and every other guard passes), and a retainer whose Stripe cycles minted child receipts. A stamped number, a sent/paid/void status, a live client copy, an armed pay token and a ledger that nets to zero are all handled by the delete instead of refused, so the refunded test invoice finally goes. The intent gate is on the server: anything numbered or ledgered must carry the rev it read and the invoice number typed back; a clean draft keeps today's zero-ceremony discard byte for byte. deleteInvoice runs four phases in an order chosen by direction of failure — P1 cancels the Stripe subscription immediately and gates everything (a live subscription billing for a record that no longer exists is not recoverable from inside Kosmos; at-period-end would bill a cycle into mintChild against a plan that is gone), P2 takes the client's copy off the hub with nothing pending on the board, P3 does a narrow write on a freshly loaded board that is re-checked for money and aborts if a payment landed during the hub round-trip. Nothing is kept: no tombstone, no deletedInvoices[], no audit line — stampNumber only ever steps invoiceSeq forward, so the counter is the tombstone and a deleted number can never be re-issued. The pay token's KV key is overwritten with a self-expiring {deleted:true} marker rather than deleted, so a client holding the link is told nothing is owed instead of "replaced" (which implies a new link exists). And the project purge — one click in Trash, which cascaded past every guard and would have taken $7,500 of held money with it — now refuses on the same predicate. (2) SAME-WINDOW PAY. "I don't want the pay button to open a new tab — they still have the ability to see a stale invoice if it was left open." Correct, and it is the money-losing direction: the OLD tab goes on showing Total due under a live Pay button after the money has moved. Both obvious fixes are wrong. Dropping target="_blank" does not produce a dead button — a sandboxed frame may always navigate itself, so it loads Stripe Checkout inside the doc iframe (opaque origin, no storage, and Stripe refuses to be framed at all). Adding allow-top-navigation-by-user-activation was rejected: hub.appolis.app is ONE host for every tenant's hub and view:'public' is a shipped preset, so that token hands every uploaded document a phishing primitive under Appolis branding, and one Safe-Browsing flag takes every hub down — a platform-wide price for one first-party document type. Instead the doc links to a first-party bridge on the hub's own origin, which asks the shell to move the top window; the shell re-derives the whole destination from its own constants and treats the message's two values as opaque credentials, percent-encoded into one path segment and one query value, judged again by the pay door against the record — so no attacker-chosen host is reachable, ever, and the sandbox token list is unchanged and now pinned literally on both surfaces. The bridge degrades to today's new-tab behaviour if nothing acks in 900ms, and fast-paths a top-level hit straight through, so a pasted URL and an unframed doc behave exactly as before. The document stays inert (zero <script>, its bytes frozen in R2 with a content hash) because the script lives in the worker, where a bug is patchable in every invoice already sent. (3) THE WAY BACK. Stripe already returned the payer to /pay/:id/done; that page now reads the checkout session back and says only what Stripe has actually confirmed — payment received vs bank transfer started — never that the invoice is settled, because the webhook that banks the money is asynchronous and for ACH lands days later. It renders a View your invoice button pointing at publish.url (a stored fact, never a reflected query parameter, and the trailing slash in the host check defeats hub.appolis.app.evil.com), earned by a possession proof — our own session metadata or the live pay token — because invoice ids are enumerable and otherwise anyone could walk them and harvest client-copy URLs. Every branch carries "please do not pay it a second time". Invoice docs also go no-store (on a public hub the old 60s cache handed a just-paid client the pre-payment bytes — the same stale view, relocated into a cache entry), and an in-flight ACH now takes the Pay button away on the document and refuses at the door, which is the only structural fix: a card double-click is already safe because the idempotency key returns the same session, but ACH outlives Stripe's 24h window by days, so copy alone cannot close it. (4) WHAT THE ADVERSARIAL REVIEW OF (1)–(3) CHANGED BEFORE ANY OF IT SHIPPED. Three reviewers went at the first cut and eight defects survived reproduction, all fixed here. The project purge was wrong in both directions at once — "🗑 Forever" from Trash cascades db.invoices, and gating it on deleteBlockers refused on paidCents > 0, the normal end state of every successfully paid invoice, so a finished project became permanently unpurgeable and the owner was told to refund money they had legitimately collected; meanwhile it imported none of deletePrereqs, so a retainer with a live Stripe subscription cascaded off the board in one click and Stripe billed the client forever at a record that no longer existed. The line is now an unfinished external commitment (live subscription, live client copy, ACH still clearing → refuse and point at the per-invoice 🗑 Delete, which dismantles them), settled money cascades but demands the project name typed back on the server so the connector cannot do it in one call either, and the cascaded pay tokens come back for the Worker to tombstone. P1's Stripe cancel is irreversible and P2/P3 can still abort — every abort now carries subscriptionCancelled, writes recurring.status:'canceled' + an audit line onto the surviving record (without moving updatedAt, which would cry wolf on the ⚠ banner), and says so in the toast; before, the owner was told "nothing was deleted" while their client had silently stopped being billed. A live Checkout Session is money the board cannot see — the pay door is a pure read, so an invoice the client was standing on the Stripe page for looked perfectly deletable and the charge landed hours later with nothing to attach it to (a 23-hour window, not a race); the door now leaves a paysess: marker in KV, never on the board, and the delete route expires the session at Stripe and proceeds, or refuses if Stripe says it is already complete. The ACH defence was dead code — touchesClientCopy returned false for the very event that sets stripe.pending, so the published bytes never re-rendered and the client kept a live Pay button for the whole ~4-day clearing window; both pending transitions now qualify. /pay/:id/done was an unauthenticated Stripe amplifier — it called api.stripe.com with the platform's live key on every anonymous GET, and Stripe's read limit is account-wide, so noise from one IP would 429 the pay door, the webhook and the retainer-delete cancel; the cheap KV possession proof now gates the expensive call. The preview stopped being the client's bytes for an invoice armed before its first publish (a supported order): it resolved only publish.hubSlug, so the owner proofed the plain Kosmos door and the client received the hub bridge — it now resolves the slug publish would use. And the hub's /d/{id}/file route was stored XSS on the shared origin (pre-existing, but the pay bridge's whole threat model assumes it is impossible): tenant-uploaded bytes were echoed back with the tenant's own Content-Type and inline for anything matching ^image/, so an uploaded image/svg+xml carrying <script> executed in hub.appolis.app — cookie on .appolis.app, every other tenant's invoice doc and its plain-text pay token one credentialed fetch away. Active-document types are now normalised to an opaque attachment at both ends (upload and serve, so bytes already in R2 are covered) with a Content-Security-Policy: sandbox as the structural second line; application/pdf is the one deliberate exemption, because the shell frames it and Firefox's viewer is itself JavaScript. 729 assertions across 11 suites (from 491/9) — a new pay-door suite drives the real Worker over real Requests with Stripe stubbed at fetch, so the amplifier, the in-flight gate, the subscription cancel and the paid-then-refunded delete are proved by observed traffic and observed state, not by code shape; the pay-bridge suite executes the shell listener rather than grepping it; a board-level test forces a payment to land during the delete's hub round-trip; and the payHref invariant carries its own regression guard: omit it from republishAndRecord's comparison render and the ⚠ stale-copy banner lights after every successful automatic update. Verified in a browser against the real shell: clicking Pay moves the top window off the hub shell to the pay door, a hostile uploaded doc's top.location throws SecurityError and window.open(…,'_top') returns null, and with the shell's gesture guard removed entirely its forged {kosmos:'pay', url:'https://evil.example/…'} still lands on /aws-motor-club/pay/..%2F..%2F..%2Fevil.example%2Fowned — a 404 on its own hub, because the shell builds the URL from its own BASE and never from the message.)lib/hubs.js has a Durable Object: withHub reads a rev, replays the whole mutation on conflict and cannot lose a write. The board is worker.js's kvStore — a plain KV get/put with no compare-and-swap — so its read-modify-write is only as safe as it is short. v7.62 copied the attended publish's "hub write first, then exactly one store.save(db)" rule onto three doors that have money in hand, which put a hub round-trip (DO load + R2 put + DO save, replayed up to 5×) inside the board's read-modify-write. Two Stripe events landing together then destroyed one banked payment permanently — the KV seen-marker survives the clobber and answers the retry with duplicate. Measured: 6 losses in 6 rounds against v7.62, 0 in 6 against v7.61. The resulting state was the exact double-payment invitation the feature exists to close, with the client's copy saying paid while the board said the money was outstanding — and with the ⚠ banner, the designated fallback, reporting "✓ The client is looking at this exact version", because updatedAt and publishedAt rolled back together. The unattended doors now run in three phases: A mutate and save (the money, committed, with nothing else pending — this is also a stronger reading of "a failed republish never undoes what triggered it": the payment is durable before the hub is touched at all), B the hub write, with no board write outstanding, C republishAndRecord reloads the board and merges only the publish{} block onto it, so the second write cannot move money and sees anything that landed during the round-trip instead of overwriting it. Re-rendering the freshly-loaded record and comparing it to the bytes that actually went out is what decides whether the banner stays off — if something changed underneath, the record says the copy is behind rather than stamping itself in sync. Four more defects, all independent of the race. (1) Any webhook published the owner's unsettled mid-edit — the editor debounces so a client never watches an invoice being typed, but that promise held only for the editor's own door, and a payment shipped a half-typed line reading "NOT AGREED YET" to a real client in testing. The trigger now states whether an edit was pending (measured before it moves updatedAt, or every payment looks like one) and the update degrades to a recorded skip; the double-payment invariant still holds meanwhile, because /pay/ re-derives the balance on every click. (2) auto:true — the editor's 4s idle settle and its close hand-off — rode the wire and was dropped on the floor by publishInvoice, so "an automatic update never SENDS an invoice" rested entirely on a browser function; proved by POSTing it at a draft, which stamped a number, flipped draft→sent and minted a client URL. The settle now has its own door (autoRepublish), and publishInvoice honours the flag directly for every other caller. (3) republishLive carried only one of the four preconditions publishInvoice enforces, so a webhook could push documents the owner is forbidden to publish by hand — including an invoice with every line deleted, and one whose project had been trashed. One canRender() now answers for both doors. (4) The seen-marker was written even when the event was not applied, so a payment for an invoice the board could not resolve was discarded for ever; "no matching invoice" is exactly the case a retry fixes (KV reads are eventually consistent), so it is refused non-2xx to earn that retry — capped at 24h, because an endpoint that never stops failing is one Stripe turns off. Also: one automatic update now costs one audit entry instead of two, and the extra one no longer asserted that Stripe published the invoice; a later skip clears a finished failure instead of leaving the banner naming an outage that ended; a republish records the hub's view, so "up to date" and "readable" stop being the same claim (a hub set private after publishing swallowed every update in silence and still showed a green tick); the pay-link KV index is written before the board save and the old one retired only after it; and the editor stopped reading a publish field nothing has ever written. 491 assertions across 9 suites (from 437/8) — the new ones include a board-level lost-update test that fails on the v7.62 shape, and a ninth suite covering the editor half, which no suite touched before.)applyStripeEvent banked the money and nothing touched the document, so a client who had just paid still opened their link to Total due and a working Pay button: the same invariant the void republish exists to protect, failing in the same direction. Server-side triggers now carry it — and so do the owner's own edits, on a settle (closing the editor, or 4s idle) rather than on every debounced save, so a client never watches an invoice being typed one line at a time. The table is deliberate, not "republish on everything": money landing (card/Link/wallet, an ACH debit clearing days later, a subscription cycle) and money coming back (a refund) all republish, because the renderer's stamp, its Total due → Invoice total flip, its Payments received / Balance due rows and the whole Pay block are derived from paidCents/balanceCents. An ACH debit merely initiated, a failed debit, dunning, an expired session, a dispute and every subscription-schedule mirror do not — they write only fields the document never prints, so republishing on them is a guaranteed content-hash no-op: a hub load and save per billing event for zero client-visible change. (Two are also judgement calls: a bank debit that failed is an email, not a silent document mutation; and quietly re-writing a document a client has formally disputed is the one moment a version bump could be read as tampering.) Arming or rotating a pay link now republishes too — a live defect, not a nicety: the pay URL is baked into the published bytes and /pay/ re-checks the token against the record, so rotating left the client's published button dead until someone republished, and the route's own response admitted it with a hint telling the owner to go and do it by hand. A payment the owner typed in is not a second class of payment, so mark-paid moved onto the Worker beside void, with its four earned guards extracted whole into lib/invoices.js — both doors run one implementation rather than two that can disagree about what a payment is. Three rules hold the whole thing up: it never publishes a draft (the gate is one positive condition, publish.state === 'live', which structurally excludes drafts and every freshly minted recurring child — publishing is what SENDS an invoice and stamps its number); a failed republish can never undo or block what triggered it (it cannot throw, it records its reason on the record, and execution falls through to the same single store.save(db), so the money and the failure note persist together and Stripe still gets its 200); and one write channel — hub write first, CAS-protected and replayed up to 5× by withHub, then exactly one board save. Also fixed: an unattended republish will not fall back to a fresh doc when the old one was deleted out of band (right for an owner pressing ↻, silent stranding for a webhook — it skips and records why), a rescan that added an unpriced line skips rather than pushing half-priced work to a client, and a latent banner bug — pushDoc read the clock twice with an audit push in between, so whenever the millisecond ticked, updatedAt came out newer than publishedAt and the ⚠ "your draft has changed since that copy was published" banner lit straight after a successful publish. Tolerable when it meant "you may want to re-publish"; not tolerable now its whole job is "the automatic update could not run". The banner stays, re-scoped, and it still fails safe: it is computed from timestamps, so any future path that forgets to republish lights it automatically. 55 new assertions, 437 across 8 suites — including the payment republish driven through the real Durable Object CAS with a competing writer forced in between the load and the save, proving the replay double-banks nothing, bumps the rev once, writes one audit entry and loses the other writer nothing.)sandbox directive AND rendered in a sandboxed iframe — deliberate, so uploaded HTML never executes with the app origin. But a tab opened from a sandboxed context inherits the sandbox unless allow-popups-to-escape-sandbox is present: opaque origin, no storage access. Stripe Checkout cannot initialise without storage and died with CheckoutInitError: apiKey is not set. BOTH surfaces needed the token — the iframe attribute and the CSP directive — because the CSP one binds the document itself and the parent frame cannot relax it; fixing only the attribute would have looked like a fix and changed nothing. The document stays sandboxed: no allow-same-origin, no allow-forms, no allow-top-navigation. Only tabs it opens become ordinary tabs. Also folded in allowfullscreen, closing the slideshow decks' ⛶ full-screen bug found and filed earlier the same day (todo_1223) on this exact element. New test/hub-sandbox.mjs pins all of it — including that the two surfaces carry IDENTICAL token sets, drift between them being precisely the failure mode that makes this look repaired when it is not. 382 assertions across 8 suites.)idempotency_error — Keys for idempotent requests can only be used with the same parameters they were first used with. The key is kos:<account>:<invoice>:r<rev>:<balance> and says nothing about the request body, so any deploy that changes how a session is built, or one earlier attempt that failed on different arguments, binds that key to the old shape and Stripe refuses every later click for 24 hours. The invoice was wedged before the fix landed, which is exactly why fixing the real bug changed nothing — a failure mode worth remembering: a stuck key makes a repaired bug look unrepaired. call() now retries once, and only on idempotency_error, with the original key plus a timestamp suffix. That retry is safe precisely there and nowhere else: Stripe returning that code means these parameters were never executed under this key — had they been, it would replay the original success instead of erroring. Every other refusal still surfaces its real code untouched and is never retried. 6 new assertions (retried exactly once, different key, prefix preserved, payment completes, a genuine amount_too_small is NOT retried and keeps its code); 369 across 7 suites.)customer_email is validated strictly and a bad value rejects the entire Checkout Session — a name typed in the client's email box, or an address pasted with a leading space, both fail with email_invalid. That field only PRE-FILLS Stripe's form; it is a convenience and must never be able to block a payment. usableEmail() now trims it and, if it still is not an address, simply omits it so the client types their own on Stripe's page — the session is built either way. Also named the one other refusal a retry cannot fix: below Stripe's 50¢ minimum the pay page now says so plainly instead of "try again shortly", which was actively misleading. 6 new assertions pin it (clean address passes through, stray space trimmed, name/blank/nonsense dropped, and the rest of the session survives a bad email); 363 across 7 suites.)isPaid asked one question — is anything still owed? — and a zero balance answered no. But an invoice nobody has priced yet ALSO owes nothing, because every drafted line is blank: marking one sent stamped it paid, with a paidAt, writing into the owner's own books that a client settled a job they were never quoted for. Publishing was already guarded (the needsPrice gate refuses an unpriced invoice, so no client copy could exist), which is exactly why it would have gone unnoticed — the damage was confined to Kosmos's own records and the 💰 profitability strip. totals() now carries awaitingPrice — true while any line still holds needsPrice — and settlement requires a zero balance and a priced invoice. The two legitimate zeros are untouched and asserted: a 100% discount and a deposit credit that covers the work both carry real amounts on their lines, so their flags are already clear and they still settle the moment they are issued. New test/invoice-settle.mjs pins all of it — the unpriced trap, the partly-priced trap, both deliberate zeros, refund walk-back, part payment, draft and void. 357 assertions across 7 suites, up from 338.)project.tagline + project.summary into a billable line. It held on the things it was built to hold — 0 unchecked items reached a line across every project × every type, counts and ids all resolved, the cross-invoice subtraction, void-release and sibling-hold all behaved, and the 📆 period scan came out byte-identical to v7.54 on all 14 real projects (same titles, same source ids, same period). It broke on the words the drafter writes itself. “full build and delivery” is not copied from anywhere, and nothing gated it: The Vegan Patriot — a restaurant at stage vision with zero recorded work — drafted a one-line invoice reading “The Vegan Patriot — Future restaurant: full build and delivery” over a pitch paragraph, with an empty reword list. Verbatim is not the same as true. A delivery claim is now earned by the board: not delivered and no other evidence → no scope line at all, with report.noScope explaining that the description was read as a pitch rather than a record; not delivered but real evidence → the line bills that evidence titled “work delivered to date”; prose still saying pending / planned / upcoming is flagged before a client can read a promise as a delivery. Four more real defects fixed alongside: a second scope line appended on rescan whenever the summary was edited (its id hashes the prose, so a typo fix minted a new id and re-sold the whole build on one document); a replace redraft silently deleting the owner's expense lines, possibly already priced, after a confirmation that promised they would be drafted again; the retainer report claiming “1 line drafted from 19 checked items” on a project with none (9 to-dos and 10 sessions, counted as the wrong thing); and raw wording that reaches the client unflagged — a local disk path quoted inside a board record's own text, and a � left by text mangled long before it got here — plus truncation that could cut an emoji in half and store a lone surrogate. Every fix carries a negative control: each new assertion was re-run against the reverted code and confirmed to fail. Quantum Flip is untouched — still 6 lines, still led by its full-build line. 338 checks green (invoice-api 201, up from 184).) v7.56 (🌌🏺 show/hide Agora projects on the board (note_589 #73) + 🗑 trashed projects stop leaking through the connector (note_589 #72) + 🔌 a stale tool list now says so (todo_1159)). (#73) a segmented Show on this board control — Both / Kosmos only / Agora only — persisted per account in settings.boardSource, rendered ONLY when you actually have Agora mirrors so a soloist never sees a control for a distinction their board does not have. Caught in the browser, not by reading: filtering to Agora-only emptied every section, which made exampleVisible() think the board was brand-new and paint all seven onboarding EXAMPLE tiles over it — examples now key off the section's REAL project count, never the filtered view. (#72) findProject() skipped the deleted flag that overview already honoured, so a project moved to Trash vanished from the board and still came back from get_project and search — two read paths disagreeing about what exists, which meant a session could not verify its own cleanup and a later one could re-merge a tile no human can see. Trashed rows are now invisible to every read, opt-in via {includeDeleted:true} for delete_project's already-in-Trash reply, and restore_project (its own resolver) still finds them. Agora already behaved this way; same engine, one definition one place. test/trash-visibility.mjs pins all six cases. (todo_1159) clients only learn the tool list at initialize, so kosmos_move_note shipped invisible to every running session and "unknown tool" gave no hint that reconnecting was the fix; an unknown name that IS served now says so and tells you to reconnect. capabilities.listChanged STAYS false on purpose — it is a promise to push notifications and this transport is POST-only, so claiming it would be worse than false; the comment says what implementing it would take.) v7.55 (🧾 CHOOSE WHAT KIND OF INVOICE IT IS — the scan depends on it (Tyler, 2026-08-05) — drafting an invoice for Quantum Flip, a shipped 8-mission 3D game, produced one bug fix and a redeploy. Not a bug in the scan: the scan was billing task residue — checked boxes, done to-dos, logged sessions — which is exactly right for “here is this month's work” and catastrophically wrong for “here is the finished product”. The whole deliverable was sitting in fields it never opened (project.tagline, project.summary, the milestone log) and on an archived, unlabelled, 99-of-99-checked history list that if (n.archived) continue skipped. So the invoice now has a type, chosen before the scan, and the type selects the sources: 🏁 completion (the finished thing — scope prose copied verbatim as one fee-shaped line at the top, dated milestones, every list with a checked box including the archive, with the stage history written into the intro as the engagement narrative), 📆 period (byte-identical to what shipped), 🔁 retainer (everything folds into one line — a retainer client buys availability, and itemising it invites an argument about a fee already agreed), 🔧 maintenance (bugs/changes/updates-released/sessions only — never the archive, never the original build), 🚨 incident (one line per day, deliberately breaking the fat-lines rule, because an emergency rate is sold as a response timeline; undated bug fixes are offered, never auto-added), 📍 milestone (exactly one line, itemising nothing), 💵 deposit (scans nothing — it bills forward, and every scan source is a record of the past) and ␀ blank. Nothing is ever billed twice across two invoices on one project: lines[].source.ids already is the ledger, so a completion invoice re-reads everything and subtracts what has gone out, naming the invoice number it went out on. Voiding an invoice releases its work; an open sibling draft holds its records; a rescan self-excludes. That id-set is the only mechanism that could do this job — list items and to-dos carry no timestamp, so a date window can only ever hide them. The drafter still only copies, counts and dates. Every character of every description is verbatim board text; project.summary becomes one quoted line, never split into three deliverables as if each were separately evidenced; unchecked items are future work on every type; stage transitions never carry money; decision forms, hub/lander lists and origination rows are dropped and named in the report; internal [AI-worker] tags are stripped and the lines carrying raw wording are flagged for rewording. Everything stays unpriced on purpose — the rate card is entirely a video/photo production card and video projects can't be invoiced at all, so for the apps and games this builder serves there is zero rate data and no duration on any record; a suggested number would be a number the app invented on a financial document. Also fixed in passing: a description item that already ended in a period printed a double period mid-sentence on the client copy. 288 checks across the three invoice suites (184 + 49 + 55), up from 202.)rev off a save, so it kept its own stale needsPrice flags — 🚀 Publish then refused forever, telling the owner to price lines they had just priced. The editor now adopts the server's normalised lines, touching the rate box is the pricing decision (so a deliberate $0 publishes — it previously could never clear the flag), and the header badge, the draft report and the publish gate finally read one predicate instead of three disagreeing ones. (2) mark-paid could invent a refund. It defaulted to the outstanding balance, which on an invoice whose credits exceed its work — a deposit larger than the final job, a legitimate state — is negative: accepting the default appended a phantom negative payment to an append-only ledger with no removal path, and burned a real invoice number on the way. Amounts are validated before anything is stamped, and a refund can never exceed what was actually received. (3) Voiding did not reach the client — the copy in their inbox went on saying Total due under a live Pay button, which is the one invariant this feature exists to hold failing in the direction that costs money. Void now runs on the Worker beside publish: it clears the pay token and republishes in place at the identical URL, so the client's own link reads Void / Invoice total (cancelled) / Nothing is due with no Pay button. (4) The webhook took the event's word for the money. It now skips a payment whose Stripe object is already banked (event-id dedupe alone let the same session double-bank under a second event id — proven live: 3 verified deliveries, 1 payment) and flags money landing above the outstanding balance instead of feeding it silently to the 💰 strip. (5) Money crossed the share boundary — the invoice records were withheld, but the project object was spread verbatim to collaborators carrying project.finance and, after a publish, an invoice tile in links.docs with the number, client name and total. Both are now stripped server-side. (6) listed:false was a privacy control that wasn't — a detached hub tile 404s at its own URL, so it locked the client out while the record claimed to be live; refused now, with the audience (view axis + member count) surfaced on publish instead. Also: the pay link is actually rotatable (it only ever minted once, and it is printed on the document), /pay/ re-checks the token against the record, the webhook body is capped at 256 KB before an unauthenticated read, the pay URL is https-only and no longer derived from an arbitrary inbound Host, party blocks are length-capped on edit as well as create, qty:0 clamps to 1, a zero-total invoice can close itself out, and a project with live invoices can't be moved into video and strand them. 202 checks across the three invoice suites (98 + 49 + 55), up from 151.)needsPrice, and publishing refuses while one is left), with a report of what it found and what it skipped so nothing is a silent guess. The owner edits lines, discount, dates, client block, terms and notes; totals recompute live. NOT for the video pipeline — that already quotes and invoices through the rate calculator's quote sender, so the button is absent on section video AND the API refuses it, because a client-side gate is cosmetic and the API is directly reachable. THE ARCHITECTURE IS THE POINT. The invoice is STRUCTURED DATA in db.invoices[] — integer cents, no total ever stored — and two independent renderers read it: the owner's editor, and lib/invoice-doc.js, which GENERATES the client's document. The mmc-florida prototype this design is ported from did the opposite: it shipped an editable HTML file and regex-stripped the editing affordances out to make the client copy, with its numbers in localStorage. One un-matched pattern there publishes an editable invoice, and per-browser state can never satisfy “the client must never see a stale copy”. Here there is nothing to strip — the generated document has zero <script>, zero contenteditable, zero form controls, zero localStorage, and zero external requests (no font CDN, no images: it renders inside a sandboxed opaque-origin iframe where a network dependency is a privacy leak and a rendering risk), plus print CSS so the client saves a clean PDF, and every field escaped because invoice fields are user input and this is the one artifact a third party ever sees. Asserted, not asserted-in-a-comment: 45 checks in test/invoice-doc.mjs. PUBLISHING REPLACES IN PLACE. It rides the hub's live-doc doctrine — hubs.putDoc with the invoice's stored doc_id — so a republish keeps the doc id, the tile position and the identical public URL while the bytes go to a new immutable R2 version, and hubs.js's content-hash idempotence means an unchanged republish mints nothing at all. The client's link cannot rot and cannot go stale; 12 retained versions sit behind it for rollback. One write channel is honoured literally: the hub write first (it is CAS-protected and replayed on conflict), then exactly one board save carrying both the invoice's publish{} block and the project-links sync. Publishing a draft is what sends it — the number is stamped then, from a per-account year-keyed sequence, so an abandoned draft never burns one. The owner's preview is byte-identical to the published bytes (asserted by rendering both and comparing), and an invoice can be published unlisted so it serves at its URL without appearing on the hub's tile grid. Publishing to a still-private hub says so plainly instead of silently handing the client a 404. 💰 It feeds the profitability strip as a third auto source beside the calculator quote and the AI cost lines — PAID invoices only, because profit must not count money that has not landed; outstanding sits beside the profit figure, never inside it; the real Stripe processing fee books as its own cost line; and an invoiced project stops double-counting its old quoted bid. Nothing is ever mirrored into project.finance — the invoice record is the single source of truth and a copied total is exactly how two numbers drift apart. 💳 STRIPE IS BUILT BUT DORMANT — the owner has no keys yet, so that is the path that had to be flawless: drafting, editing, publishing and marking paid by hand all work with no keys, nothing throws, and every Stripe route answers 501 … not connected with the exact wrangler secret put command rather than a 500. When the keys land, the client's copy grows a Pay button that is a plain outbound anchor (the hub sandbox has no allow-forms and no allow-same-origin, so a hosted redirect is the only shape that works, and no card data ever touches an Appolis origin) pointing at a stable Kosmos /pay/ URL that mints a fresh Checkout Session at click time — an expiring session pinned to the current amount, never a reusable link that can charge an old total twice. Recurring is Stripe Billing: Stripe owns retries, dunning, card updates and the next billing date; Kosmos owns presentation and the project linkage and schedules nothing. The webhook verifies its HMAC signature over the RAW body with a 5-minute replay window and dedupes on event id — an unauthenticated endpoint that marks invoices paid is a real vulnerability, not a hypothetical one. Secrets: STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET as Worker secrets (never in the committed wrangler.jsonc), STRIPE_PUBLIC_BASE as a plain var. Invoices are owner-only end to end: absent from shares.CROSS_COLLS, never merged into a collaborator's board, rev-guarded so two sessions cannot last-write-wins a money record — the published read-only doc is the client's only view.)String(args.text) unvalidated, so any call arriving without a usable text argument minted a to-do whose text was forever the word "undefined" — the real wording was never persisted (KV inspection: todo_1138…todo_1190 all carry exactly add_todo's key shape, no createdAt). Fixes: add_todo and POST /api/todos now REJECT missing/blank/literal-"undefined" text with a loud error instead of storing junk (add_todo also stamps createdAt now); ai_worklist flags the already-poisoned items text_lost:true with a note telling the session to ask the owner or complete them away rather than build from the word “undefined”; search also matches an exact id (todo_123 / note_45 / a project id — the tool description says so) and, like complete_todo's fuzzy scan, no longer assumes every to-do has text. The 14 poisoned to-dos' real wording is unrecoverable from Kosmos — whoever filed them must re-queue. Verified by driving the patched handlers against a snapshot of the REAL deployed KV doc: 15/15 checks green — corrupt items flagged, healthy todo_1014/todo_1065 untouched, id search finds todo_1190, textless add_todo rejected and saves nothing.)_space.m/.d keys, so nothing saved changes meaning). Split = independent Top and Bottom sliders (mt/mb/dt/db), both seeded from the linked value so nothing jumps at the moment of splitting; re-linking folds the two edges to their average. In CSS the edge vars sit ABOVE the linked value in a fallback chain — padding-top:calc(3.6em*var(--spdt,var(--spd,1))) — which buys the subtle case for free: a linked value plus a single-edge override renders override-top/linked-bottom (measured: d:0.5 + dt:2 → 144px top, 36px bottom). Still exactly ONE desktop .band .wrap padding rule, preserving the v7.49 guard. Split-ness is “does an edge key exist”, so edge keys survive at 100% — deleting them there would snap the panel back to linked mid-drag. Measured both viewports: split mt:0.5/mb:2 → 23/92 mobile, dt:0.5/db:2 → 36/144 desktop, defaults untouched at 46/72. test-space2.js supersedes test-space.js with the edge cases folded in; 12 suites green.)--spd correctly and the CSS threw it away: the 992px media query contained TWO .band .wrap padding rules — an early one carrying calc(2.3emvar(--spd,1)) and a later hard-coded padding-top:3.6em. Equal specificity, later in source order, so the hard-coded one always won and the multiplier was never consulted; mobile worked because nothing overrode its rule. The multiplier now lives on the ONE remaining desktop rule — calc(3.6emvar(--spd,1)) — keeping 3.6em as the untouched default, which is exactly what actually rendered before, so nothing already published shifts (verified: neither live Brody cfg stores a desktop spacing value). Measured in-browser at 1200px: default 72px, half 36px, double 144px — exact 0.5×/2× where all three previously rendered 72; mobile unchanged (46/23/92). A guard comment marks it as the only desktop padding rule, since a second one below would silently kill the slider again, and a test now asserts exactly ONE desktop .band .wrap padding rule carrying the var. NOTE: any lander where someone set the desktop slider in the past and shrugged when nothing happened will now take effect on its next republish — by design, that is the value they asked for.)list() is eventually consistent, so the refreshList() fired immediately after the purge kept returning the slug that had already been removed — the picker rebuilt itself from that stale answer and the lander appeared untouched until the page was reloaded. Waiting on KV timing is not a fix, so the just-deleted slug is now dropped from the picker locally; the delete already succeeded, and the list is only being consulted for everything ELSE. Second cause, one I introduced with the “(unsaved)” holder in v7.47: deleting a lander that had never been saved left UNSAVED still pointing at it, so refreshList() dutifully re-added the very thing just deleted. Cleared on delete. Also: the follow-on loadLander() is now awaited (delete used to return before the next lander finished loading), and HAS_ANY is recomputed from the real options so deleting the LAST lander drops straight into the first-run screen. Verified all three against a list stubbed to stay PERMANENTLY stale — the deleted slug vanishes from the picker anyway, an unsaved lander stays gone, and deleting the last one shows “You have no landers yet” with its Create button, rail cleared and the live link blanked. No page refresh in any case.)refreshList() could never show it — the picker now carries it locally as “{slug} (unsaved)”, selected, and the first save promotes it to the real entry; and loadAB()/loadDomain() are not part of hydrateStatic(), so a brand-new lander inherited the previous one’s A/B split and custom domain on screen — both are now called on create. Verified end to end: from a lander with A/B at 70% running, a mapped domain and a pixel, creating a new one gives picker “brand-new-lander (unsaved)”, A/B back to 50/off, domain and pixel empty, 7 sections; saving replaces the holder and keeps it selected. An unsaved entry also survives a refreshList() fired from anywhere else, and is dropped when you switch away or publish. 🎚 STICKY BAR TOGGLE: per-lander checkbox in the CTA block. Absent means ON, so nothing already published changes without a migration — only an explicit false hides it; the bar’s three copy fields disable themselves while it is off, so they cannot be filled in and silently ignored. 🗑 DELETE now asks for the word DELETE rather than the slug (case-insensitive) — verified the slug itself is now REJECTED, so muscle memory cannot fire the old confirmation. 22 suites green.)DELETE /api/lander/purge/{slug} removes the page, config, counters, reset stamp, A/B record, domain mapping and owner record, frees the address for reuse, AND clears any OTHER lander's experiment that pointed at it — a dangling B side would have kept routing traffic to a page that no longer exists. Owner-gated, and it asks you to type the slug, because one stray click should not be able to destroy a live ad page. 🌐 SELF-SERVE CUSTOM DOMAINS — the answer to “how do OTHER people do this?”: researched against Cloudflare's docs and independently cross-checked rather than assumed, and the headline is good: Cloudflare for SaaS custom hostnames work on a FREE zone (100 included, then $0.10 each) and the customer only ever adds ONE CNAME. With ssl.method:'http', real-time validation proves ownership AND Cloudflare answers the CA's challenge from the edge, so the certificate issues with no TXT record and no dashboard access. New lib/cfhostnames.js registers the hostname on save, and a Check status button reports it in the only terms that matter — waiting on DNS, live and secured, or stuck and why. “Ready” deliberately requires both status and ssl.status to be active, which the docs call out as the classic trap, and a hostname Cloudflare dropped after its 7-day retry window reports as gone rather than as an error. Deleting a lander or clearing its domain releases the Cloudflare hostname too, so the account stops paying for it. Unconfigured (no CF_API_TOKEN) every call is a graceful no-op and the UI shows the manual instructions — nothing breaks, it just is not automatic yet. Two caveats stated plainly rather than discovered later: subdomains only (lp.brand.com — an apex domain needs Enterprise apex proxying, because DNS forbids a CNAME at the zone root), and the one-time account setup is deliberately NOT automated because it needs dashboard access. 17 hostname checks; 14 suites green.)<img src="">, and still parses — plus a 17-section starter lander end to end (verified in-browser: no collapsed sections, six placeholder images, none broken). 🌐 CUSTOM DOMAIN: each lander can be served at its own hostname instead of /slug. dom:{hostname} → {tenant, slug}; the engine resolves it only for hosts it does not already answer for, so the normal path costs nothing, and deeper paths still resolve so a lander's own images keep working. The hostname is validated hard before it becomes a KV key and a routing decision — scheme and path stripped, case normalised, appolis.app reserved, and anything with spaces, newlines, a leading dot or a bad label refused — and a domain already pointed at another lander returns 409 instead of hijacking it. DNS and the Cloudflare custom hostname stay manual (they need account credentials), so the app stores the mapping and states exactly the two steps left rather than pretending the domain is live. 20 domain checks; 12 suites green.)DELETE /api/lander/stats/{slug} — a two-segment path so it can never be confused with the unpublish route — owner-gated like every other lander action (verified: another account cannot zero your live ad counters, and the attempt leaves the numbers untouched). It clears views + clicks and stamps sreset: so the panel says what window the numbers cover; the page and its config are never touched. On an experiment the button reads Reset both and clears BOTH sides, because zeroing one and keeping the other leaves a comparison of two different time windows — worse than no numbers at all. Behind the styled confirm, since the old counts are not recoverable. THE PANEL now pairs an experiment into ONE result instead of two unrelated rows: B nests under its A with its traffic share, and a verdict calls it — refusing to call anything under 50 views on the thinner side, refusing to call a sub-0.5-point gap a win, and otherwise naming the winner with its lift (“hero-a is ahead — 5.2% vs 2.1%, 148% better”). The Meta info lives here too: each lander shows the pixel it reports to with a direct link to that pixel's Events Manager, or says plainly that it has none — so the internal CTR and the ad-side truth sit side by side instead of in two different tabs. Also fixed: the lander ENGINE was never under version control — no repo, no history, no rollback, on the worker that serves every published page and now accepts pages from any Kosmos account. It is now a git repo with a .gitignore and its v1.2.0 state committed.)landers.appolis.app/{slug} is a global address space — so ownership had to ship WITH it, not after: own:{tenant}:{slug} → accountId, a slug with no record resolving to root because every pre-existing lander is Tyler's and two are running paid ads. Listing, config, publish, unpublish and stats are all scoped; plan caps now count only your OWN live pages (a raw count charged you for everyone else's); the image pool is namespaced per account. Extracted to lib/landers.js with 22 tests, including that another account cannot read, republish, unpublish or claim a legacy lander. ⚠️ THE REGRESSION THE REVIEW CAUGHT: publish stored the browser-supplied HTML byte-for-byte. Every sanitiser — the digits-only pixel id, the <meta> whitelist, the clamped CSS — lives in the renderer, which runs in the CLIENT, so with the admin gate gone ANY account could have POSTed hand-written HTML and served arbitrary JS on the same origin as the live ad landers: phishing under the ad domain, and script able to read or rewrite every other lander's cookies. The worker now RENDERS the page from cfg and ignores any posted html, which is what makes those whitelists load-bearing; verified the browser and worker paths render byte-identical output so republishing cannot silently alter a live page. A/B SPLIT: ab:{tenant}:{slug} = {b, split, on} — one ad URL, two versions, sticky per visitor by cookie, views counted against the version actually served, split responses never shared-cached, a missing B falling back to A instead of 404ing the ad. META: pixel id + arbitrary <meta> tags (domain verification, og:*), PageView/ViewContent/AddToCart/InitiateCheckout with eventIDs, and lander/fb_event_id/fbp/fbc riding the cart into Shopify so an order can be matched back. Purchase itself must come from Shopify's own Meta channel on the SAME pixel — the lander cannot fire it from a different domain. Also fixed from the review: a parseInt(split) || 50 that turned a deliberate 0% into 50% (an earlier test had passed this by coin-flip luck — now asserted deterministically over 40 draws); the base pixel firing a real PageView from the builder preview on every repaint; AddToCart never firing from the ➕ button because that handler calls stopPropagation() (now capture-phase); _fbp/_fbc read at parse time — before the pixel writes them — so orders reached Shopify with empty attribution (now read at submit); InitiateCheckout firing on an empty cart; and a cookie ratchet where a PAUSED experiment rewrote sticky-B visitors to A for 30 days, quietly emptying the B side. 8 suites green.)_ft[key].w/.wd and emitted as max-width — not width — so a resized image still shrinks to fit a narrow phone. Sizes are per viewport and fenced inside their own media query, the same trap fixed for type in v7.35: #sec-0 .frame (1-0-1) outranks .hero2 .frame (0-2-0) at every width, so an unfenced value would pin one number across the breakpoint (verified: phone w:9 and desktop wd:30 coexist, each leaving the other alone). The slider's max is the column the image sits in, measured live — max-width can only pull an image IN from its column, never push it past, so an uncapped slider would have had a dead top half (on a 390px hero the cap is the full 16.65em column = shrink only; on desktop the frame caps at 22.6em inside a 26.4em column, so it can grow). Two supporting fixes: the duo two-up had NO field mapping at all, so neither of its images nor captions had any controls — it now maps each side to its own figure (:first-child/:last-child), verified sizing the two independently at 7em and 14em; and the panel gates on the FIELD holding an image rather than on the frame being measurable, because still/duo emit their frame even when empty and measuring alone would have offered to resize an empty bordered box. Values are range-clamped like the rest, so a hand-edited cfg cannot write arbitrary CSS. 14 new checks, and the three existing suites still pass.).btn that is neither the bar's own nor inside the buy box — so it works on any lander without naming a section; a page with no CTA at all behaves exactly as before. The existing rule is untouched: the bar still drops away once the buy box is on screen, because covering the thing it is selling was the point of that one. Verified across the whole page by stepping the document through eleven scroll positions and reading the class each time: hidden at 0/600/1100 and still hidden at 1152 with the button's last pixel on screen, appears at 1160 the moment its bottom clears −7, stays up through 1400–6000, and hides again from 8000 on as the buy box comes into view. (Neither real scrolling nor screenshots work in the headless pane — IntersectionObserver and rAF never fire and the top-level window will not scroll — so the check drives the shipped barCheck by translating the document, which moves every getBoundingClientRect exactly as a scroll does, and dispatches the scroll event it actually listens for.))☰ Justify and ▤ Justify incl. last line (the latter emits text-align-last, since justify-all has no real browser support). Both are offered ONLY on running-text fields — a constrained block or a flex row has no line box to stretch, and the block branch would have read “justify” as “left”. TEXT PROPERTIES: every text field gets an Aa panel — size, line height, letter spacing, weight, style — stored as props._ft and emitted scoped to the section, exactly like alignment. The size slider is SEEDED from what the element actually renders, measured live out of the preview iframe: CSS has no “scale by 1.2× of whatever this already is”, so without the seed one nudge would slam a 2.1em headline to 1em. CADENCE: the new defaultPlan names the tile that opens selected; blank keeps the quantity treatment. Both Brody landers now default to Every 30 days. — WHAT THE REVIEW CAUGHT (all fixed, all regression-tested): ① an id-scoped font-size outranks the template's own @media (min-width:992px) rule (media queries add NO specificity), so a single size value would have frozen the breakpoint and handed every desktop visitor the phone headline, ~23% smaller — size is now stored per viewport (fs/fsd) and each side is FENCED inside its own query, so tuning one leaves the other on the template's responsive value (verified live: phone-only 1.2× override still renders the template's 2.8em on desktop). ② the seed silently fell back to 1.00× when the element was absent — which is exactly whenever the field is EMPTY, since every template emits those nodes conditionally — so nudging a .74em attribution from a slider reading 1.00 was a 42% jump; the panel now refuses to invent a value and says to add text first. ③ data-autocad was gated on defaultPlan being non-blank rather than on it RESOLVING, so a typo silently killed the 2+ bag cadence bump while the visible tile looked right — a shopper on a 30-day supply re-shipped in 15. ④ the one-time fallback was a substring test against a marketing sentence, so subscription selected “Just once — no subscription”, the precise inverse of intent, dropping the SUB20 discount; now exact-match or a reserved word, and plan labels match exact-before-substring. ⑤ pre-existing: if bulkPlan was loose enough to tag every plan, the “back to one bag” auto-switch target resolved to the one-time tile — pressing minus would have cancelled the subscription; the target now needs a real plan, same guard the current selection already had. Also hardened both emitters: every _ft/_fa value is whitelisted or range-clamped before it reaches CSS, since a cfg can arrive hand-edited or imported. ftOpen resets on section change and re-seeds after the preview repaints. 40 checks across three suites, plus the full buy-box loop re-run unchanged.).cmp-head and .cmp-row are SEPARATE grids sharing one grid-template-columns:1fr 3.4em 3.4em rule, but the head ran at .72em and the rows at .9em — so that identical rule produced 48.96px tracks in the header against 61.2px in the rows, plus 18.72px vs 23.4px of left padding. Measured live: “Flip 7” sat 22px right of the ✓ it titles and “The cabinet” 10px right of the ✕. Fixed at the root — the grid CONTAINERS now stay at the table's own font-size and the type scale moved onto the CELLS (.cmp-head>div{font-size:.72em} / .cmp-row>div{font-size:.9em}), tracks restated as 3.06em mobile / 4.68em desktop. Verified zero offset on all 7 rows at both breakpoints, with every rendered text size and padding value preserved to the pixel. A comment now warns never to put a font-size back on those two containers. (2) Publishing reset the lander picker. refreshList() rebuilds the <select>'s innerHTML, which snaps the selection to the first option — and pushLive() calls it, so the dropdown jumped to lander #1 while the editor stayed on the one just pushed: the picker and the canvas disagreed. It now remembers the loaded slug and restores it, and loadLander() sets the picker itself so the invariant holds however a lander is opened. Verified against the real builder with fetch stubbed: load-middle → publish → load-last → publish all keep the picker in step, and a lander vanishing from the list falls back cleanly instead of throwing. Same bug class as v7.33 — an em resolving against the wrong font-size — so the whole renderer was swept again adversarially (2 lenses, 5 candidates, all 5 refuted, one verifier re-measuring the fixed table in a live browser): no further instances, and no regression from this change.).bb-fl (a selected flavor row) is a div and inherits the band's 20px, so .bb-fn at .8em renders 16px and .bb-th at 2.1em renders 42px — but .bb-pk (a picker tile) is a button, and a button does not inherit type from its parent. It sat at the UA default 13.33px/Arial, so the same em rules resolved against THAT: 10.7px name, 28px pack shot, 41px tile against the row's 63px. Same markup, half the size. One declaration — font:inherit on .bb-pk — puts the tile in the row's type context, and the two now measure identically: 16px name, 42×42 thumb, 335×63 box, 9px/11px padding, 11px gap, zero height delta, on desktop and at 375px with no horizontal overflow. Every other control in the box (.bb-mode, .bb-pk-open, .bb-q, .bb-save) already declared its own font, which is why only this one drifted — note that an em inside the font: SHORTHAND resolves against the parent, so those were never affected. Swept the whole renderer for the same trap with an adversarial audit: three independent lenses over the whole renderer raised 4 candidates and an adversarial refute pass killed all 4 — .bb-pk was the ONLY instance of the trap, because every other control declares its own font. The audit did surface one genuine ADJACENT divergence, though: .bb-pk hard-coded text-align:left while .bb-fl inherits the band's alignment, so a centred buy box centred the row's name and left the tile's stranded left — and removing the declaration outright would have been worse, since a bare button defaults to CENTRE. Fixed with text-align:inherit; verified the row and tile now agree at default, left, centre and right. The pair of inherit declarations is what makes a button render as its div twin — do not swap either for a fixed value.)pick was already the mode helper in that scope, so naming the picker element pick would have shadowed it and broken every price sync — renamed pkBox.)duo template: two stills side by side from 992px, stacked on a phone, optional caption under each (verified row on desktop, column at 375px, no h-scroll). (2) A second lander VOICE. The formula so far framed a page as one man testifying — attributed quotes, "he said it himself", "his words, not ours". Tyler wants the sentiment carried as HEADLINES, ACTIONS and the reader own gripes instead, drawn from the approved headline bank (note_1057). Built as a SEPARATE lander (landers.appolis.app/brody-flip7-headline) so the existing quote-framed page stays live and untouched for comparison — that one is now the "quote lander" variant of the formula. Same assets, same commerce, reframed voice: hero "You are not lazy. Your body is just running on empty.", beats turned into the reader Tuesday, the turn carried as a headline with NO attribution, stills captioned as instructions, closer unattributed. A build-time guard fails the render if any testimonial phrasing survives. ⚖️ AND IT SURFACED A REAL COMPLIANCE PROBLEM (filed as note_1125): the quote lander disclosure asserts Brody is "a real member of the Flip My Life community, telling his own story in his own words", while the Production Bible says every frame is Seedance-generated and the voice is synthesised — a fabricated-testimonial claim sitting inside the sentence meant to protect the page. The new voice removes the endorsement entirely and discloses dramatization; the old page was left alone pending Tyler decision.)lines fields are deliberately untouched — newline is their ITEM SEPARATOR there, and converting them would have silently merged list items. A one-line tip in the rail makes it discoverable. Verified: a break typed into a former single-line field renders as a real break in the preview, the box grows to fit, and list fields keep their items intact.)_align {m,d} rides the same pattern as size/space/hide — left/centre/right/auto for mobile AND desktop independently. It moves more than text: chip rows, framed media, constrained blocks (beats, compare table, guarantee) and the button row all follow, because text-align alone cannot move them. Absent = the template's own look, so every published page renders byte-identical until a choice is made. (3) THE DESKTOP PREVIEW WAS LYING: #pv.desktop was min(1280px, 96%), so in a narrow builder window it sat UNDER the lander's 992px breakpoint and quietly showed MOBILE styling — meaning every desktop-only control (alignment now, but also the existing size/spacing/hide sliders) previewed wrong. It is now a true 1180px viewport scaled to fit the stage, re-fitting on resize. Verified: desktop preview reports 1179px inner width and renders the desktop alignment while mobile keeps its own.)items[n][id]/[quantity]/[selling_plan] cart line — verified live that Shopify accepts the multi-line form POST with selling plans attached (2 choc + 1 PB both landed on the 30-day plan). Rows light up when they are in the basket, the hero bag follows whichever flavor was last added, the running total reads 4 bags, and an empty basket disables the button rather than posting nothing. 🔁 SUPPLY-AWARE CADENCE: one bag is a 15-day supply, so crossing to 2+ bags auto-switches the plan to the bulkPlan (30 days on Brody) — and crossing back down returns to 15. It fires ONLY on the threshold crossing, so a deliberate pick afterwards STANDS (verified: manually choosing 15 days at 4 bags, then adding a 5th, keeps 15 days — the page never fights the shopper). Configurable per section via the new bulkPlan prop, not hardcoded. PRICING stays penny-exact at basket scale: 4 bags across 3 flavors → page $158.34, live checkout $158.34 (Shopify allocates the code ACROSS lines — 9.90 + 19.79 + 9.89 = 39.58 off $197.92 — and the aggregate matches our round-then-floor math exactly).)label | id | imageUrl and the import pulls each variant's own featured_image, so tapping a flavor swaps the bag (fade transition). (2) First-order pricing: new firstOff prop — the subscribe price now shows what they ACTUALLY pay on order one, with a then $X per shipment line under it. (3) 15-day cadence added and made the DEFAULT on Brody (one bag = 15 days supply); 30/60 follow, one-time last. (4+5) Nobody leaves the lander: every CTA (hero, closer, sticky bar) is intercepted and scrolls to the buy box with a flash instead of bouncing to the store — hrefs stay intact as a no-JS fallback — and the ONLY exit is the buy box's own submit, straight into a filled checkout. PRICING RESEARCH (answers the Brody ad's open checklist item): SUB20 is subscription-only — it allocates nothing on a one-time order (no discount chip at all) and stacks ON TOP of the plan's 10%: $54.98 -> $49.48 (plan) -> $39.59 first order. Matching Shopify to the cent required mirroring its rounding exactly — the plan discount ROUNDS per unit, the code discount FLOORS on the line; verified live at qty 1 ($39.59) and qty 2 ($79.17), both exact.)shopcart section: flavor pills, quantity stepper, subscribe-vs-one-time cards with the savings badge, and a live total. Built provider-agnostic — cfg.commerce = {provider, domain, discount, urlTemplate} at page level; Shopify is the first adapter, any other cart rides urlTemplate ({variant} {qty} {plan} {discount}). THE CRITICAL FINDING, caught by testing against the real store instead of trusting the docs: a Shopify cart PERMALINK (/cart/{id}:{qty}?selling_plan=…) silently drops the selling plan — the line lands at FULL PRICE with no subscription (verified via cart.js: plan: NONE, $54.98). The buy box therefore renders a form POST to /cart/add (the only path that attaches the plan), with return_to=/discount/{CODE}?redirect=/checkout so the code applies on the way — verified end-to-end live: 3 × PB subscribe → cart holds 3 @ $148.44 with plan 30 days, then discount → checkout. One-time purchases submit with the selling_plan field DISABLED so it is never sent. Price math rounds PER UNIT like Shopify does (a total-first calc drifted a cent: our $148.45 vs the store's $148.44 — now penny-exact). Builder: ⬇ Import product pulls real variant ids, selling-plan ids, price and image from the store's public product JSON via the new master-gated GET /api/lander/shop-import (SSRF-shaped host check; never hand-typed IDs), plus page-level Commerce settings. CTA click beacon extended to the submit button so buy clicks count in the v7.16 stats.)paint() swaps the preview iframe's srcdoc, which reloads the document and reset scroll to 0 on every keystroke; the repaint now captures the preview's scroll position and restores it after load with behavior:'instant' (the lander's own scroll-behavior:smooth would otherwise animate every restore — jitter at typing speed). (2) See your change where you make it: every SECTION edit (props, cards, sizing, band, add/move/remove) repaints with paint(1) = follow mode — the preview auto-scrolls to the section being edited via new id="sec-{i}" anchors the renderer now stamps on every band; clicking a section in the rail also smooth-scrolls the preview to it (pvShowSel). Page-level edits (title/theme/CTA) keep plain position-restore. (3) ↕ Spacing per viewport: new props._space {m,d} (0–250%) with Mobile/Desktop sliders beside the sizing pair — scales the band's top/bottom padding via --spm/--spd (.band .wrap padding is now calc(2.8em*var(...)); published pages with no _space render pixel-identical). Verified in-browser: scroll survives repaint at 900px exactly, follow lands on the edited section dead-on (4461/4461), 200% spacing doubles computed padding (56→112px).)concurrent_activity block appended to its response — the last 3 events with session labels + a stop-sign rule line ("if an entry below covers what you are doing, STOP and reconcile"). Read-only calls and quiet projects stay clean (zero noise). Project resolution: result.projectId → args.project → args.noteId/result.noteId → note lookup; echo is best-effort and can never fail a write. initialize instructions updated to name the mechanical layer. In-process tested: write-with-recent-activity → echo w/ session+what; quiet project → no echo; read-only → no echo.)HubDoc DO, which killed the cross-isolate stale read — but /save was an UNCONDITIONAL put, so two writers who both loaded the same record BOTH landed and the second still erased the first. That narrowed the window to one handler's duration; it did not close it. Agora proved the missing half in its v0.10.0, and this is that half brought home: rev + compare-and-swap (/load returns x-kosmos-rev, /save 409s a stale writer and writes NOTHING), reconcile on cold start instead of seed-once — a live bug, not a hardening, because seeding only when storage was empty quietly turned any out-of-band KV write (a restore, a roll-forward) into data loss the moment the DO mirrored its own older copy back over it; adoption is now STRICTLY-NEWER by updatedAt, which is monotone, so a stale edge read can never win — plus /reload for a warm DO and blockConcurrencyWhile around boot. On the caller side REV/CONFLICT WeakMaps keyed by the hub RECORD carry the rev with zero change to the stored JSON, and a conflict is a FLAG, never a throw (lib/mcp.js wraps its whole envelope in try/catch, so a thrown conflict would become a 200 "isError" and the AI door would go on losing writes while the web door looked fixed). withHub(env, resolve, fn) replays the whole mutation on fresh data with jittered backoff ×5, then gives up LOUDLY — never a success response for a write that did not land. Wrapped: putDoc (the mutation that actually ate docs), setTile, removeDoc, unpublishHub. Blob deletes in removeDoc moved to AFTER the commit (Agora's doc-first rule — deleting versions for a removal that never landed is unrecoverable). ⚠️ A UNIT TEST OF THE PORT FOUND A RACE THE ORIGINAL STILL HAS: the rev check sat BEFORE await request.json(), putting an await between the comparison and the increment — check-then-act, and two interleaved handlers both passed, committed 200/200, and lost an edit. Reproduced, then fixed by hoisting the body read so compare-and-increment is synchronous and indivisible. test/cas-hubdoc.mjs pins all of it (13 assertions incl. the two-writer race, strictly-newer adoption, stale-read rejection, and a mis-addressed DO refusing 400 not 409). (todo_1083) config.js said 7.07 while HEAD was v7.09 — the ☰ badge lied to every user; bumped, the two missing changelog entries written, and "config.js bumped?" added to the built-stage ship checklist. Its second half was NOT fixed by the bump: startVersionWatch polled /app.js's etag, so a release that never touched that one file raised no banner at all — exactly what v7.08 and v7.09 did. It now polls a new open /api/version (worker + server parity) reading the same config.) v7.09 (🎨 the lander design system, modeled on gruns.co (Tyler: "it needs to feel like this site" — deep dive) + PER-VIEWPORT SIZING — gruns was reverse-engineered two ways at once (a live browser audit of computed styles/keyframes/timings at both widths, plus a 7-agent workflow over their 256KB theme CSS, homepage and PDP). Their signature turned out not to be the pastel palette but the STICKER AESTHETIC: 2px outlines, HARD zero-blur offset shadows (3px 4px 0 0), and buttons that press INTO their shadow rather than lifting — plus one real breakpoint at 992px, 20px body type, full-bleed alternating bands, a 30s/40s marquee, giant display numerals, scroll-snap card carousels that become grids, and an fr-unit accordion. All of it re-carried in FML cream/cocoa/copper with Arboria (Book/Medium/Bold/Black pulled from the FML Shopify CDN and self-hosted in the engine KV as fml-font-; the engine's /media route gained Access-Control-Allow-Origin so the builder preview can load them cross-origin). NEW templates: ticker · stats · compare · faq; hero rebuilt to gruns anatomy (framed+tilted media, sticker offer badge, stat chips, CTA + under-caption + proof line); close became a full-bleed gradient banner. PER-VIEWPORT CONTROLS (Tyler: "you should be able to change mobile and desktop somewhat independently"): every section is em-scoped to its band, so each carries Mobile/Desktop scale sliders (props._size {m,d}, 60–150%), hide-on-📱/hide-on-🖥 toggles (props._hide) and a band-tint override (props._band) — all config, both previewable. Full reverse-engineered spec archived at LANDER_DESIGN_SPEC.md. Verified live at 390px and 1280px: 4/4 Arboria weights load, h1 42→56px (gruns' exact pair), carousel→grid flip, accordion animates, no h-scroll, compliance sweep clean.) v7.08 (🛬 LANDER BUILDER v1 + the shared lander template registry — productizing the Video Ad → Lander pipeline (note_990). A lander is now pure CONFIG (cfg:{tenant}:{slug} in the lander engine's KV) rendered through public/lander-templates.js — ONE renderer shared by the Builder UI and the generation scripts, so the preview IS the shipped page. Kosmos becomes the AUTHORING surface while the flipper-landers engine stays a dumb, fast serving layer (untouched): a cross-app LANDERS KV binding plus master-gated /api/lander routes (list · cfg get/put · publish · unpublish · image-pool list/upload). Builder at /lander-builder: section rail (add/remove/reorder from the registry), schema-driven prop editors, an IMAGE POOL modal showing every candidate frame the generator sampled (+ upload), theme/CTA/page editors, mobile+desktop live preview, Save draft (cfg only) vs 🚀 Push live (renders + publishes), and Unpublish that takes the page down but KEEPS the cfg editable. Styled ask twins throughout — no bare prompt/confirm, per the house rule. Compliance is built into the templates: divider marks the story→brand seam and disclosure carries the small print, because a spokesperson's story and the product pitch must never merge into a claim they took it.) v7.07 (🐛 three flagged bugs — note_589 #69/#70/#71 — (#69 typed text lost on Move) the note modal's title/body save via onchange, which only fires on BLUR, so any action taken with the caret still in the box read the PRE-typing value: new flushNoteEdits(id) writes both boxes first and is now called by moveNote, promoteNote, noteToList (it converted the stale copy), listToNote, and closeModal (✕ / backdrop / phone-back, fire-and-forget so teardown isn't blocked) — Cancel still discards, as Tyler specified. (#70 company card, screenshot report) the per-project chips under 🏺 Agora are gone — the card is the two doors he asked for (Open team space → / Open business →) plus role + counts; project navigation lives on the board's mirror tiles anyway. (#71 pipeline readability) progress now fills THROUGH the current stage (it used to render hollow) and every reached step paints its own point on a coral → gold → mint ramp (stageRamp lerps across the pipeline; the current dot enlarges with a colour-matched halo, labels take the same colour) — applied to both the project stepper and the tile pipebars; and on narrow screens centerStage() scrolls the CURRENT stage to the middle of the stepper on every render (clamped at both ends, so it's always visible without scrolling). Verified: fill-through-current + ramp colours, mid-pipeline centring exact (offset 0) and clamped-but-visible at both ends, typed text survives Move AND ✕-close, card chips gone with both buttons + counts intact.) v7.06 (🎼 score-cue-sheet joins TILE_TYPES (routed ask from the hubs-template session) — the Score & Music cue-sheet template had to squat video's generic other slot (templates key one-per-{pipeline}:{tile_type}, so a second misc video template would have overwritten it). Added score-cue-sheet (🎼) to the enum — wizard dropdown, lander sections, validation, and template matching all derive from TILE_TYPES so they pick it up automatically; the two hardcoded enum strings in the connector tool descriptions updated. MIGRATION (post-deploy): the template record moved hubtpl:video:other → hubtpl:video:score-cue-sheet (tile_type rewritten; templates are plain KV, safe to move directly), the other key deleted (slot FREE again), and brooke's live cue-sheet doc retyped via set_tile through the internal MCP door — hub-record writes go through the HubDoc DO only, never raw KV.) v7.05 (🏛 the team ROW restored, with collapse + hide (Tyler's final steer — v7.04 over-merged) — what he wanted was the WAVE's row exactly as it was: a separate team "open right now" strip directly BELOW the project dashboard (not a grid panel), just collapsible and hideable. Restored: agoraLinkBlock's linked branch renders the original 🏛 "From the team board — X @ Y" row with the /api/agora-feed loader (assignees + dues), in its original position — plus a chevron collapse (header click → header-only row; open by default, exactly as before; persisted per project in kosmos_teamrow_closed) and a ✕ hide (row disappears; a dashed 🏺 "Show team view" chip takes its place; persisted in kosmos_teamrow_hidden); the link picker shows in the header when open. The v7.03/7.04 GRID team panel is fully retired (PANEL_DEFS entry, gate, gen, TEAMVIEW loader removed; the worker's /api/agora-team-project endpoint stays available). Verified: row renders as its own row above the grid, open by default, grid panel gone, collapse→header-only, hide→chip, chip restores.) v7.04 (🏺 team panels UNIFIED + collapsible (Tyler: "you should be able to collapse this panel like the others") — his quoted header revealed TWO team surfaces coexisting: the multi-session wave had already shipped a FIXED "🏛 From the team board — X @ Y" strip (agoraLinkBlock linked-branch + /api/agora-feed loader — also the writer of the OBJECT agoraLink shape) and v7.03 added the grid team panel; a linked project showed both, and the wave's strip couldn't collapse. MERGED into ONE: the collapsible 🏺 Team project GRID panel owns the linked view (collapses/moves/hugs like every panel) and absorbed the strip's value — header now names the team ("— X @ Y"), body adds 🕐 Team to-dos with due dates (cap 5), and the team-link picker (extracted agoraLinkSelect, string-shape-safe) lives in the panel footer for move/unlink; agoraLinkBlock is now ONLY the doorway on unlinked projects; the dead client feed loader removed (the worker's /api/agora-feed endpoint stays for other consumers). Verified: exactly one team surface, old strip gone, header @ team, picker + dues present, collapse + expand cycle clean.) v7.03 (🏺 SIDE-PROJECT MODEL CORRECTED (Tyler's steer) — three changes to match how he actually works: (1) the mirror never disappears — it's the team project's ANCHOR tile on the board; creating a side project no longer suppresses it (the v7.01 twin-suppression was wrong: "why would I only be able to make one and it go away"); (2) MANY side projects per team project — the default name auto-numbers when siblings exist; the mirror keeps its + Side project button always; (3) every side project carries a 🏺 Team project LIVE VIEW panel — a new team panel (gated on agoraLink) fed by GET /api/agora-team-project (worker proxies to Agora's internal MCP get_project under the caller's own identity — soft-fails to a link if Agora is unreachable): team stage pill, each working list's OPEN items (read-only, done hidden, capped 6 + "more on the team board"), note titles, latest activity, and Open-in-Agora. Fetched once per visit (TEAMVIEW cache). Verified: mirror + side tile coexist on the board; the panel renders stage/list/notes/latest with done items hidden (and the panel-h3-uppercase test gotcha struck again — case-insensitive assertions).) v7.02 (🐛 stuck-view fix — agoraLink has TWO shapes (Tyler's live report: "the project view stays open when you click out") — agoraLink exists in the wild as BOTH my v7.01 URL string AND a richer OBJECT {teamId, projectId, name, teamName} written by another lane's sync; agoraMirrors() called .includes() on the object → TypeError inside viewBoard → app.innerHTML never swapped → the previous view froze on screen (render-crash = stuck view, the same failure signature to remember). Fix: agoraLinkId()/agoraUrl() read BOTH shapes forever; the twin match compares ids not substrings; tile badge + hero button resolve through agoraUrl (hero shows the team name from the object); startSideProject now WRITES the object shape (canonical, matches the sync); and agoraMirrors is wrapped in try/catch — mirrors are decoration and must never take the board down. Verified against Tyler's exact live record (object-linked side project): click-out works, links resolve, Studio-7 mirror correctly suppressed by its side project.) v7.01 (🏺 SIDE PROJECTS — the real intent behind the mirrors (Tyler) + 589#68 — a mirror is just the doorway: every mirror tile now carries + Side project, which creates YOUR OWN fully-editable Kosmos project in that category, linked to the team project via agoraLink (startSideProject: named via styled prompt, defaults "⟨name⟩ — side work") — side lists/notes/to-dos that are NOT part of the team board. The side project REPLACES the mirror in its category (the twin-suppression match), wears a 🏺 corner badge that opens the Agora team project, and its page hero gains a 🏺 Team project button; agoraMirrors twin resolution now prefers ACTIVE twins for suppression while any twin still supplies the home section. (589#68, the screenshot user-report): the 🏺 AGORA badge overlapped long names on mirror tiles — .top now pads 88px clear of the badge (measured, verified no rect intersection). E2E-verified: side project created into video w/ correct agoraLink, mirror suppressed, badge on the side tile, hero button present.) v7.00 (🏺 AGORA MIRRORS on the Kosmos board (Tyler's standing rule, 2026-07-24) — a project you work on in Agora is VISIBLE in your Kosmos account too, in the Kosmos category it fits: agoraMirrors() derives mirror tiles from the same /api/companies data as the strip and the board's section loop renders them beside real tiles — dashed gold border, 🏺 AGORA badge, stage pill, team name; clicking opens the project in Agora. Section resolution, in order: your explicit re-file (settings.agoraSectionMap, set by the tile's "file under" picker) → the archived local twin's section (projects MOVED to Agora carry agoraLink back, so Studio 7 automatically returns to 🎬 Video) → an Agora→Kosmos section heuristic (website→web, products→apps, campaigns→video, events→photo, else business). A project still ACTIVE on the Kosmos board never doubles (mirror suppressed until the local twin is archived). Verified in-browser with a stubbed companies payload: Studio 7's mirror rendered inside 🎬 VIDEO PROJECTS via twin-match, badge + Agora deep-link correct, re-file persisted to settings.) v6.99 (✏️ markable features deck (todo_1011) + three bug fixes (note_589 #65/#66/#67) — (1011) the deck gains ✏️ Mark mode: click any slide → a styled box collects the change request (copy tweaks, content, whole-slide ideas) → queued marks POST to the new POST /api/deck-notes (shared lib/api.js), which find-or-creates the 🎞 Deck change requests checklist on the Kosmos project and appends each mark as [slide N · label] … with ai:true — so deck feedback flows straight into ai_worklist with zero extra steps; bare alerts avoided (deckToast, styled-input rule). (#65) in-place re-renders (collapse/expand, check-offs, polls) swap #app's innerHTML, momentarily shortening the document so the browser CLAMPS scroll to top — render() now remembers scrollY on same-route re-renders and restores it immediately + next-frame (post-fitPGrids heights). (#66) collapsing a card panel changed its WIDTH (the hug pass skipped gcollapsed, falling back to the stored span) which re-flowed the whole grid — the hug now computes collapsed panels' width arithmetically from card data (their inner grid is display:none, nothing measurable), so collapse keeps the exact expanded width: only the vertical gap closes, layout stays locked. (#67) showLogin now hides the topbar ↶ undo along with search/menu (login reloads, so everything returns on its own); the Agora half of the bug is routed to its lane.) v6.98 (🔒 HubDoc — ONE WRITE CHANNEL for hubs (data-loss bug, reproduced + fixed) + ↩ layout revert — verifying v6.97 live EXPOSED the bug Tyler was feeling: three rapid hub_put_doc calls through the unified connector each read the KV edge cache (stale up to 60s, different isolates — v6.94's RECENT band-aid only protects reads within ONE isolate) and saved over each other: only the last doc survived, the typed test docs silently vanished. THAT is why "the changes weren't visible" — every typed doc I added to prove the feature was being eaten. Fix: HubDoc Durable Object, one per hub slug — loadHub reads fresh from DO storage (lazily seeded from KV on first touch, zero migration) and saveHub serializes through it with KV kept as the write-through read mirror; the no-DO path remains as a test fallback. Verified under wrangler dev: a genuinely CONCURRENT 3-call burst → all 3 docs survive with their tile_types; local lander groups (🎬 Shotlist · 💡 Lighting Guide · ▪ More + chips); then re-verified LIVE post-deploy. Follow-up flagged: upgrade the DO to CAS/rev checks for true same-millisecond writers. (↩ revert, Tyler's ask): the Arrange bar now always shows “Revert to ⟨template⟩” whenever the layout came from a template — one tap re-copies the saved template and unsaved drag/resize tweaks fall away (per-bucket aware).) v6.97 (🧩 tile types VISIBLE + 🎫 Basic preview tier (Tyler, 2026-07-24) — (tiles, finishing note_621#28's visible half): the v6.95 tile machinery existed but nothing SHOWED — existing docs were all untyped with no way to type them, and the lander rendered flat. Now every wizard doc row carries a 🧩 type picker (hubRetypeDoc → the same set-tile door; works on parked rows too), and the hub lander GROUPS tiles under per-type section labels in the accent color with a type chip on each card — only types actually in use render; hubs with no typed tiles render flat exactly as before (verified via direct shellPage render: sections only for in-use types, detached tiles excluded, untyped under ▪ More, emitted client script still parses per the shell-JS rule). (🎫 Basic tier): an Appolis ID kosmos grant with type: 'basic' = usage-capped preview — tierFromEnts stamps/refreshes account.tier on every ID resolve (masters exempt; upgrade/downgrade applies next login), and basicCap in lib/api.js enforces 1 ACTIVE + 1 ARCHIVED project per pipeline on create/archive/restore across BOTH doors (web API via the actor param — which now only stamps by: when an email is present — and the connector via ctx.tier on create_project/restore_project), plus a 25MB total attachment cap (counter in the account's own db.meta, checked only for basic). Caps are usage, not features — every flow stays open, unit-tested (create/archive/restore blocked at cap, other sections unaffected, no-tier unlimited). Cross-app Basic definitions routed to the agora/hermes/phantasia lanes; the tier contract note lives on the appolis tile.) v6.96 (📜 SCAN-FIRST RULE served over MCP (Tyler's rule: "baked into our mcp so it never happens") — the per-app connector's initialize instructions now LEAD with the concurrency rule: SCAN FIRST — ALWAYS (multiple people/AI sessions work this board concurrently; read overview + ai_worklist + FULL item text before creating or changing anything; re-read before each new burst; out-of-lane work goes on its own project's board). The unified Appolis Connector previously sent NO instructions at all on initialize (and never forwards per-app initialize), so no AI client ever received house rules — fixed on the appolis side the same day (connector 0.7.1 serves the full rule naming all four app prefixes). Also committed the inherited v6.95 hubs work as-found (authored by the interrupted session, verified complete by a 5-reader state map before touching anything).) v6.95 (🛖 HUBS: approval-gated joins + tile manager + house template library (note_621 #27/#28/#29, from the Brody port) — three interlocking hub features. (#27 joins) the lander's join becomes a REQUEST by default: name/email/role land in hub.pending[] + a ⏳ Join-requests checklist on the project (the board's attention tile + #/attention now count them); NO account exists and NO password is ever stored until the owner approves (wizard queue or hub_decide_join) — approval /id/admin/ensures the kosmos-only Appolis ID and enrolls the member, and the requester claims it with their own password from the hub's same Join button (ensure→claim); per-hub auto-approve toggle restores instant joins; invite links and already-approved emails skip the gate; responses stay uniform (anti-enumeration preserved). (#28 tiles) docs gain a tile_type (final-film · shotlist · storyboard · blocking-plate · lighting-guide · board-deck · bible · registry · script · checklist · lander · other — TILE_TYPES registry, drives icons) and a hidden flag: ⊖ Detach ≠ 🗑 Delete — a detached tile stops rendering AND serving (shellPage/syncLinks//d/* all filter it) but the doc, its R2 versions, and any board note survive for ↩ re-attach (hub_set_tile / /api/hubs/set-tile); the wizard's add row is now a tile-TYPE dropdown feeding every source (note/link/HTML/file); drive-by: removeDoc now purges file-kind blobs too (it leaked) and the dead camelCase joinEnabled field is gone. (#29 templates) a house template library at KV hubtpl:{pipeline}:{tile_type} (>200KB bodies in R2): the master account publishes ONE settled standard per tile type per pipeline (hub_template_put, incl. from_hub+from_doc HARVEST of an existing doc, e.g. Brody's director's sheet), every profile reads it (hub_template_list, wizard ✨ Standard button), and docs created from it are COPY-ON-ADD with a template_version stamp — when the standard evolves, stale docs show ↻ update-from-template (old content stays in 🕘 version history); the library never enters the serve path. New MCP tools: hub_set_tile · hub_decide_join · hub_template_put · hub_template_list (11 hub tools total).) v6.94 (🐛 TWO BUG FIXES from the 07-23 Discover re-scan (note_987). (1) note_589 #64 — collaborators couldn't reach Phantasia from shared video projects: the 🎬 Phantasia button was gated isSuper() && !shared (invisible to every collaborator); now it shows on ALL video projects, AND /internal/video-projects merges video projects SHARED to the caller's email (shares.loadIndex grouped by owner, role re-verified vs project.shares, works pre-provisioning) tagged shared_from — Phantasia's 🎥 tiles + dashboard wear a 🤝 owner tag. (2) todo_978 — hubs read-after-write: KV edge-caches hot reads ~60s, so a hub_get right after an upload could serve the pre-write record (an agent retried and minted a duplicate version). Fixed at two layers: per-isolate read-your-writes (saveHub parks the fresh record; loadHub prefers it for 90s) + content-hash idempotence in BOTH write paths (upload path now uses a TRUE bytes hash, not the old length:versionId pseudo-hash; identical re-uploads apply meta but never mint a version, returning unchanged:true).)hub.appolis.app/{slug} — ONE custom domain, unlimited hubs, live instantly at publish (no attach step). handleHub grew the canonical path-mode branch (same machinery as the /h/ fallback) + a legacy branch: old per-hub domains (studio7.appolis.app) 301 to the path form with path+query preserved, so no shared link breaks. publish no longer calls the CF domain API; /api/hubs/attach-domain + /api/hubs/finalize-wildcard endpoints removed; lib/cfdomains.js PARKED (unreferenced — the future Cloudflare-for-SaaS vanity-domain starting point). Wizard + connector copy updated to the path form.)GET /internal/video-projects — Phantasia reads this account's video-section projects (stage label/icon/color from the video pipeline config, next event, shot + open-todo counts, live/hub links) to power its 🎥 Video Projects area. Kosmos video projects' 🎬 Phantasia button now opens that area's per-project dashboard (/?vp=<id>) — the ONE door (Tyler killed a separate Kosmos live-shoot button 2026-07-19); the shoot-day clapper quick door lives on Phantasia's tile (🎬 LIVE when stage=shoot or the next event is today). Phantasia runs the shoot; Kosmos watches.)focus block (items + note + a hard directive) with in_focus:true flags on matching queue items — so a live session re-reads it mid-run and narrows mechanically; out-of-focus items stay visible for routing only. Chat twin: new set_cowork_focus tool (resolves project/note/client refs by partial name; clear:true wipes) — the app panel and chat write the SAME model. 16 dispatcher-level tests + full UI loop verified.)seconds (lockstep). Live chat answers still act instantly regardless.)#/example/<section> — a read-only project page shaped like the real thing: hero + EXAMPLE badge, the full pipeline with the current stage lit, the category's scaffold lists filled with believable sample items, sample to-dos with due dates, a sample note, and a "+ Create your own" CTA. Seven hand-written examples (Orbit habit app · Sunrise Fitness brand film · Harvest Market logo · Ramos wedding · Bloom Bakery site · self-watering planter · corner juice bar). Agora v0.8.0 ships the same feature with eight store-team-shaped examples (retention initiative, bundle drop, email blitz, UGC batch, restock week, homepage refresh, farmers-market pop-up, influencer collab). Verified in-preview: empty sections auto-show, populated sections hide, pull-open/re-hide both work.) v6.50 (☑ scaffolds SHAPED per category (Tyler's todo_686 correction) — the dev four (🐛 ⚙️ 🎨 🔮) belong to Apps & Games + Web ONLY; every other section now seeds its own intuitive 4-list set beyond the native To-dos: video 🎬 Shot list · 📋 Pre-production · ✂️ Post & edit notes · 🚚 Deliverables; design 📝 Brief & references · 🎨 Concepts & revisions · 🖼 Assets & sources · 🚚 Deliverables; photo 📋 Shot list · 🗓 Session prep · ✂️ Editing queue · 🚚 Deliverables; inventions 🔬 Research · 🧪 Prototype notes · 🛠 Parts & materials · 🛡 Protect & patent; business 🔍 Research · 📋 Launch checklist · 💰 Costs & funding · 🤝 Contacts & partners; unknown future sections get a neutral set (📋 🚧 📝 🔮). Board backfill corrected: the 12 mis-seeded empty dev lists on video/business projects removed (identified by their seed marker — nothing hand-made touched), 12 category lists seeded. Agora v0.7.1 ships the same shape for its 8 team sections (website = the dev four).) v6.49 (☑ five-list scaffold on EVERY project type (todo_686) — seedProjectLists in lib/api.js: every new project of every section (apps, video, web, design, photo, inventions, business) is born a working surface with 🐛 Bugs · ⚙️ Function changes · 🎨 Design changes · 🔮 Future Updates pre-made (To-dos are native) — wired into BOTH creation doors (API POST + the connector's create_project) and exported for Agora's identical use (its v0.7.0 ships the same code + the full v6.47 engine-parity port, per the Kosmos-trains-Agora doctrine). Existing board backfilled: 21 lists across 12 projects.) v6.48 (🏺 Agora links → agora.appolis.app — the Spaces menu entry and every company-card/project chip deep link now use Agora's custom domain (provisioned 2026-07-17) instead of workers.dev; part of the suite-wide "workers.dev is never user-facing" sweep ahead of the flipmylife → appolis account-subdomain rename.) v6.47 (🐛 the note_589 #35–41 batch + 621#9 (Tyler's bug list, 8 items one build) — (#41 THE SPACING ROOT CAUSE, two halves): (a) the grid fit rode requestAnimationFrame, which never fires in a hidden/backgrounded tab — come back to the app mid-render and the RAW unfitted grid stayed on screen (360px holes under short panels, no dense packing); every fit call now rides scheduleFit() (setTimeout 0 — always fires). (b) outer rows quantized at a whole square (~48px), so even a correct fit left up to a square of dead space below every panel; outer rows now quantize at 12px (column width decoupled onto data-colw for the width-hug math; collapsed-panel span clamp 4→16) — panels land within ~13px of true content height and dense flow finally packs Tetris-tight (verified: Activity backfills directly beneath two collapsed panels; collapsed panel = its header, 63px). (#40) card resize regression: v6.43 hid the ◢ handle on collapsed cards but v6.13 made every card START collapsed — handles now show on ALL cards in Arrange; resizing a collapsed card is width-only (kills the original snap-back that motivated the hiding). (#35+621#9) ✅ Lists left the top bar: 🔎 By source took its slot and 🕐 Recents joined it (new #/recents view — notes, lists and to-dos merged newest-first, 40/page \"show more\"); hamburger carries By source + Recents + Lists. (#36) Quick-Add: picking a project tied to a client auto-selects AND LOCKS the client field (unlocks when the project clears; scan + combobox pre-fills route through the same lock). (#37) ‼️ IMPORTANT on any to-do or list item (editors + Quick-Add): marked rows show ‼️ and sort first, and the connector's ai_worklist carries important:true sorted first with a handle-immediately hint (todos POST/PATCH whitelist + lib/mcp.js). (#38) 📸 screenshots on Quick-Add bug reports (≤4 images ≤2MB, uploaded via the attachment pipeline, pinned to the 🐛 Bugs note + remembered on the item — the item editor shows them). (#39) finished items ALWAYS start collapsed: the note-popup checklist groups done under \"show N done\" (indices preserved) and the full to-dos modal does the same.) v6.46 (🔌 Appolis Connector P1 — Kosmos is the first room on the suite hub (APPOLIS_MCP_DESIGN.md) — new machine-only route POST /internal/mcp (gated by the shared ID_SECRET, 404 otherwise): the appolis apex hub forwards RAW JSON-RPC here over the new KOSMOS service binding, so the person's ONE Appolis connector (appolis.app/mcp/<amt_token>) mounts every Kosmos tool as kosmos_ — the SAME lib/mcp.js brain as the per-account /mcp/<token> connector (which keeps working untouched), identity resolved by x-appolis-email with lazy provisioning exactly like SSO login (master email → ROOT). Licensing == visibility: the hub only forwards for IDs entitled kosmos/; revoke an entitlement or the token and the tools vanish. The per-app connector stays for anyone not on Appolis ID; the suite path is now the recommended door.) v6.45 (🏢 company cards — one company, one card (Tyler steer on the v6.44 strip) — the your-companies strip no longer lists Agora teams and Hermes businesses as separate flat rows: companiesStrip() MERGES them into company cards, keyed by the Appolis teamId when an app ships one (Agora team ids ARE registry team ids; Hermes will carry teamId once a business is linked to a team) else the normalized company name — a company living in both apps = ONE card with an "Agora + Hermes" pill and a button per space ("Open team space →" / "Open business →"), each app line keeping its own role pill + stats (Agora: open count + up to 5 project chips; Hermes: customers/users/connectors). Cards render in a responsive auto-fill grid (.cocard) instead of border-bottom rows. Paired prod-data fix on the Hermes side: tenant t_verdant renamed "Verdant Gut Co." → "Flip My Life Wellness" (live D1 UPDATE, name column only — the strip reads it via /internal/overview).) v6.44 (🏢 your-companies strip + Agora space switcher (todo_663 + todo_655, Teams layer P2 Kosmos-side) — (663) new worker route GET /api/companies (per-account, placed BEFORE the generic /api handler) fans out to the AGORA (agora) and HERMES (flip-cms) service bindings' /internal/overview?email= with the shared ID_SECRET header and merges {teams,businesses}; an app that's down or unbound just omits itself. The board gains a collapsible 🏢 Your companies section (renders ONLY when you have company work): each Agora team = name + role pill + open count + up to 6 project chips deep-linking into agora (/#/project/<id>); each Hermes business = name + role + customers/users/connectors counts linking to hermes.appolis.app. Fetched once per session (COMPANIES/loadCompanies, CAL_EXT pattern). (655) the ☰ menu gains a Spaces group with 🏺 Agora — your team space (agora.flipmylife.workers.dev until the apex DNS lands; Agora v0.1.0 already ships the reverse link) — the both-ways switcher from note_649. (665, confirmed-shipped) inline item editing from the working-lists view already exists since v6.6 (openItem on every row: text/🤖/done/delete/→to-do without opening the note) — verified in-browser and closed; Agora inherited it via today's engine copy.) v6.43 (🏛 per-category default layouts + column-only pins + collapsed-card resize fix (Tyler) — THREE things. (1) Category defaults — Tyler's "working templates I refine for people": root arranging a project can choose 🏛 Make this the default for every ⟨Section⟩ project (new option in saveLayoutTemplate's askChoice, root-only); it writes settings.categoryLayouts[sectionId]={panels}, the worker serves it to members read-only (DB.categoryLayouts), and panelEntries uses it as the per-panel DEFAULTS (via categoryDefault() → gridEntry's defW/H/O/C) so EVERY project of that type inherits it — unless the account has personally arranged that specific project (projLayout still overrides). Verified: setting the apps default (notes w=20) propagated to tiplift (apps, uncustomized → 20) but not a business project (stayed 8). (2) Vertical-gap fix — column-only pins: a card pin now sets the COLUMN only; the ROW always flows densely (gridMoveDrag drops the gy/row-start, gridEntry forces y=0), so dropping a card low never leaves empty rows above it and two cards can't share a cell — this kills the vertical-gap AND overlap classes together (dense flow packs), so the 589#34 collision-check was removed as redundant. Side-by-side still works (column pin widens the panel, verified mover→col 7, panel span 9, no overlap). (3) Collapsed-card resize: a collapsed card (wlcol) showed a ◢ handle but resizing snapped back (the collapse branch forces header height) — gridHandle(exp) now hides the handle until the card is expanded; expanded resize holds (verified span=gh).) v6.42 (🧩 no card overlap + panel bottom-edge hug (note_589#34) — TWO symptoms. (overlap) a pinned card SPANS several cells, so it could overlap a pinned sibling even with the cursor over an empty cell (grid explicit placement stacks colliding items). Fix: the pin branch of gridMoveDrag now rejects a target whose rect intersects any PINNED sibling's rect (cx≤sx+sw−1 && sx≤cx+w−1 && cy≤sy+sh−1 && sy≤cy+h−1) — the card holds its last valid spot until it finds free space; flowed sibs (gx=0) are skipped since dense flow routes them around explicit placements. (dead space) the panel BOX stretched to fill its whole 48px-row span, leaving quantization dead space below the cards even though the inner grid hugged; .pgrid:not(.inner)>.gitem{align-self:start} makes the panel box hug its content height (cards keep set-size — inner grid unaffected). Verified via the real drag engine: an overlapping pin attempt was rejected (card stayed, then dropped to free gy=9, no visual overlap); panel dead-space-below-inner dropped 35px→17px = the 16px intentional bottom padding. Deploy pending; docs all 6.42.) v6.41 (↔️ side-by-side cards widen the panel (note_589#33) — the v6.40 hug was "close" but removed the ROOM to arrange horizontally: with the panel hugged to its widest card there was no empty space to drop a card beside another. Fix: arrangeRoom(ig) — the moment a CARD drag passes the 7px threshold (or a card corner-resize starts), its panel temporarily opens to FULL outer width and the inner grid re-tiles, so empty cells exist beside every card; dropping there pins the card (existing x/y mechanism) and on release the hug re-tightens around the new arrangement — the pinned extent (x+w−1) is exactly what keeps the panel wide. Verified through the real drag engine: hugged span 6 → drag → room span 20/29 cols → dropped beside card 0 → pinned x=9 → settled hugged span 12 holding the side-by-side pair; no console errors.) v6.40 (🤲 panels move-only + full content hug (note_589#32) — panels lost their ◢ handle entirely (gridHandle(card) renders it for CARDS only): you MOVE panels; their size is DERIVED. Height hugs content (v6.39) and now the card panel's WIDTH hugs its widest card too — computed from card DATA (max(x? x+w−1 : w) in sixths → px → outer fine span, clamped [4, tracks]), not from rendered layout, so it's pure arithmetic with no reflow loop AND it works in both directions: resize a card wider (card maxW no longer clamps to the panel's current tracks) and the panel grows on release; shrink it and the panel pulls back in. After hugging, the inner grid re-tiles for the new width and cards refit. Text panels (no inner grid) keep their template/saved width — height-hug only. Arrange toast copy updated. Verified: 0 panel handles / 5 card handles; working panel hugged span 20→6 around an 8-sixth card, then grew 6→14 when the card was engine-resized 8→20 sixths; no console errors.) v6.39 (📐 panels trim dead space (note_589#31) — a project panel's height was max(saved-h, content-need), so a panel dragged taller than its cards held dead space below them. Now panel height = content need exactly (the saved h is ignored for panels; fitItems outer branch drops the Math.max(gh, …) floor), so panels hug their cards with no empty room. Cards are untouched — the inner branch keeps 589#21 (expanded card = exact set size, scrolls inside) and 589#25 (collapsed → header only); the outer grid stays dense/Tetris (589#20). Verified in-browser: a panel with set gh=14 renders at span 3 (its content) not 14; the working panel span 11 = its content 11 (hugs); an expanded card still renders exactly its gh=4 with internal scroll while the panel re-hugs.) v6.38 (🗓️ calendar bubble blowout + 🎯 Quick-Add suggester accuracy (note_589 #29, #30) — (#29) long event/to-do titles were stretching the month grid: .ev has white-space:nowrap+ellipsis but the grid used repeat(7,1fr), so a long unbroken title gave the cell a min-content width that forced the column to grow (the same min-width:auto grid-blowout root cause as v5.5) — fixed with repeat(7,minmax(0,1fr)) + .day{min-width:0;overflow:hidden}; bubbles now clip cleanly and carry the full text as a title= hover tooltip. (#30) the Quick-Add project/client auto-match was inconsistent + wrong: it dropped every word under 4 chars (so acronyms AWS/REW/FML never matched → "sometimes no suggestion") and scored every name-word equally (a hit on "flip" — shared by Flip My Life + Quantum Flip — pointed as confidently as a distinctive word → wrong picks). Rebuilt qcScanNow with distinctiveness weighting: word floor lowered to ≥3, each name-word hit scored 12/df (df = how many candidates share that word, so rare words dominate), and a clear-winner gate (top score ≥3 AND ≥1.5× or +4 ahead of #2) — ambiguous input now fills NOTHING and clears any stale auto-fill instead of guessing. Verified against real board: "aws deal"→AWS Motor Club, "quantum flip"→Quantum Flip, bare "flip"→no project (ambiguous), "flip my life"→Flip My Life, "the vegan patriot"→Vegan Patriot (no "the"→wrong-match regression), "random grocery"→nothing.) v6.37 (↶ undo for inbox accept (bug) — accepting inbox items ("✓ Accept all high/medium-confidence", "Accept everything", or a single "confirm") was NOT undoable: the v6.31 undo system only wraps the list/to-do/note mutations, and acceptAll/confirmNote were never registered, so Ctrl+Z / the topbar ↶ had nothing to take back. Fix: POST /api/inbox/accept now returns the exact ids it flipped, a new POST /api/inbox/unaccept sends those note ids back to the inbox (triage.confirmed → false; inbox membership is !triage.confirmed), and both acceptAll and confirmNote register a precise pushUndo (un-accept exactly what was just filed, not everything confirmed). The toast now says "· ↶ undo" so it's discoverable. Live-verified: accept high → items filed → ↶ returns them to the inbox.) v6.36 (🚪 suite-wide sign-out fix — signing out now also clears the shared appolis_id cookie (Domain=.appolis.app, Max-Age=0), not just the Kosmos session. Before, an account that signed in via Appolis ID left the shared cookie behind on logout, so resolveAccount's SSO fallback re-authenticated them on the very next request — "Sign out" silently no-op'd for SSO users. Now any door closes every door: signing out of Kosmos signs you out of the whole suite, matching Phantasia's v0.5.0 logout. One appended Set-Cookie in the worker's /api/logout, mirroring the login handler's existing appolis_id set. Live-verified: SSO login → sign out → next request is unauthenticated.) v6.35 (🔬 the TINY GRID (todo_635 pt 2 — 635 complete) — the arrange grid runs at FINE resolution: panels move/resize in quarter-square steps (desktop 20 tracks / tablet 12 / phone 8 — the gap math makes 4 fine tracks + 3 gaps exactly one old square, so the square rhythm survives) and cards in sixth-square steps (old third-cells halved; .pgrid.inner gap drops to 10px so 6 cells + 5 gaps tile a square exactly — twelfths were tried first and CANNOT tile once gaps are paid). All stored units are now fine units marked u:2; older entries scale on read (panels ×4, cards ×2, x/y remapped) — per-entry migration, buckets and templates included, zero bulk rewrite. House presets stay in square units; applyPreset converts ×4 on copy; thumbnails scale down. Spans went INLINE (gridPos + fitPGrids clamp to each grid's real track count) — the gw/gh CSS class ladder and its breakpoint clamps are deleted; rszMove resizes via inline gridColumn. Collapsed panels now measure their header (span ≈2 fine rows) instead of holding a whole square. Same contract as ever: panels hug their cards + Tetris-dense, cards fixed-size w/ internal scroll, deliberate gaps inside panels only. Verified: one-fine-cell corner drag 8×4→9×5 persisted u:2; Tyler's saved square-unit layouts render identically (5×1→20×4); phone bucket 8 tracks no h-scroll; presets convert (Work first → 20×12); no console errors.) v6.34 (📐 per-resolution layouts + instant panels + arrange auto-exit (note_589 #26–28) — (#27) project layouts now save PER RESOLUTION class on the grid's own breakpoints (>900 desktop·5 cols / 641–900 tablet·3 / ≤640 phone·2): layoutBucket/bucketTarget/bucketView — desktop stays the original flat projLayout fields (zero migration, all saved layouts keep working), tablet/phone tweaks live under L.bp.<bucket> with PER-ENTRY fallback to flat until first saved at that size; gridEntry reads bucket→flat→default, saveLayout writes through bucketTarget, the resize listener re-renders when the viewport crosses a bucket on a project page, and 💾 save-template snapshots whatever bucket you're arranging. (#28) saveLayout is OPTIMISTIC: local DB.settings update + immediate render, PATCH persists in the background (awaiting the round-trip made collapse/expand laggy; the 45s poll reconciles losses). (#26) Arrange mode is per-visit — render() drops ARRANGE whenever the route changes.) v6.33 (🧵 AI lanes — the session-per-project system, productized — every project page gains a 🧵 AI lane button → a modal with a ready-to-paste, project-scoped brief (lanePrompt/openLaneBrief/copyLane): a fresh assistant session becomes that project's dedicated LANE, pulling only its flags via ai_worklist(project) (the connector already supported the filter — zero backend change), staying in lane by routing foreign work onto the right tile with add_todo, respecting ai_scheduled_later, and reporting each pass. #/cowork gains panel ④ explaining lanes; deck co-work slide updated. This ships the house's cross-session discipline (board-as-router + one-writer lanes) to EVERY account through their own per-account connector — the process scaffolding travels in the generated prompt, while house methodology stays invisible (per-account playbooks flow through next_steps at runtime, never through this text).) v6.32 (🏛 layout templates (todo_635) + modal-chain fix + Hermes tile — the five house layouts are now IMMUTABLE STANDARDS: applying one deep-copies into your own settings.projLayout (L.base remembers which), so tweaks never touch the template. 💾 Save layout… in the Arrange bar snapshots the current panel arrangement: members save personal templates (settings.layoutTemplates); the house (root) chooses via the new styled askChoice — update the house standard (settings.houseTemplates overrides, served to every account, ↺ resets to shipped) or keep it personal, with 🔗/🔒 share toggles; members receive ONLY the shared subset (DB.ownerTemplates) + house overrides via a worker attach on /api/db (which now also carries DB.me role) — the invisible-playbook doctrine applied to layouts. Fixed en route: dropOverlay()'s async history.back() was tearing down the NEXT modal in any chained flow — the styled twins now use dropOverlayChained() (consumes the back-state only if no follow-up modal claims it by next tick). Board: flip-cms tile renamed Hermes (app-side rename shipped by the Hermes session; keyword pattern gains hermes). Repo now under git (baseline commit pre-v6.32).) v6.31 (↶ undo (note_589#23) — built by the background co-work worker in a parallel session: a 10-deep UNDO_STACK of {label, revert} closures captured at the api-wrapper level covers checks, edits, adds and deletes across lists, to-dos and notes; Ctrl+Z or the topbar ↶ button (lights mint when there's something to take back). This line back-filled by the live session — the worker synced config + deck but missed the breakdown; both sessions shipped one merged deploy.) v6.30 (🎛 suggested layouts (todo_635 pt 1) + to-dos full view (589#24) + web restructure (todo_636) — (635) Arrange mode now opens with a ✨ Suggested layouts bar: five pre-made arrangements (Command deck · Work first · Split focus · Mission brief · Library — Tyler's layouts shipped as defaults per the main-brain doctrine), each drawn as a tiny color-coded thumbnail (mint working / sky notes / violet next / gold patterns / coral activity), ordered per project category (SECTION_PRESETS); tap to apply (replaces the panel arrangement, cards untouched) and tweak from there. The tiny-grid freeform half of 635 stays open. (589#24) the 🕐 To-dos card gains the ↗ full view — a modal with every open + done to-do and an add row, like any list. (636) the three site-worker tiles (REW locker, fraud cleanup, Rivo sub apply) are ARCHIVED — their story lives on Flip My Life as a pinned 🌐 Site work log, and their reusable lessons are now three website-playbook patterns (discount-stacking lock · fraud auto-cleanup · widget+app-proxy pair) for any other business's site.) v6.29 (🃏 collapsed cards shrink (note_589#25) + 👑 master wildcard — a collapsed card (▸) now shrinks to just its header/progress rows (wlcol class + a no-floor measure in fitPGrids) instead of holding dead space at its set size; expanding restores the exact set size (589#21 unchanged). And Kosmos honors the Appolis ID wildcard entitlement — a 👑 master ID (Tyler) opens Kosmos with no per-app grant.) v6.28 (🪪 Appolis ID adoption — P1 of the one-login design (todo_594) — Kosmos is the first suite app on Appolis ID (APPOLIS_ID_DESIGN.md in the appolis repo): dual-auth login (/api/login tries Kosmos accounts first, then the same credentials against Appolis ID over the new APPOLIS_ID service binding — an entitled account signs in at Kosmos's own door, per the any-door rule) and shared-cookie SSO (resolveAccount falls back to the appolis_id cookie on Domain=.appolis.app: local HMAC verify via the shared ID_SECRET var, then /id/resolve for entitlements — signing in at ANY suite app opens Kosmos too). Appolis identities provision local member accounts lazily (provisionFromAppolis, mapped by appolisId/email; the master email maps to ROOT) — per-account isolation unchanged by construction. Live-verified end-to-end: test Appolis ID + kosmos grant → logged in through Kosmos's form (both cookies set) → resolved from the SSO cookie alone → member space empty (isolation) → portal showed the entitled tile → full cleanup.) v6.27 (✍️ add-item box conforms (note_589#22) — the note-popup "Add item…" box was a fixed <input>; now an auto-grow/shrink textarea (min one line, Enter adds, Shift+Enter newline) matching the item rows.) v6.26 (📏 cards keep their set size (note_589#21) — refined the v6.23 auto-fit split: PANELS still grow so you never scroll a panel to see its cards, but CARDS now hold exactly the size you set — content taller than the card scrolls INSIDE it (.pgrid.inner>.gitem{overflow-y:auto}; fitItems pins inner spans to data-gh instead of growing), or open the note full-view as usual.) v6.25 (💬 bring-in-your-AI-chats + menu scroll fix — Tyler's ask: the connector must be robust enough to capture EVERYTHING from every chat/code/co-work session, for whatever AI, with an easy in-app how-to. Reality: one chat can't reach into another (platform privacy), so the import page now teaches the three honest paths — ① Live sync: a ready-to-paste SYNC_PROMPT (copy button) that any connector-attached session runs once: create_project for new work (fitting icons), import_notes for ideas/decisions (deduped), add_todo for next steps, search-first so nothing duplicates; ② Bulk history: the in-app importer now parses Claude and ChatGPT conversations.json exports (parseChatExport — Claude's chat_messages shape + ChatGPT's mapping-tree, flattened chronologically; each conversation → one 💬 note labeled chat-import, transcripts trimmed at 12k chars so one giant chat can't bloat the KV doc; exact claude.ai/ChatGPT export steps in the panel); ③ paste anything else. Plus: the desktop ☰ menu was taller than the viewport with no scroll — .menupop now max-height + overflow-y auto.) v6.24 (🎛️ third-cell cards + deliberate gaps + popup item rows (note_613#0, note_589 #18–20) — (613#0) the inner card grid runs at a THIRD of a panel cell (3×3 card cells per panel square; default card = 3×3 = one panel cell; card spans to 15w/12h). (589#18) dragging a card onto EMPTY grid space pins it to that cell (x/y in the layout; explicit column inline + fitPGrids applies the row start), so you can leave deliberate space between cards; dragging it back over a sibling returns it to the flow. (589#20) panels never pin — they stay auto-flow dense ("Tetris"), so collapsing one lets the rest backfill the space. (589#19) the full-list popup's items are now WRAPPED auto-growing textareas (long items fully readable in place) and each row carries the 🤖 assign/unassign toggle.) v6.23 (📐 auto-fit grid + collapsible panels + suggester fix (Tyler refinements + note_589 #16/#17) — (no inner scrolling) grid items now AUTO-GROW: fitPGrids measures each item's content (span-1 shrink → scrollHeight → span = ceil(content/rows)), so cards force panels to fit around them and nothing scrolls inside a panel; the saved height is a MINIMUM (corner-resize still sets it, now via inline gridRow since auto-fit owns the property). (collapsible panels) every project-page panel collapses to its header bar (chevron h3, like the rollup); state persists in the same per-project layout (c flag, carried through drag/resize writes). (589#17 suggester) Quick-Add auto-fill waits 900ms after typing stops, matches exact WORDS only (no substrings — "the" was matching The Vegan Patriot), drops stopwords, and requires real confidence (a name-word hit, or 3 description hits) — no suggestion beats a wrong one; clearing the text also clears whatever was auto-filled. (589#16) connector create_project takes an icon and instructs assistants to ALWAYS pass a fitting emoji (placeholder until a real logo).) v6.22 (🧩 Arrange v2 — drag, not dials (Tyler's refinement) — the ◀▶/W/H button bar is GONE. In Arrange mode you now hold-and-drag any panel or card to move it among its siblings (live reorder preview; drops persist) and drag its ◢ corner handle to resize in whole cells — Pointer Events, so mouse and touch behave identically (bindArrange/gridDown/gridMoveDrag/rszDown/rszMove; 7px drag threshold so taps don't trigger moves; items lock scrolling + touch-action:none only while arranging). Half-cell cards: the inner card grid now runs at HALF the panel-cell size — a 1×1 panel cell holds a 2×2 of card cells (fitPGrids computes inner tracks from the outer cell; card spans run to 10 wide / 8 tall; default card = 2×2 = one panel cell). Same persistence model (settings.projLayout).) v6.21 (🧩 project-page grid + Arrange mode (note_621#2) — everything below the hero/pipeline/rollup (which stay fixed, per Tyler) now lives on a grid of equal square cells: 5 across on desktop, 3 on portrait tablet, 2 on phones (.pgrid + gw1–5/gh1–4 span classes, spans CSS-clamped per breakpoint, fitPGrids() keeps rows square by matching row height to measured column width). Every panel (Working lists & to-dos, Notes, Next steps, Playbook patterns, Activity) is a grid item, and every card inside the working panel (🕐 To-dos, 🐛 Bugs, ⚙️ Function changes, 🎨 Design changes, 🔮 Future Updates, any list) gets the SAME treatment on its own inner grid. 🧩 Arrange (hero button) overlays each item with ◀ ▶ reorder + W−/+ H−/+ resize controls; arrangement persists per account per project in settings.projLayout[pid] = {panels:{key:{o,w,h}}, cards:{…}} (gridEntry/panelEntries/cardEntries/layoutNudge/layoutSize/saveLayout). Content taller than its cells scrolls inside the item. Also .wtx[onclick] gets a pointer cursor (589#15 styling nit).) v6.20 (🎨📸 two new work sections (todo_633) + queue/guide fixes — (633) config.js sections[] gains 🎨 Graphic Design (Brief → Concepts → Design → Revisions → Approved → Delivered; stalls on Revisions/Approved) and 📸 Photography (Inquiry → Booked → Shoot → Culling → Editing → Delivered; stalls on Culling/Editing — the post-shoot backlog), each with a house stagePlaybook; the whole app is config-driven so board blocks, pipeline views, stage tiles, Quick-Add project sections and /breakdown pick them up automatically; the connector's create_project/add_methodology section lists updated. (589#15) AI-work-queue list items are now tap-to-edit (openItem) like to-dos. (589#14) setup guide order: 📥 Import moved to 2nd, right after profile.) v6.19 (🧭 setup guide (note_614#1) — a first-time walk-through that's a LIVING checklist, not a one-shot tour: 9 steps (profile, first capture, first project, import, AI connector, first 🤖 flag, co-work schedule, calendar, vault), each with the why + a take-me-there button. Steps auto-detect from real data where possible ("detected automatically ✓"); the optional ones (AI connector etc.) can be ⏭ skipped honestly or ✓ marked done, and anything reopens. Progress (n/total + bar) is always visible — on the page and as a badge on the ☰ "🧭 Setup guide" entry, which is how you start it any time. Per-account state in settings.setupGuide. Brand-new accounts (nearly empty, never seen it) land on #/setup automatically on first login.) v6.18 (scan polish + conventions (note_589 #10–13) — (#12) transcription is now a CHOICE: after a scan saves, pick 🤖 My AI (recommended, handwriting) or ⚙️ Built-in reader (Tesseract.js, lazy-loaded from CDN only when chosen, runs right now, best on printed/neat writing); accounts with no AI connector get the built-in by default (heuristic: no minted connector URL). (#13) every note image gets a 🗑 Delete image button in the popup (PATCH accepts attachments) — clear the photo once it's transcribed. (#11) every apps + web project seeded with a pinned 🔮 Future Updates list (9 added; Kosmos + FLIPPER already had theirs) — the parking-lot convention is now universal. (#10) icon language: 🕐 to-dos (they're scheduled items) · ✅ lists — swapped on the Quick-Add tabs, category pill, and the project To-dos card.) v6.17 (📷 scan-a-note (todo_582) + phone quick-add tabs (note_589#9) — (582) photograph a handwritten note (☰ → 📷 Scan a note; capture="environment" opens the phone camera) → downscaled client-side (≤1600px JPEG so the connector can carry it) → stored via the existing attachment pipeline on a note labeled scan-import ("📷 Scanned note — awaiting transcription", lands in the Inbox). The connector's ai_worklist now returns those as scans, and the new read_attachment tool (24 tools) hands the image back as real MCP image content — any vision-capable assistant SEES the photo, transcribes it, and writes it onto the note with update_note/add_items; once text lands the note leaves the queue. Works with the twice-daily co-work run automatically, or on demand. Assistant-agnostic by design — MCP image content is the open standard, no OCR vendor. notes POST accepts attachments. (589#9) the five Quick-Add tabs overflowed on phones — labels hide ≤640px, icons go bigger (.qcl spans).) v6.16 (📅 calendar integration (todo_595) + edits-list retirement — (595 out) every account gets a private ICS feed URL (profile page → 📅 Calendar feed; token-auth like the connector, kical_ tokens in the accounts registry, GET /ics/<token> on the worker, regenerate-to-revoke) that Google/Apple/Outlook subscribe to — open dated to-dos (☐-prefixed, project-tagged) + milestones as all-day events, generated by the new shared lib/ics.js (buildIcs). (595 in) connect external calendars INTO Kosmos: 📅 Calendar → 🔗 Feeds manages settings.calendarSubs ICS URLs; GET /api/calsubs (shared lib/api.js — Worker + Node both have fetch) pulls + parses them (parseIcs, −60d/+400d window, recurring shown on start date) and the month grid + day panel render them as sky "external" events. OAuth two-way Google connect + sign-in-with-Google/Apple parked on 🔮 Future Updates (ties to the Appolis one-login, todo_594). (edits retirement, Tyler's directive) the generic "<Name> edits" lists are GONE — every app/game/site project now runs on exactly To-dos · 🐛 Bugs · ⚙️ Function changes · 🎨 Design changes: Quantum Flip's 19 open items classified + moved (14 function / 5 design), its 99 checked items preserved in an archived "…(history)" note; Kosmos's open item moved; the three empty seeded edits lists deleted. Web projects share the identical structure (bugs since v6.4, change lists since v6.15).) v6.15 (🗓️ scheduled to-dos + Quick-Add restructure + notes⇄lists (todo_598, note_589#8) — (598a) Scheduling the bot: a dated to-do is a SCHEDULED item — the connector's ai_worklist now only returns flagged to-dos that are due (today or undated, per settings.timezone, default America/New_York); future-dated ones come back as ai_scheduled_later with a leave-it hint, so you can book tasks for specific days knowing the twice-daily scans respect them. (598b) Quick Add = 5 types: ✅ To-do · 📝 Note · ☑ List · 🐛 Bug · 🚀 Project (last, per Tyler) — List turns each line into an item and targets an existing list (combobox, auto-suggested) or creates a new one; Project takes a name + section (+ app/game type) and jumps to the new tile. (598c) Notes ⇄ lists: two distinct things, transformable — note modal "⇄ To list" (each line becomes an item) / "⇄ To note" (items become lines, ✓-prefixed if done); kind PATCHable (guarded to note|checklist). (598d) every apps/web project seeded with 🎨 Design changes + ⚙️ Function changes lists (labels design-changes/function-changes) — 🤖-flagged items on them surface via ai_items as always. (589#8) today() was UTC, so after ~8 PM Tampa the date pickers pre-filled tomorrow — now local-date.) v6.14 (🧹 Fable audit pass over the v6.5–v6.13 batch (note_589#7 + archived leaks + dead code) — (589#7) the feature-example "Try it →" buttons never navigated (dropOverlay()'s back-state consume cancels the just-set hash) and Tyler judged the popup alone sufficient — removed. Archived leaks: archived projects were still counted/shown in the pipelines overview (viewPipelines), the stage tiles (viewPipeline), the per-stage views + switcher counts (viewStage), client pages (clientProjects — also fixes client open-todo counts), and the rate calculator's video-project list — all now filter !p.archived. Deliberately NOT filtered: edit-dropdowns (an item on an archived project must still render its selection), youEfficiency shipped stats (archiving a shipped project shouldn't erase your stats), and search (a door back to archived). Dead code swept: qcApply (chips replaced by direct pre-fill in v6.8), specialPanel + addSpecialItem + the sp-bugs refresh block (the separate Bugs panel died in v6.7; specialList stays — Quick-Add bugs use it).) v6.13 (collapsible working-list cards (note_589#6) + Your-Kosmos upgrades (note_585#1) — 589#6: every card in the "Working lists & to-dos" grid (workListCard + todosCard) is now COLLAPSIBLE and starts COLLAPSED (header + progress bar only); tap a header to expand (EXP_WL set + toggleWlCard, which re-renders just that card); the ↗ button still opens the full note. Keeps the section compact even with many lists. 585#1 (closed): the "What Kosmos does for you" bubbles are now CLICKABLE (YOU_FEATS module list + openFeatureExample → a modal with a concrete example + a "Try it →" link where a page exists), and a new youEfficiency() stats row shows projects shipped + avg days idea→shipped + fastest, next to the momentum chart.) v6.12 (🗂️ To-dos inside the working-lists section (note_589#5) — on the project view the To-dos list is no longer a separate panel stranded in a column below the working lists (which left dead space on desktop). It's now its own card (todosCard) in the SAME auto-fill working-lists grid, right beside the lists — the panel is retitled "☑ Working lists & to-dos". New addTodoCard (adds with due = today); refreshProjectPanels re-renders the card in place. The left column below now holds just Notes; Next steps / Playbook / Activity stay on the right.) v6.11 (🖼️ upload-your-own logo + to-do date defaults (finishes todo_593) — the icon picker now also takes an image upload (uploadIcon → /api/import/attachment → stores the returned path in a new logo field on the project/client; the emoji icon is kept for <select>/<option> contexts). iconMarkup(entity) renders the logo <img> when present, else the emoji, across board tiles + project/client heroes + the clients list. Picking an emoji clears the logo. And new to-dos default their due date to today — the Quick Add + project add-row date inputs seed today() until you change it. logo added to the projects + clients PATCH whitelists. Fully closes todo_593.) v6.10 (🎨 Icon picker (todo_593) — the hero icon on any project OR client page is now clickable (.icobtn) and opens a picker: a curated grid of ~57 emoji options (ICON_OPTIONS) plus a type-your-own field; picking one PATCHes icon on the project (/api/projects/:id) or client (/api/clients/:id, whitelist already had icon) and re-renders. openIconPicker(kind,id) + setIcon.) v6.9 (momentum chart + note-scroll fix + Quick-Add bug AI (note_589 items 3 & 4, note_585) — (585) ✨ Your Kosmos momentum: the #/you page now has a weekly bar chart of notes captured + project milestones over a selectable 6 / 12 / 26-week window (youMomentum, account-scoped, from note.createdAt + dated events). (589 item3) Note-popup scroll: autogrow no longer caps the note textarea at 50vh — it grows to full content height so the MODAL scrolls (a capped textarea trapped the wheel/touch, leaving only arrow-key scrolling). (589 item4) Quick-Add bug → AI: the 🤖 "let the AI handle this" toggle now shows for the Bug type too (split into qc-airow, separate from the due-date row), and a bug logged from Quick Add carries its ai flag onto the project's bugs list.) v6.8 (🗄 Archive projects + smarter Quick Add — (592) Archive: projects carry an archived flag (project-page 🗄 Archive / ♻️ Restore button); archived projects drop off the board, the Projects count, the pipeline overview, and the stall/attention radar, and live in a 🗄 Archived view (☰ menu, route #/archived). (593) Quick Add pre-fills instead of chips: as you type, the best-matching project/client — and, for notes, the target list — are set directly on the fields (only where you have not chosen), with an "✨ Auto-filled — adjust if needed" hint, replacing the old tap-chips. (594) Context pre-fill: opening + Quick Add from inside a project (or client) view pre-selects that project + client and locks them from the auto-scan. QC_TOUCHED tracks manual/context choices so the scan never overrides you.) v6.7 (🖥️ Desktop project-view layout + wrapping inputs (note_589) — the project view was a wide half-empty left column beside a tall narrow 340px right stack, and the 🐛 Bugs list showed BOTH as a right-side panel AND (because it is a checklist note) in Working Lists. Fixes: Working Lists now span the FULL width (their natural home; cards auto-fill), the secondary panels sit in a balanced two-equal-column grid (left: To-dos + Notes; right: Next steps + Playbook + Activity), and the duplicate right-side Bugs panel is removed — Bugs lives only in Working Lists (created via + Quick Add → Bug or the connector, edited like any list item). The project to-do and working-list add-boxes are now auto-growing textareas that wrap (Enter adds, Shift+Enter = newline) so long entries are fully visible instead of scrolling off to the right.) v6.6 (🎯 Open-right-now upgrades + to-do↔list conversion — the project "Open right now" rollup now (1) starts collapsed (tap the header to expand; the open count + a 🤖 N "assigned to AI" badge show even when collapsed), (2) has editable items — tap any item for an editor (text, 🤖 AI-assign, done, delete, → convert to to-do); to-dos in the rollup open the to-do editor, and (3) shows a 🤖 AI-assigned indicator/toggle on every row. New openItem/saveItem/deleteItem/itemToTodo + an EXP_ROLL collapse set. todo_588 both directions: list item → to-do (existing move-to-project + new itemToTodo) and to-do → list item (new todoToList in the to-do editor, carrying the 🤖 flag). All account/connector-agnostic — the 🤖 flag + connector check-off work for any profile and any MCP-capable assistant, by design.) v6.5 (⚡ Quick Add v2 — the + Quick-add now does three types (✅ To-do · 📝 Note · 🐛 Bug), adds a client picker (attach to a client even with no project yet), auto-suggests project + client from what you type — plus a target list on the Note side — as clickable chips, and the Note type has a Title / add-to combobox: type a new title to make a note, or pick an existing list/note to drop the text straight into it (picking one pre-fills its project + client). Bugs land on the chosen project's 🐛 Bugs list. Backend: clientId on to-dos + notes; client pages roll up client-attached to-dos. Housekeeping: cleared 22 finished to-dos + checked off the fixed badge bug — the standing rule is to REMOVE finished items after finishing.) v6.4 (🌐 Web projects get the apps/games treatment — the 🐛 Bugs panel (and with it the full working surface: working lists + open-items rollup + 🔮 future-updates) now shows on WEB-section projects too, not just Apps & Games, so client websites with ongoing updates are tracked the same way. Added Flip My Life (flipmylifenow storefront) as a web project with a seeded working list — completes todo_581.) v6.3 (⚡ Quick-add + 🌙 schedule builder — (1) Quick create (todo_581): a floating + button mounted on the body (present on every view) opens a Quick-add modal to make a to-do OR a note from anywhere — segmented To-do/Note toggle, auto-growing textarea, optional project, and for to-dos a due date + 🤖 AI flag; posts to /api/todos or /api/notes. (2) Co-work schedule builder (todo_576): the #/cowork "② Build your schedule" panel lets each user pick a cadence (twice-daily / daily / weekday mornings / every 3h / hourly / custom cron), saved per-account in settings.coworkSchedule, and shows the exact cron + tailored Claude/other-assistant setup steps — Kosmos supplies the schedule spec, the user's own assistant runs it. (3) Fix: the app/game tile badge moved below the name into its own .stagekind row so the absolute ⚠ stalled badge no longer overlaps it (note_589).) v6.2 (🎮 Apps vs Games + working-list scaffolds — the Apps & Games section now distinguishes 🚀 apps from 🎮 games: every project carries a kind shown as a tile badge and editable on the project page (App/Game dropdown), and the connector's create_project accepts kind. Following the Quantum Flip reference layout that Tyler likes, every app/game is seeded with a working list ("<Name> edits") so its page is a ready working surface — the same structure Quantum Flip has — instead of empty panels.) v6.1 (🤖 flag ANY checklist item for the AI — the 🤖 toggle now lives on every working-list item (project working lists + Lists hub), not just to-dos and bugs; wlToggleAi stores ai on the item, the AI work queue + connector ai_worklist now return a general ai_items list of every 🤖-flagged unchecked checklist item across all lists. Added an "AI co-work" slide to the deck so the slideshow reflects the flag→queue→background loop.) v6.0 (🌙 Background co-work guide (#/cowork, ☰ menu) — an assistant-agnostic, Claude-first setup guide: ① connect your AI connector (links to profile), ② schedule a recurring run, ③ a ready-to-paste COWORK_PROMPT (copy button) that tells the assistant to read ai_worklist, complete what it can, mark it done, and report. Kosmos supplies the queue + the guide; each user points their own assistant at their connector and schedules it. The completion of todo_558.) v5.9 (🤖 AI work queue (#/ai, ☰ menu with a live count) — one surface with every 🤖-flagged to-do and bug across all projects, grouped by project; the co-work view that mirrors the connected AI's ai_worklist. Empty-state until you flag something; each item stays actionable (check off / unflag). The Kosmos-side of "background co-work" — a scheduled Claude session works this same queue via the connector.) v5.8 (🤖 Bugs get the AI toggle + one-source version stamping — the 🐛 Bugs list now carries the same per-item 🤖 toggle as to-dos (wlToggleAi stores ai on the bug item; the connected AI sees them in ai_worklist as ai_bugs). And the version is now single-sourced everywhere: config.version → the ☰ menu, the /breakdown HTML (runtime), the cinematic deck (features.html carries a __VERSION__ token that build-deck.js stamps at build time), and the PPTX — so the deck can never again show a different number than the app. APP_BREAKDOWN's build line is bumped in lockstep. Changelog history entries stay per-version.) v5.7 (convert-cleans-the-note + ✨ Your Kosmos — breaking a note line into a to-do (✂ break-down) now REMOVES that line from the source note, so nothing lives in two places (esp. 🔮 Future Updates lists); checklist → drops the item, text note → drops the line, extract-all keeps only the checked items. ✨ Your Kosmos page (#/you, ☰ menu, every account): a personalized "what Kosmos has organized for you" — real per-account stats (notes captured, projects/shipped, things checked off, sources pulled together, inbox/clients/areas/pipelines) + plain-language capability tiles, no house rules/build internals. The full dev deck (Features slideshow) + Live breakdown doc are now super-admin-only — members never see how it's built.) v5.6 (🤖 AI-per-todo + editable to-dos everywhere — every to-do now has a 🤖 toggle (mint = flagged for the AI); flip it on any to-do and the connected AI sees it via the ai_worklist tool (now returns ai_todos), so the separate "🤖 Changes for AI" list is retired — queue_ai_change + add_todo(ai:true) create AI-flagged to-dos instead. To-dos are now fully editable from anywhere they show (project page, client page, and the calendar/timeline — the old dead spot): tap the text → editor modal (text, due date, project, 🤖 AI flag, done, delete). New DELETE /api/todos/:id + ai field on todos. Bugs list stays; the leftover test-junk AI note was removed.) v5.1 (notes carry a direct CLIENT assignment — note-modal Move row + client-page rollup; Claude AI-triage pass filed all 117 low-confidence inbox notes with reasoned suggestions, low 117→2.) v5.0 (📤 sharing · 🧠 per-account methodology · polish batch — (same-day patch) connector-handshake fix: claude.ai's add-connector OAuth discovery probes (/.well-known/oauth-protected-resource etc.) were getting the SPA fallback's 200+HTML, so it believed a sign-in service existed and failed registering with it ("Couldn't register with KOSMOS's sign-in service", ref ofid_…); the worker now answers /.well-known/ + /register with a clean JSON 404, and an invalid connector token returns 404 instead of 401 (401 is the MCP cue to launch OAuth) — the connector now adds as a no-auth server, the token in the URL being the whole handshake. (patch 2) "no tools available" fix: JSON-RPC messages with id: 0 were treated as notifications (empty 202), so claude.ai's tools/list — which counts from 0 — came back toolless; a notification is now strictly a message with NO id member. Verified with claude.ai's exact handshake sequence: 18 tools. Sharing: send a copy of any note (modal 📤) or whole project (page 📤 — with its notes, images, open to-dos) to another Kosmos account; the one cross-account door, sender-initiated, copy-only (GET /api/share/directory, POST /api/share); shared notes land in the recipient's inbox tagged shared:<name>. Per-account pipeline intelligence: methodology moved OUT of shared config into each account's own settings.stagePlaybook — the house's way of working ships to nobody (configView strips it); each account's playbook starts empty and builds via the ▶ panel's "+ Add to my playbook" or the connector's new add_methodology tool (18 tools); root's data was seeded with the house methodology (his own brain). Connector rebranded AI connector — works with Claude or any MCP-capable assistant. Polish: browser/phone BACK closes the note popup (history-state pattern) instead of leaving it stranded; popups fit the screen (no sideways scroll, ✕ always visible, sticky header); global fit pass (overflow-wrap, min-width guards); inbox suggestion chips now unmistakable (● HIGH solid green / ● MED solid blue / ○ low gray); board redesigned calmer — stalled/overdue collapsed into one ⚠ attention tile → #/attention, every section collapsible (state remembered); page scroll locks under the open ☰ menu; /pf/ project links remapped to the right docs-hub docs (flip-cms→flipper etc., mission-control→its own /breakdown + deck).) v4.9 (▶ Pipeline intelligence, phase 1 — every work section's pipeline now carries a stagePlaybook in config.js: 2–3 methodology steps per stage distilled from how projects here actually shipped (apps: house rules/docs-sync/clean-env e2e/custom-domain-on-worker…; video: rate-calculator bids/Studio storyboards/picture-lock-first; plus web, inventions, business). Surfaces twice: a ▶ Next steps panel on every project page (per its current stage, one tap queues a step as a to-do, already-queued steps marked) and the connector's next_steps tool (17 tools now) so any connected chatbot plans against the same methodology. Deck + changelog synced same-version per the docs-sync rule.) v4.8 (📽 docs-sync + one-version + 🔎 by-source — the features deck was silently stale (a manual copy in public/assets/ from v2-era) and had no way back to the app: deck fully regenerated for v4.8 (new slides: accounts/Studio, Claude connector, universal import + working lists, full-changelog slide, updated finale), a ← Kosmos back button joins the deck controls, and scripts/build-deck.js now stages features.html + features.pptx into public/assets/ automatically so the served deck can never drift again. DOCS-SYNC RULE (now standing, every app): APP_BREAKDOWN.md ↔ live HTML breakdown ↔ features deck all regenerate together, same version, full changelog. One version, one place: the floating corner badge (which carried a second, different number — v1.8.0 vs v4.7) is gone; config.js version is THE version, shown once in the ☰ menu with its date; the refresh banner stays. 🔎 By source (☰ menu): everything visible, filterable by which chatbot/platform it came from (🤖 Claude, 📒 Keep, 📓 Notion, 🐘 Evernote, 📄 files/paste, ✍️ made here) × status (still open / finished).) v4.7 (🔌 Claude connector + 📥 universal import + 🎯 live working lists — three big pieces: (1) MCP connector: every account gets a private connector URL (profile page) to add on claude.ai → Claude reads/writes that account's Kosmos live over 16 tools (overview, get/create project, set_stage, notes/lists CRUD, check_item, todos, open_items, import_notes, events, search) — new work started in Claude gets pipelined in, finished work gets crossed off, all hard-isolated per account (lib/mcp.js, POST /mcp/<token>, regenerate-to-revoke). (2) In-app import (#/import, every account): Google Keep Takeout zips (with photos), Notion export zips, Evernote .enex, markdown/text, JSON, pasted text — parsed in the browser (own zip reader + DecompressionStream), posted to /api/import/notes, deduped by the same content hash as the CLI importer, images to the account's own space. (3) Project pages became working surfaces: a 🎯 "Open right now" rollup of every unchecked item + open to-do (checkable in place), working-list cards with progress bars, inline check-off, quick-add; plus a 45s data poll (+on tab focus) so changes from any device or from Claude appear without a manual refresh. Fable review fixes: master-password login now requires root's own email (or none); /f/ share links pass through identity-free; deleting an account also purges its att: images; Accounts panel no longer inlines emails into onclick JS. Badge → v1.8.0.) v4.6 (⚙️ Edit profile — every signed-in account gets a self-service profile: change name + email, and (members) change their own password with a current-password check; the owner account's password stays the deploy PASSWORD secret. In the ☰ menu: the account chip is now clickable, plus an "Edit profile" item. GET/POST /api/profile + POST /api/profile/password. Badge → v1.7.0.) v4.5 (👥 Multi-account — Kosmos is now a multi-tenant product: admin-created accounts (no public signup), each a fully isolated space with its own board/notes/lists AND its own Studio brain (t_kosmos_<id>). The single shared-password gate became email+password accounts (lib/accounts.js: PBKDF2 hashes + HMAC session tokens carrying the accountId). Root (Tyler) signs in with the deploy PASSWORD master secret and keeps the legacy KV keys (db, att:…, tenant t_kosmos) so his data + pull/push tooling need zero migration; members get namespaced keys (db:<id>). Super-admin 👥 Accounts panel (☰ menu) to add/reset-password/delete people. Isolation verified end-to-end: a member's space is empty of root's data and vice-versa; members are blocked from admin (403). Badge → v1.6.0.) v4.4 (🎬 Studio over the binding — Kosmos now has a full creative Studio (AI tools, 7-step generator pipeline, video editor, My Files) served by phantasia-engine over a Cloudflare service binding, in its own tenant t_kosmos — separate tools, pipelines, house references; nothing shared with FLIPPER or the standalone. The worker pure-proxies the UI bundle + API (no local copy → it can't drift from Phantasia) and stamps Kosmos's identity; a violet 🎬 Studio nav item opens it, gated by the same Kosmos password. Badge → v1.5.0.) v4.2 (final logo — the "cosmos of work" mark: a spiral of gold work objects (camera, gears, film reels, storefront, blueprints, tools) resolving into a bright mint star at the core, on deep navy. GPT Image 2, ref-anchored on the round-6 winner; master in public/icon-512.png / apple-touch-icon.png; used as favicon, PWA icon, header mark, and login mark; the old temple icon.svg is retired. v4.1: design pass — tile grids use centered auto-fit so sparse sections sit centered instead of hugging the left edge; list item text wraps (no sideways scroll); ➜ move-item-to-project on every checklist item in the note modal (creates that project's to-do + removes it from the general list, one tap); scripts/split-lists.js dry-run/apply tool for bulk splitting — high-precision word-boundary matching only, because loose matching produced false positives ("Jordan"→Dan) on real data. v4: work sections + clients + rate calculator — every kind of work gets the Apps & Games treatment: 🎬 Video Projects (Brief→Quoted→Pre-Prod→Shoot→Post→Review→Delivered, stalls on Quoted/Review), 🌐 Web Projects (Idea→Scoped→Design→Build→Review→Live), ⚙️ Inventions (Spark→Research→Design→Prototype→Protect→Launch), 🏛️ Future Businesses (Vision→Research→Plan→Funding→Buildout→Open; seeded with The Vegan Patriot 🦅) — all config-driven in config.js sections[], identical tile→stage-tile→per-stage flow, per-section stall radar, #/pipelines overview from the Projects stat. 🤝 Clients tab: 8 seeded clients/brands (FML, AWS, PermaSafe, Grizzly, Dan, Flood, Sasquatch, Spartan-internal), projects carry clientId, notes link via labels, client pages roll up projects+notes+todos. 💰 Video rate calculator (#/calc): Tampa Bay rate card in db.settings.rateCard (editable in-app, PATCH /api/settings), shoot/crew/gear/deliverables/travel/rush/discount → itemized bid, copy or save-as-quote-note onto a video project; modernized from the old Video Shoot Price Calculator sheet (length-tier + crew + drone model kept). Fable audit: login brute-force throttle (10 fails/10 min per IP → 429), inbox reassign chip updates without scroll-jump re-render, scripts/pull-db.js/push-db.js guard KV↔local drift (pull → migrate → push; push validates + quotes paths). v3.3: notes are now fully editable in the modal — title, body, and checklist item text with autosave-on-blur, add/remove/toggle items; logo redesigned to read "order out of chaos" — a mint Greek temple whose columns dissolve into a swirl of gold chaos particles at the base. v3.2: mobile + identity pass — Greek temple logo/app icon (public/icon.svg → icon-512.png/apple-touch-icon.png, manifest.webmanifest PWA), hamburger popup menu (sections on mobile + Live breakdown / Features slideshow / Log out, all labeled; desktop keeps top tabs and the menu carries just the tools), tagline in the header, and mobile overflow guards — no view exceeds the viewport at 375px. v3.1: list views hide completed items by default behind a "show N done" toggle — imported Keep checklists had up to 98 checked items burying the open ones. v3: content-first restructure — ✅ Lists hub turns every checklist into a working list; ✂ Break-down sends note lines out as real to-dos; #/projects pipeline as stage tiles → per-stage project tiles + stage switcher (no horizontal scroll); area pages group by label instead of one wall; tightened categorizer + keyword→area rules, 16 mis-filings auto-corrected. v2: inbox ✕ delete with undo. v1: Keep import ×2 accounts, board, areas, inbox triage, calendar, playbook, /pf doc door) · last updated 2026-07-16node:http server, JSON file store, vanilla HTML/CSS/JS front-end.F:\Claude Code\mission-control\assets/features.html — cinematic 11-slide feature showcase generated from this breakdown (fullscreen · arrow nav · PDF via print · features.pptx PowerPoint download, regenerated by node scripts/build-deck.js). Regenerate whenever this doc changes materially.The task/idea efficiency hub for everything in F:\Claude Code and beyond. Google Keep notes import
into a triaged Inbox, projects get tiles + pipeline pages, a calendar tracks due work, and a
playbook accumulates what past projects taught — so new ideas get a suggested path instead of rotting in a notes app.
House rules it follows:
/breakdown renders from real config+data with write-back edits.data/db.json, atomic writes)| Collection | What | Key fields |
|---|---|---|
areas | The 7 top-level categories | id, name, icon, color, order, desc |
projects | Tiles on the board | id, name, areaId, icon, tagline, stage, pinned, lastActivity, stalledSince?, stallReason?, links{folder, breakdown, deck, live}, summary |
notes | Imported Keep notes + manual notes | id, hash, sources[], title, text, listItems[{text,checked}], labels[], pinned, archived, createdAt, editedAt, links[], attachments[], kind(note/checklist), areaId, projectId?, triage{areaId, projectId, confidence, reason, confirmed} |
todos | Actionable items | id, text, done, due?, projectId?, areaId |
events | Calendar entries | id, date, title, kind(milestone/stage/event), projectId? |
playbook | Learned patterns + insights | patterns[{name, desc, seenIn[]}], insights[{title, detail}] |
invoices | 🧾 Money owed, per project (§9e) | id, rev, number, projectId, status(draft/sent/paid/void), client{}, from{}, currency, issuedAt, dueAt, termsLabel, lines[{title, description, qty, unitAmount (cents), kind(work/expense/credit), taxable}], discount{kind, value}, tax{mode, rateBps}, payments[] (append-only), publish{hubSlug, docId, versionId, url, contentHash}, stripe{}, recurring{}, audit[] |
Claude Code sessions can read/write db.json directly — that is the intended path for AI triage (no API key in the app).
🚀 Apps & Games · 🏢 Brands & Clients · 🎬 Creative Studio · 💡 Ideas · ✅ To-Dos & Lists · 🏡 Life & Home · 📚 Reference
Auto-filing (config.js): Keep label rules (high confidence — e.g. AWS → Brands + AWS Motor Club, Cooking → Life) →
keyword→project rules (medium — kept TIGHT, two-word phrases; loose patterns mis-file) → keyword→area rules (medium — e.g. Flip 7/FML → Brands, storyboard/b-roll → Studio) → checklists → To-Dos (low) → everything else → Ideas (low).
Nothing is silently final: notes stay in the Inbox until confirmed (singly, or per confidence tier in one click).
After tightening rules, node scripts/recategorize.js re-files unconfirmed notes only (confirmed filing is never touched) and prints the moves.
Idea → Scoped → Building → Built → Deployed → Live — click a stage on a project page (or dropdown in /breakdown) to move it; a stage event is logged and lastActivity updates. Projects sitting in Built/Deployed ≥ 7 days (config stallStages/stallDays) are flagged on the board — the playbook's "last mile" insight, observed on 2 of the 7 seeded projects.
scripts/import-takeout.js <zip|dir>…)Google Takeout → Keep. Normalizes title/text/listContent/labels/colors/pinned/archived/timestamps/weblink annotations,
copies image attachments to data/attachments/, skips trashed, dedupes across accounts by content hash (re-import = no-op),
auto-categorizes, lands everything unconfirmed in the Inbox.
First import (2026-07-13): two accounts, 533 raw → 520 unique kept (12 dupes merged, 1 trashed skipped), 27 images.
Confidence split at import: 315 high / 17 medium / 188 low.
Capture-completeness fix (2026-07-14): the content hash was title+text+items only, so every blank-bodied note (empty or image-only) collided to one hash and got dropped as a "dupe" — that silently lost 7 notes on the first import, 3 of them photos. Fixed: the hash now also includes createdAt + attachment basenames, so genuinely-distinct notes never collide while same-note re-imports still dedup. Audited both source exports against the fix: of the 12 originally-dropped, 5 were true duplicates (correct) and 7 were false collisions. Recovered the 3 image notes (the other 4 were fully blank — no title/text/items/images/labels — intentionally left out); their images were already in KV. Existing note hashes were migrated to the new scheme so a future re-import stays a no-op. DB: 520 → 523, image notes 17 → 20. List-item capture was already complete (the item-count delta was entirely inside the 5 true-duplicate checklists). Next: in-app, per-account import (upload Takeout/other sources in the browser, into your own account) — the multi-source piece of the multi-account vision.
#/ — stat strip (all four tiles navigate: pipeline / notes / inbox / lists), ⚠ needs-attention strip (stalled + overdue), project tiles (stage pill, pipeline bar, note count), area cards with peek lines (To-Dos card opens the Lists hub), coming-up list.#/projects — stage tiles in the same tile-flow as the board (one tile per stage: count, ⚠ tally, project peek). Click a stage → #/stage/<key>: that stage's projects as full project tiles + a stage-switcher strip to hop to any other stage without going back. The Projects stat tile lands here. No horizontal scrolling.#/lists — content-first: every checklist note renders as a working list (check/add/✕-remove items inline, progress bar), grouped by area; loose to-dos grouped by project with quick-add. The nav's ✅ Lists. Completed items are hidden by default (a "▸ show N done" toggle reveals them); checking an item tucks it into the collapsed done section so the list stays focused on what's left. Same behavior in project-page task lists and the project to-do panel.#/area/<id> — notes grouped into collapsible label sections (largest first, Unlabeled last) instead of one wall; archived toggle, + note.#/project/<id> — hero with quick links (Live / 📋 Breakdown / 🎞 Deck / copy path), stall banner, clickable stage stepper; content organized as ☑ Task lists (interactive) + 📝 Notes & ideas (readable rows with ✂ break-down shortcut), to-dos with due dates, matching playbook patterns, activity log. Hero actions include 🛖 Publish Hub and — on every section except video — 🧾 Invoice (§9e), which drafts from the work already recorded and publishes a read-only copy to the client's hub.#/inbox — every question waiting on you (❓ decision forms, ❓ question to-dos, 🚪 hub join requests), all at once, each opening as a form; ✅ answered forms fold below as history. The same list renders as the tray at the top of the Board and as the Hub/rail count (v8.93.0).#/triage — the unfiled notes (this was the Inbox until v8.93.0): suggestion chip (area → project · reason) per note, per-note re-file dropdowns, accept-all per tier, ✕ delete for non-notes (undoable; skips the pipeline entirely).sourceNoteId), or all lines at once. Promote-to-project stays for whole notes.#/calendar[/date] — month grid (mobile: dot markers), day panel with quick-add (event/milestone/due to-do).#/playbook — insights, patterns with project chips, the pipeline definition./breakdown 📋 and the deck 🎞.startVersionWatch() records the /app.js asset ETag at load and re-checks it every 5 min + on tab focus; when a deploy ships new code the ETag changes and a persistent "✨ A new Kosmos version shipped · Refresh now" banner pops (on document.body, outside #app, so it survives re-renders) — never run a stale build. Mirrors FLIPPER's refresh chip.| Route | Does |
|---|---|
GET /api/db | full state + live config |
POST/PATCH /api/notes[/:id] | create, edit, move, pin, archive, confirm |
DELETE /api/notes/:id · POST /api/notes/:id/restore | delete junk outright (sits in db.trash, last 100 — Undo in the toast) |
POST /api/inbox/accept | bulk-accept by confidence tier |
POST/PATCH /api/projects[/:id] | promote idea → project; set stage (event log + stall bookkeeping) |
POST/PATCH /api/todos[/:id], POST/DELETE /api/events[/:id] | to-dos, calendar |
GET/POST/PATCH/DELETE /api/invoices[/:id] + /:id/{redraft,mark-sent,mark-paid} | 🧾 invoices (§9e) — every edit carries the rev it read; a mismatch is a 409. DELETE /api/invoices/:id hard-deletes: drafts with no ceremony in the shared handler, and — on the deployed Worker's own route, registered above the generic branch — an issued invoice too, after cancelling its Stripe subscription and taking the client's copy off the hub. Refused while money is held (calc.deleteBlockers); anything numbered or ledgered needs rev and confirm = the invoice number. GET /api/invoices lists the account's invoices (no query filters; the SPA already has them from /api/db and filters client-side) |
GET /api/invoices/:id/preview | the read-only client document, rendered from the record (the exact bytes publish pushes) |
POST /api/invoices/:id/{publish,unpublish,void} | push/withdraw/void-and-restamp the client's copy on the project hub — Worker only (needs HUB_DO + R2). Locally, publish/unpublish 501 and void falls through to the shared record-only handler |
GET /api/invoices/stripe/status | is Stripe connected? The one door the UI asks before drawing any payment affordance — answers cleanly with no keys, never calls Stripe, never throws |
POST /api/invoices/:id/{stripe-link,pay-link} | arm (or rotate) the Pay button — mints the capability token and the stable /pay/ URL; Worker only, 501 not connected without keys |
POST /api/stripe/webhook | 💳 Stripe events. Unauthenticated by design — registered above the session gate because Stripe sends no cookies; the HMAC signature over the raw body is the auth. Body capped at 256 KB, ±5 min replay window, v1 signatures only |
GET /pay/:id?t=… | the client's payment door — no session, the capability token is the gate (re-checked against the record, so rotating or voiding kills it). Mints a fresh Checkout Session from stored truth on every click |
GET /breakdown | live breakdown (renders from db+config, stage dropdowns write back) |
GET /pf/<project>/<path> | serves any project's files from its folder — .md rendered dark-themed, decks resolve relative assets. The one door to every breakdown + deck. |
GET /attachments/, /assets/ | note images, this app's deck |
Quantum Flip (live) · FLIPPER/Flip CMS (live) · AWS Motor Club (building) · TipLift (built) · REW Discount Locker (live) ·
Flip Fraud Cleanup (deployed, stalled — awaiting store install) · Rivo Sub Apply (built, stalled — awaiting deploy + setup) · Mission Control (building).
Playbook patterns seeded: Cloudflare Worker + OAuth-in-KV (×4) · living breakdown + paired deck (×3) · zero-dep OOXML deck export (×3) · mock providers first (TipLift) · branded team-update hub (Rivo).
Deployed 2026-07-13. Same codebase runs two ways with no drift — data handlers live in lib/api.js, shared by both:
node server.js → localhost:3900 (file store data/db.json; auth only if PASSWORD env set).worker.js on Cloudflare (kosmos.appolis.app, custom domain, auto DNS+cert). Store = single JSON doc in KV (db); note images in KV (att:<name>); static shell + deck via the ASSETS binding.lib/auth.js) — HMAC session cookie, PASSWORD secret. Fail-closed: data routes 401 until the secret is set. Static shell is public (no data); all data (/api/, /breakdown, /attachments) is gated. /pf/ redirects to docs.appolis.app (no local disk when hosted).npx wrangler deploy · Set password: npx wrangler secret put PASSWORD (applies immediately, no redeploy) · Re-seed data: wrangler kv key put db --path data/db.json --namespace-id <id> --remote.KOSMOS = a5eb218f104144a19e33754d5fd69a01. Config: wrangler.jsonc.Kosmos is multi-account (v4.5). Every account is a completely separate Kosmos — its own board, notes, lists, calendar, clients, playbook, AND its own Studio brain — with zero cross-account bleed (the hard requirement).
acc_root) adds each account by email + a temporary password in the 👥 Accounts panel (☰ menu). People sign in; they can't register themselves.lib/accounts.js, shared by Worker + local server): PBKDF2-SHA256 password hashing (per-account salt, 100k iters); the session cookie is an HMAC-signed token carrying {accountId, role, exp}, signed with the deploy secret so it can't be forged. Login POST /api/login {email,password}; GET /api/me reports the signed-in account.PASSWORD master secret (email optional) and keeps the legacy storage keys — data doc db, attachments att:<name>, Studio tenant t_kosmos — so nothing migrated and pull-db/push-db keep targeting db. Members get namespaced keys: data db:<accountId>, attachments att:<accountId>:<name>, Studio tenant t_kosmos_<accountId>.kvStore(env, accountId) picks the per-account KV doc, and handleApi() only ever touches the store it's handed → every data path is scoped by construction. The Studio proxy stamps x-studio-tenant per account, so each account's AI tools/pipelines/house-refs/files live in a different Phantasia brain. Verified: root and a test member each see only their own notes; member /api/admin/* → 403.GET /api/admin/accounts · POST /api/admin/accounts {email,name,password} · POST /api/admin/accounts/:id/password · DELETE /api/admin/accounts/:id (removes the account + its data doc; root can't be deleted here — it's managed via the secret).GET /api/profile (returns the account + rootManaged), POST /api/profile {name?,email?} (email uniqueness-checked), POST /api/profile/password {currentPassword,newPassword} (members only — verifies current password; a wrong current password returns 400, not 401, so it doesn't trip the login bounce). Reached from the ☰ menu (clickable account chip + "⚙️ Edit profile"). The owner account can edit name/email but its password is the deploy secret (rotate with wrangler secret put PASSWORD).data/db.json); adding people is a hosted-only capability.Kosmos has a full Studio — the same AI tools, 7-step generator pipeline, video editor, and My Files that FLIPPER and the standalone run — without shipping a line of that React app. It's served by phantasia-engine (the extracted Studio service) over a Cloudflare service binding, and Kosmos is its own tenant t_kosmos: separate tools, pipelines, house references, files, house keys, and settings. Nothing is shared with FLIPPER (t_verdant) or the standalone (t_spartan) — verified: a fresh t_kosmos starts with empty pipelines/files and its own seeded tool set.
How it works (worker.js):
wrangler.jsonc declares services: [{ binding: STUDIO, service: phantasia-engine }] + a STUDIO_KEY var (= phantasia-engine's INTERNAL_KEY, the handshake secret — not a user credential).isStudioPath() matches /studio, the hashed bundle /studio.<hash>.js, /vendor/, /api/studio/, /api/myfiles, /api/auth/, /f/, /report/. Those are forwarded to env.STUDIO.fetch() — Kosmos holds no copy of the UI, so it always serves Phantasia's current build. Everything else stays on Kosmos's own data API.proxyStudio() overwrites x-studio-internal/-tenant=t_kosmos/-user/-role=admin (the browser can't spoof them — they're always replaced) and drops Kosmos's cookie. The engine resolves via:'binding' and mirrors the profile; the header pill reads "Kosmos" (from the seeded tenant name)./f/ share links stay public (their own HMAC). /studio is requested as /studio-compiled.html upstream so the fast, hashed, self-hosted-React shell is always served (bare /studio collides with the engine's studio.html asset name)./studio.lib/shares.js)Kosmos keeps one owner per project (the line vs Agora's multi-owner boards) but the owner can grant live access to other accounts — not a copy (the old /api/share cloned the project into the recipient's isolated db; that's gone for projects, kept only for note-to-inbox pushes).
viewer (read) · contributor (+ add/edit notes, to-dos, list items) · manager (+ run the project: stage, rename, structure) · owner (+ delete, manage collaborators, finance — owner-only, never granted).project.shares = [{email, role, name, addedAt}] on the owner's project is the source of truth; keyed by email (works before the person is provisioned — an Appolis ID resolves on first login). Reverse index sharedto:{normEmail} KV → [{ownerId, projectId, role}] lets a collaborator's board find shared projects without scanning; it's re-verified against project.shares on every use, so a stale index can only under-grant./api/share/grant · /revoke · /list, owner-only). Directory lists existing accounts; any email is accepted too./api/db (live from each owner's board), each tagged sharedFrom {ownerId, ownerName, role}.api() auto-stamps ownerId on any write to a shared project (resolved from the merged sharedFrom); the worker verifies the caller's role (shares.roleAllows), routes handleApi to the owner's store, and passes the caller as actor so created notes/todos are stamped by:{email,name} (list items are stamped client-side). GET → any grant; content → contributor; project meta (PATCH) → manager; delete → owner-only.byTag chips on notes/to-dos/items; every owner/manage/content control is role-gated (server enforces too). Finance never rides a share.Connector (lib/mcp.js, hosted-only): stateless MCP Streamable-HTTP at POST /mcp/<token>. The per-account token (minted on first profile view, regenerate = instant revoke) resolves WHICH account's KV doc the session works on — a connector session is exactly as isolated as a login session. 49 tools (board/notes/todos/shots/storyboards, decision forms, kosmos_hub_ — §9d); the initialize instructions teach Claude the sync loop: overview first; when discussed work gets done → check_item/complete_todo; when new work starts → create_project/add_todo/create_note; import_notes for bulk*. Setup: profile page → copy URL → claude.ai → Settings → Connectors → Add custom connector. Writes stamp sources:['claude'] and land in Inbox unless a project/area is given; stage moves log activity events.
Import (#/import): browser-side parsing (zip central-directory reader + DecompressionStream('deflate-raw')), normalizing Keep Takeout JSON (incl. photos → /api/import/attachment, base64, 8MB cap, per-account KV), Notion markdown zips, Evernote .enex (inert DOMParser, <en-todo> → items), .md/.txt (checkbox lines → items), generic JSON, pasted text. Batches of 40 → POST /api/import/notes, which hashes with the SAME recipe as the CLI importer (createdAt+title+text+items+attachment basenames) so every import door is mutually idempotent.
Live sync: the app polls /api/db every 45s + on tab focus and re-renders when meta.updatedAt moved (skipped mid-edit) — so phone, desktop, and Claude-connector changes converge without manual refreshes. KV remains the single source of truth per account.
lib/hubs.js)Productized Free Will lander (spec appolis-hubs-build-spec.md v1.0): any project → a hosted, access-controlled hub at https://{slug}.appolis.app (wildcard *.appolis.app zone route → this worker; kosmos.appolis.app/h/{slug} is the path-form fallback). The page is publicly addressable but the content is gated.
⚠️ The.appolis.appwildcard route OVERRIDES sibling apps' custom domains (Cloudflare wildcard routes beat custom domains). So worker.js keeps anAPP_HOSTSmap ({host → service binding}) and forwards known app hosts (hermes/flipper→HERMES, agora→AGORA, phantasia→STUDIO, docs→DOCS, telemetry→TELEMETRY, landers→LANDERS) to their own workers before the hub branch; only unclaimed slugs reachhubs.handleHub. Any new.appolis.appapp must be added toAPP_HOSTS+ a Kosmos service binding, or the wildcard swallows it.
view (private default | unlisted rotatable ≥256-bit token, SHA-256-hashed | public) × action (members default | any_authed | off; action wiring is P1). Presets: Private · Client review · Free Will mode · Showcase. Every content response re-runs the check server-side; a private/missing/unpublished hub returns the same branded 404 gate (no enumeration), with no-store + noindex.hub:{slug} — slug IS the key, uniqueness structural; hubidx:{account}:{project} enforces 1 hub/project). Doc bytes in R2 (HUB_BLOBS = bucket kosmos-hub-docs) as immutable versions hub/{slug}/{docId}/{versionId}; replace-in-place flips currentVersionId, the URL never changes (live-doc doctrine). Access list = the hub's own members[] (Free Will crew-list pattern generalized — Kosmos has no project_members table).html docs are self-contained single files served at /d/{id}/raw with Content-Security-Policy: sandbox allow-scripts allow-downloads allow-popups inside a sandboxed iframe → opaque origin: author scripts + copy buttons work, but document.cookie throws and credentialed calls to appolis.app are unauthenticated (dogfood §13.7 confirmed live). note kind server-renders a Kosmos note/checklist read-only; link is an outbound tile.kosmos_hub_* tools in lib/mcp.js: hub_publish, hub_put_doc (inline HTML ≤200KB, else request_upload:true → tokened PUT that auto-finalizes; file kind always uploads; doc_id ⇒ new version), hub_set_access (+rotate_unlisted), hub_get, hub_remove_doc, hub_unpublish. Every publish/update auto-syncs the project's links.live + links.docs tiles → the 🎬 Docs & hub panel works with zero UI changes. Identity (accountId/accountEmail) rides both /internal/mcp and /mcp/<token> for ownership checks + ownerEmail.openHubWizard in app.js). A 🛖 button on every project page opens one modal that creates AND manages: slug/title/tagline/accent + preset picker on create; then live URL + copy, docs list (+Note / +Link / ⬆HTML / ⬆File, remove), two access axes + members + join toggle + roles, rotate secret link (shown once), unpublish. Drives thin authed /api/hubs/* HTTP endpoints in worker.js (for-project, get, publish, put-doc, set-access, remove-doc, unpublish) — the same lib/hubs.js functions the MCP tools call, using the logged-in account (Kosmos session or appolis_id cookie) as ctx.file kind + Range streaming. Audio/PDF/image/video (≤30MB) upload via the tokened handshake with their real Content-Type; served at /d/{id}/file with HTTP Range (206 + Content-Range, tail + suffix ranges, 416 guard), nosniff, and the file's content-type — never the sandbox CSP (bytes, not executable HTML). The shell plays them inline, same-origin (so a member cookie rides the byte request) — native <audio>/<video>/<img>, PDF in a frame, else a download tile. /d/{id}/raw refuses file docs so the sandbox contract stays HTML-only. Audio takes are reviewable right in the hub.action axis live: members (owner + member emails) / any_authed (any signed-in; acting auto-enrolls when join is on) / off. Join (join_enabled) → scoped Appolis-ID enrollment (kosmos only, the no-Phantasia rule) with a role → hub members + a hub-crew checklist on the project. Per-doc notes → hub-notes list. One-tap approvals → hub-approvals list. All identity-tied, written to the owner's board (so the tile + the AI over the connector see them), and mirrored into hub_events. Shell renders a join CTA + 💬 Note / ✅ Approve in the doc bar. (Every emitted shell script is string-ops only — no regex/backslash — after a template-literal escaping bug once syntax-errored the whole script and made tiles un-clickable.)mint_invite + invite_role on set-access) are role-carrying capabilities that grant view on any hub (bypass a private gate for onboarding) and pre-fill the join role; revoke_invites kills them all. Request access — the gate has a button (session email when signed in, typed when out) → files to a hub-requests list on the owner's board; the endpoint returns an identical {ok} for real vs nonexistent hubs (no enumeration). Wizard: 🎟 Invite link button; viewGrant recognizes ?invite=.doc.versions[] tracks them (capped at 12, older blobs deleted). hub_get exposes docs[].versions; restoreDoc (/api/hubs/restore-doc + kosmos_hub_restore_doc + wizard 🕘 button) re-points the live doc at a retained version at the same URL — no new version, so rollback is itself reversible. html + file docs.hub.stats: joins/notes/approvals/requests, bumped only on those low-frequency actions — never on views, which would be a write storm) + recent_events surface in the wizard as a stat strip + a recent-activity list. getHub returns stats + recent_events.studio7.appolis.app (Studio 7 Sessions — Genre Pack + Song 01), preset Private. Wildcard * DNS live. Remaining (P2): themes/more templates, custom domains, per-doc ACLs.A per-project invoice builder (v7.53, hardened in v7.54, typed in v7.55, fabrication-guarded in v7.57). 🧾 Invoice sits in the project hero next to 🛖 Publish Hub, owner-only,
and never on section video — that pipeline quotes and invoices through the rate calculator's quote sender.
The button is hidden there and POST /api/invoices refuses a video project, because the UI gate is cosmetic and the API is reachable.
thing it is. inv.type is chosen at create (POST /api/invoices { type }), editable while the record is a draft and
frozen once it leaves draft — same rule as the number, because the type is what selected the sources and relabelling
an issued document would describe work that was never gathered that way. Reading is one accessor, typeOf(inv), which
answers 'period' for any record written before types existed — that was what they were, so **no migration pass is run
over existing money records**; a backfill write across the one KV document that holds the whole board is real risk for a
cosmetic field. GET /api/invoices/types[/:projectId] serves the taxonomy from the file that implements it (plus the type
to pre-highlight: completion for a project at the end of its pipeline that has never been invoiced, period otherwise
— a suggestion, overridden in one click, never automatic).
| type | what it reads | shape |
|---|---|---|
🏁 completion | tagline + summary → one fee-shaped scope line, first; milestone/event rows → one dated line; every checked list including archived ones; checked 🔮 items (retitled — a ticked box is delivery, but that list's title is internal); to-dos; cowork. Stage arrows → the intro, and the period. All time, no date filter. | scope line on top, fat evidence lines beneath: price the top one and delete the rest, or price them all |
📆 period | exactly what shipped in v7.53 — live lists, done to-dos, cowork in the window | one line per list + tasks + build work |
🔁 retainer | the same sources, but as proof, not price | exactly one line; recurring{} pre-armed from the gap to the previous retainer |
🔧 maintenance | bugs / function-changes / design-changes / launch-checklist only, live lists; dated events → Updates released (a version bump is the deliverable) | fixes · changes · tasks · updates · sessions |
🚨 incident | cowork only — the one source carrying a real timestamp | one line per day; bug fixes offered in the report, never auto-added |
📍 milestone | stage arrows + milestones since the last milestone invoice | exactly one line, itemising nothing |
💵 deposit / ␀ blank | nothing at all | one empty line / none |
POST /:id/redraft takes a mode: append (the v7.53 behaviour — idempotent on source ids, never rewrites an existing line) or replace, which
throws away the lines the scanner drafted and drafts again for the invoice's current type. Lines the owner wrote by hand
are kept in place (source.kind === 'manual', the same predicate the editor counts with — the two must agree or the
editor counts a different set than the server removes), and the response carries replaced so an editor talking to an
older build can say plainly that it appended instead. Replace never overwrites an intro the owner wrote or a recurrence
they set. The type itself is never taken from the request: redraft reads typeOf(inv), so a completion invoice cannot
be topped up with a period scan.
configView puts invoiceTypes on DB.config, so the editor's picker offersexactly the types the scanner implements, in that order. The editor keeps the long “bills / leaves off” copy per key; the
server decides which keys exist. A UI offering a type the scanner has not got would draft the wrong invoice and say the
right word over it.
billedIdsFor(db, projectId, exceptInvoiceId) unions every lines[].source.ids on the project's non-void invoices; draftFromWork drops those records before grouping, so a
narrowed line's description is rebuilt from only what is left. There is no second ledger — the ids already are the
record of what went out, and a duplicate copy is exactly how two numbers drift apart (the same reason no total is stored).
A void invoice releases its ids (it billed nothing, so a mis-issue must not permanently delete real work); an open
sibling draft holds its ids so two drafts cannot bill one item, and discarding it releases them; a **rescan
self-excludes — and a scope line is sold once per invoice whatever the words currently say** (its id *hashes the
prose*, so editing a tagline or fixing a typo in the summary mints a brand-new id; the exact-id have set then read the
rewritten scope as never billed, and a rescan appended a second “full build and delivery” line, separately priceable,
onto one document). A replace redraft also keeps expense lines it was not asked to redraft — they are the project's
own cost record, not an answer to “what kind of invoice is this”, and only a scan given includeExpenses drafts them at
all, so sweeping them out silently deleted reimbursables the confirmation had promised would be drafted again.
This id set is what makes
“billed monthly all year, then a completion invoice” safe, and it is the only thing that could do it: list items and
to-dos carry no timestamp, so a date window can only ever hide them. The report names the invoice numbers the
withheld records went out on, so a gap is explainable instead of looking like a scanner bug.
scanWindow is not periodStart/periodEnd. scanWindow{from,to} + scannedAt record what the scan actually covered; periodStart/periodEnd are display fields the owner edits freely on the document. Keeping them separate is
load-bearing: if “since the last invoice” read the display fields, cosmetically retyping a period on a sent invoice would
silently change what the next one re-bills. The dated types default to *(newest scanWindow.to on this project) + 1 day →
today*, collapsing to a single day rather than printing a backwards window when a scan already ran today.
true, and the completion scope line's own title — “full build and delivery” — is a sentence **the drafter writes
itself**. Nothing stops a completion invoice being drafted for a project at vision, and this board holds two: one of
them a restaurant that does not exist. It produced a one-line invoice reading *“The Vegan Patriot — Future restaurant:
full build and delivery”* over a pitch paragraph, with nothing in the report to warn anyone. So the guard now covers the
words the drafter generates, not only the words it copies:
· delivered = the project's stage is the last stage of its own section pipeline, or one of live / deployed /
delivered / launched / shipped;
· not delivered and no other evidence at all → no scope line is drafted, and report.noScope says the description was
read as a pitch rather than a record. An empty invoice with a reason beats a confident invoice for work never done;
· not delivered but real evidence exists → the line stays and bills the evidence, titled “work delivered to date”,
never “full build and delivery”, and the unfinished stage is named in the reword flags;
· prose that still calls work pending / planned / upcoming is flagged for rewording — the drafter never rewrites it,
so the only honest move is to hand it back before a client reads a promise as a delivery.
A genuinely deployed project is untouched: Quantum Flip still leads with its full-build line.
board document; the only tokens it generates are separators, a date already stored on the record it describes, and
“…and N more.” project.summary therefore becomes one quoted line — splitting it into “R2 CDN pipeline” + “mobile
perf overhaul” + “telemetry” as if each were separately evidenced is the exact move that invents work, because the prose
describes the product, not the tasks. Nothing unchecked is ever billed on any type (an unchecked item is a request);
phases are never billable (a project at deployed did pass through building, but “it must have been built” is an
inference, not a record — so stage transitions write the intro and set the period and carry no money); origination rows
(“… created via Claude”, “… added to the board”, “… started”) are dropped, while a kind:'stage' row that is not the
<Name> → <stage> arrow form is read as the release note it actually is. source.count === source.ids.length on every
line. And nothing is silently dropped: skipped lists, records withheld as already-billed and lines carrying raw
internal wording (a leading [AI-worker] tag, an artifact URL, a vendor request id, **a local disk path a board record
quotes in its own text, or a �** left behind by text mangled long before it reached this file) are all named in the
draft report. Truncation is character-safe: slicing by code unit at the 220/2000 caps could cut an emoji in half, and a
lone surrogate survives into the stored record and renders as � on the client's copy.
project.links.breakdown is never read. It is a local disk path (F:\Claude Code\…\GAME_BREAKDOWN.md) and theWorker cannot open local disk, so any design that leant on it would work on localhost and quietly bill less in
production. The scan builds only from what is in the board document.
db.settings.rateCard is real but it is entirely a video/photoproduction card (half/full-day shoot, gaffer, drone, finished :30 cut, per-mile travel) — and the API refuses to invoice a
section:'video' project at all, so for the apps, games and web projects this builder serves there is zero rate data.
There is no time data to multiply either: a cowork event has a ts but no duration, a to-do and a checklist item carry
nothing. Any suggested number would be a number the app made up, printed on a financial document, for a real client. Two
honest assists instead, neither of which writes a price: shape (completion puts one fee-shaped line on top so pricing
the whole build is typing one number rather than forty) and context (the report may state what this project's own
prior invoices were actually paid — money that really moved, never a cost line, because cost is not price).
db.invoices[]); the owner's editor and the client'sdocument are independent renderings of it. The client copy is generated, never stripped — the mmc-florida prototype
made its client file by regex-stripping an editable one, which is one un-matched pattern away from publishing an editable
invoice, and kept its numbers in localStorage, which cannot satisfy “the client never sees a stale copy”.
lib/invoice-doc.js — the client's document. Self-contained HTML + CSS: no <script>, no contenteditable, no form controls, no localStorage, no external requests at all (system fonts, no CDN, no images — it is served into a
sandboxed opaque-origin iframe). Print CSS + @page for a clean save-as-PDF; the Pay button is hidden on paper while the
pay URL stays. Every interpolated value goes through esc(); newlines become <br> only after escaping.
test/invoice-doc.mjs (49 checks) asserts the inertness, the escaping, the money and the dates.
public/invoice-calc.js — the ONE money module. Dual-homed exactly like lib/lander-templates.js: the browser editor loads it as a script, node/the Worker require it. Money is integer minor units (cents) in storage; formatting to
$1,200.00 happens once, at the last render step; no total is ever stored. lib/invoice-doc.js and lib/invoices.js
both read it — there is no second implementation, and the dependency runs one way (calc requires nothing).
draft/sent/paid/void); overdue, part-paid and paid are computed from the append-only payments[] ledger and the due date, so they cannot go stale. A refund is a
negative payment, which walks an invoice honestly back to unpaid without anyone editing a status.
deliberately refuses to move a draft. So both payment doors (POST /:id/mark-paid and the verified Stripe webhook) call
markSent first: the invoice leaves draft and takes its number on the way through. Without that, a paid invoice sat at
draft with no number and shipped a client copy stamped Draft — money against a record that denied it existed.
Asserted in test/invoice-api.mjs.
rev it read; a mismatch is a409 carrying the server's copy, never a silent last-write-wins. Money records do not get to lose writes quietly. The
editor also adopts the server's normalised lines after a save: the server sanitises what it stores (clears
needsPrice on a priced line, clamps a qty of 0 back to 1), and merging only rev left the editor holding flags the
record no longer had — which made 🚀 Publish refuse forever, telling the owner to price lines they had just priced.
needsPrice marks a line the 🪄 draft created that nobody has priced yet, and itis the rule the publish gate enforces on both sides. Touching the rate box is the pricing decision — including typing
$0, so a line that really is free is publishable (it previously could never clear the flag and blocked publishing
permanently). The header badge, the draft report and the publish gate all read one predicate, invUnpriced(); they used
to disagree, with the badge counting “$0” and the gate counting the flag.
payments[] is append-only with no removal path, so a wrong sign is permanent. mark-paid defaulted to the outstanding balance — which on an invoice whose credits exceed its work (a
deposit larger than the final job: legitimate) is negative, so accepting the default recorded a refund nobody made
and burned a real invoice number doing it. The amount is now validated before anything is stamped: a defaulted payment
on a non-positive balance is refused, and a refund can never exceed what was actually received.
lib/invoice-publish.js, Worker only). The invoice becomes a tile on the project's existing hub (one hub per project is structural). hubs.putDoc is called with the stored doc_id, so a republish keeps the doc id, its
position and the identical public URL while the bytes go to a new immutable R2 version — live-doc doctrine, so the
client's link never rots and never shows an old copy; content-hash idempotence means an unchanged republish mints no
version, and 12 retained versions sit behind it for restoreDoc rollback. Order is load-bearing: hub write first
(CAS-protected, replayed by withHub), then exactly one store.save(db) carrying both the invoice's publish{} block
and syncLinks()'s project-links change — copied verbatim from /api/hubs/put-doc. Everything that must happen once
(number stamping, the audit entry) runs outside that replay loop. Publishing a draft is what sends it: the number is
stamped then, from a per-account year-keyed sequence in db.settings.invoiceSeq, so an abandoned draft never burns one.
Options: create_hub (opens one on the Client review preset — unlisted + a secret link) and view_access. Publishing to a
still-private hub returns a plain warning rather than silently handing the client a 404. unpublish withdraws the doc and
leaves the status alone — an unpublished invoice is still owed. GET /api/invoices/:id/preview renders the same bytes for
the owner, and test/invoice-publish.mjs (55 checks) asserts they are byte-identical.
listed:false is refused, and that is a correction. v7.53 offered it as “publish it without putting it on the hub
grid”, on the belief that a detached tile still served at its own URL. It does not: lib/hubs.js answers /d/:id/raw
with a 404 for doc.hidden (“a detached tile is OFF the hub — its content stops serving too”), so hiding the tile locked
the client out while the record still claimed to be live at a URL. It was never a privacy control. The real controls
are the hub's view axis (unlisted = a secret link) and, for a client who must not see the rest, their own hub. The test
that “passed” only ever compared URL strings; it now fetches the document through the real hub router.
project's hub and to anyone holding its unlisted link. Publishing returns an audience block (view axis, member count)
and the editor says so plainly after publishing, because that is a disclosure decision the owner should make rather than
discover. (A per-document ACL is still the honest fix and remains a P2 gap in the hub spec.)
on saying Total due with a live Pay button under it — the one invariant this feature exists to hold, failing in the
direction that costs money. POST /api/invoices/:id/void therefore runs on the Worker beside publish (it needs the
hub DO + R2): it voids, clears the pay token, and republishes in place at the identical URL so the client's own link
now reads Void / Invoice total (cancelled) / Nothing is due, with no Pay button. Republish rather than withdraw is
deliberate — a withdrawn doc would just 404 at the client instead of telling them it was cancelled. If the hub write
fails the void still stands and the response says the copy is still live, so the editor can offer to take it down. The
local dev server keeps the plain record-only void (it has no hubs at all) and reports stillPublished honestly.
them was the second half, missing. applyStripeEvent banked the money and nothing touched the document, so a client who
had just paid still saw Total due under a working Pay button — a double-payment invitation. Four doors now end in the
same pushDoc(), so the client's bytes come from one renderer through one door: publishInvoice (the owner, and the only
one that SENDS), voidPublishedInvoice, settlePublishedInvoice (a payment the owner recorded by hand) and
republishLive — the unattended one, called from the Stripe webhook and from the pay-link route.
· What triggers it, and what deliberately does not. Money landing (checkout.session.completed **with
payment_status:'paid'**, checkout.session.async_payment_succeeded, invoice.paid) and money coming back
(charge.refunded) republish, because those are the only events that move a rendered figure. An ACH debit merely
initiated, a failed debit, dunning, an expired session, a dispute and every customer.subscription.* mirror write only
fields lib/invoice-doc.js never reads, so republishing on them would be a guaranteed content-hash no-op — a hub load and
save per billing event for nothing. The table lives in lib/invoices.js beside the switch that applies the events
(touchesClientCopy), so the two cannot drift.
· 🚫 It never publishes a draft. The gate is a single positive condition — publish.state === 'live' — never a
negative check on status. Publishing is what sends an invoice and stamps its number, so auto-publishing a draft would
mail a client an invoice nobody chose to send and burn a number. The same condition structurally excludes every recurring
child receipt (mintChild leaves them unpublished): a webhook must never send a brand-new invoice.
· A failed republish can never undo or block what triggered it. republishLive cannot throw. On failure it records
publish.autoError + autoFailedAt and leaves state / publishedAt / contentHash untouched — the old copy is
still live and the record now says so — then execution falls through to the SAME single store.save(db), so the payment
and the failure note persist together. The webhook still answers 200: the money is committed and the seen-marker
written, so a non-2xx would only earn a Stripe retry that the event-id dedupe turns into a no-op, while risking Stripe
disabling the endpoint. It also never falls back to a fresh doc when the old one was deleted out of band — right for
an owner pressing ↻, silent stranding unattended (a new URL the client has never been given) — and it **never touches the
hub's access axis**: there is nobody to warn, and opening a hub because a payment arrived would be a disclosure decision
made by a webhook. A rescan that added an unpriced line skips rather than pushing half-priced work to a client.
Answers are four distinguishable states — updated / unchanged / skipped+reason / failed+reason — because an automatic
update that had nothing to do must not look like one that failed.
· 💳 Arming or rotating a pay link republishes, and that closed a live defect. The pay URL is baked into the published
bytes and /pay/ re-checks the token against the record, so rotating left the client's published Pay button dead
until someone republished — which the route's own response used to admit, as a hint telling the owner to do it by hand.
· ✓ mark-paid moved to the Worker beside void, for the same reason void did. Its four earned guards were
extracted whole into invoices.recordPayment (validate the amount before stamping, refuse a defaulted payment on a
credited-past-zero invoice, refuse an over-refund, markSent a draft first) so both doors run one implementation — two
copies would eventually disagree about what a payment is. lib/api.js keeps the route for the local server and reports
stillPublished honestly, exactly as its void branch does.
· 🔑 The pay token is NOT cleared on paid — a deliberate divergence from void. /pay/ re-derives the balance from the
record on every click and refuses a settled invoice, so a live token on a paid invoice cannot mint a Checkout Session.
Void clears its token because void is terminal; paid is not — a refund walks the invoice back to a real balance, and
clearPayToken also nulls stripe.payUrl, so clearing on paid would leave a partially-refunded client looking at
Balance due with no way to pay it until the owner noticed and re-armed.
· ⚠ The banner stays, re-scoped from "the normal state of a published invoice" to *"the automatic update could not
run"* — offline, a hub write that failed, an invoice published before this shipped, or any future write path someone adds
without wiring a republish. It keeps working for free because it is computed from timestamps
(inv.updatedAt > publish.publishedAt), not from a flag anyone has to remember to set, so a path that forgets to
republish lights it automatically. Fixed with it: pushDoc read the clock twice with an audit push between the two,
so whenever the millisecond ticked, updatedAt came out strictly newer than publishedAt and the banner lit immediately
after a perfectly successful publish. One timestamp is now read once and stamped on both — a warning that cries wolf
is exactly how a real stale-copy warning gets ignored.
· ✎ Editing a sent invoice updates the client's copy on a SETTLE, never on every save. A save here is discrete and
rev-guarded but it is not a unit of intent: invTouch() debounces 800ms per field group, so typing a line's title,
then its price, then its description is three saves — three republishes, three versions, and a client watching an invoice
being written one keystroke at a time. So the editor pushes once, on the signal that actually means *"this is what the
client should see"*: closing the editor, plus a 4s idle settle for the owner who leaves the modal open all
afternoon. Both go through the existing POST /api/invoices/:id/publish, which is already hub-write-then-one-board-save
— hanging a hub write off the PATCH instead would mean a second board save in one action (that route is served by the
shared lib/api.js, which saves the board itself and cannot reach HUB_DO/HUB_BLOBS), breaking the one-write-channel
rule outright. The settle is armed only by a write that landed, so the copy is never built from an edit the server
refused, and it is gated by the same single positive condition as the server's (publish.state === 'live') plus the
server's own unpriced-line refusal, so a draft can never be sent by an automatic action. There is no scheduler behind
any of this: wrangler.jsonc declares no cron and the Worker exports no scheduled(), so every republish rides a
request that already exists.
· Four honest states in the editor's published block — ok / pending / busy / failed — and the block only turns mint
when a hub write actually landed. Mid-edit it reads *"✎ Editing — their copy updates itself when you stop, never
mid-edit"*, which is the feature stating itself rather than a warning. On failure it says why (publish.autoError
from the server, or the client-side reason) instead of implying the owner forgot, and the list carries the same answer at
a glance: 🌐 the client is reading exactly this invoice, ⚠ their copy is behind. The staleness test is exact in both
directions — the ±1s tolerance that briefly compensated for the double-Date.now() bug went with the fix, because a
grace window is indistinguishable from a missed edit (an edit landing inside it would read as in-sync, skip its update
and never light the banner).
· A payment landing under an open editor is now a visible event. The webhook bumps inv.rev, which it never did
before, so the editor's next automatic push can 409. With nothing dirty there is no competing edit to lose, so it adopts
the server's copy and retries once rather than dropping into the line-items conflict dialog — whose wording asks a
question "a payment arrived" does not answer.
pure read: PAID invoices become income (profit must not count money that has not landed), outstanding shows beside the
profit figure, the real Stripe fee books as its own cost line, and an invoiced project stops double-counting its quoted
bid. Nothing is ever written into project.finance — a copied total is how two numbers drift apart.
hand) and every Stripe route answers 501 “not connected” with the exact command to fix it — never a 500, never a fetch
with an undefined key. With keys: the client's copy grows a Pay button that is a plain <a target="_blank" rel="noopener">
to a stable Kosmos /pay/{id}?t=… URL which mints a fresh Checkout Session at click time — pinned to the current
amount and expiring, rather than a reusable Payment Link that can charge an old total twice. (An outbound anchor is the
only shape that works: hub docs are served under CSP: sandbox allow-scripts allow-downloads allow-popups inside an
equally sandboxed iframe — no allow-forms, no allow-same-origin — so a form post or an embedded Stripe.js would not
run, and no card data ever touches an Appolis origin. The URL is printed under the button as a fallback.
This was unverified, then verified the hard way (v7.61): a popup opened from a sandboxed frame inherits that
sandbox, so Stripe Checkout landed in an opaque-origin window with no storage and died with apiKey is not set — while
the same URL pasted into the address bar worked. allow-popups-to-escape-sandbox is now present on both surfaces,
rawHeaders()' CSP and the shell's iframe sandbox attribute, because the CSP one binds the document itself and the
parent frame cannot relax it. The document stays sandboxed; only tabs it opens become ordinary tabs.) Recurring is
Stripe Billing: Stripe owns the Price, the subscription, retries, dunning, card updates and the next billing date;
Kosmos owns presentation and the project linkage, schedules nothing and has no cron. POST /api/stripe/webhook is
registered above the session gate (Stripe sends no cookies), reads the raw body, verifies the HMAC-SHA256
signature with a 5-minute replay window, and caps the body at 256 KB before reading it (it is the one route exempt
from the session gate, so it must not buffer an unbounded body for an unauthenticated caller). An unauthenticated
endpoint that marks invoices paid is the vulnerability this avoids.
stops Stripe retrying one event; the same Checkout Session arriving under a second event id would bank the same
money again, so a payment is also skipped when its object id is already in the ledger (the refund branch always did
this). And a verified event proves a payment happened, not what the invoice was worth: money landing above the
outstanding balance is recorded — it is real money and must never be dropped — but flagged on stripe.amountMismatch
with both figures and an audit entry, rather than silently reported as cleanly paid and fed to the 💰 strip. The
realistic trigger is nobody's fault: the price was edited down while the client still had an older Checkout page open.
ensurePayToken only ever minted once, so “↻ New pay link” handed back the identicaltoken — and the token is printed as visible text on the published document and in every retained version of it, so it
was in practice unrevocable. It now takes {rotate:true} (the button asks first, since rotating kills the button on
every copy already out there) and drops the old KV index. /pay/ re-checks the token against the invoice record, not
just the KV index, so rotating — or voiding, which clears it — kills every published Pay button immediately.
wrangler.jsonc vars is the wrong neighbourhood): npx wrangler secret put STRIPE_SECRET_KEY (a restricted rk_… is better than a full sk_…) ·
npx wrangler secret put STRIPE_WEBHOOK_SECRET (whsec — different in test and live, the classic “signature
verification failed”) · STRIPE_PUBLIC_BASE (non-secret var, the absolute origin used to build success/cancel URLs).
Locally they come from process.env / .dev.vars; absent, everything reports not connected.
invoices is deliberately absent from shares.CROSS_COLLS, is never folded into a collaborator's /api/db, and has no cross-account write path. The published read-only hub doc is the client's only view.
The records were always withheld, but the project object was spread verbatim into a collaborator's payload and
carried two money leaks by reference: project.finance (the whole 💰 strip, hidden only by a client-side
if (!p.sharedFrom), which is a decision and not a boundary) and project.links.docs, which syncLinks rewrites on
every publish — so a published invoice put its number, the client's name and the total into a tile the Docs & hub panel
rendered for viewers with no gate at all. shareSafeProject() in worker.js now strips both server-side, so no
client change can re-expose them; syncLinks carries tile_type so the filter has something to match on.
lib/hubs.js is fronted by a Durable Object: withHub reads a rev, replays the whole mutation on conflict, and cannot lose a write. worker.js's kvStore is a plain
KV get/put with no compare-and-swap, so a board read-modify-write is only as safe as it is short. The attended
publish's rule — hub write first, then exactly one store.save(db) — is correct **only where the board change IS the
publish block**. Copied onto a door holding MONEY it becomes a lost-update window hundreds of milliseconds wide, and the
loss is permanent because the webhook's KV seen-marker survives the clobber and answers Stripe's retry with duplicate.
So the unattended doors (the Stripe webhook, mark-paid, arming/rotating a pay link, and the editor's automatic settle)
run A → B → C: mutate and save the record; then write the hub with nothing pending; then republishAndRecord
reloads the board and merges only publish{} onto it. Whether the ⚠ banner stays off is decided by re-rendering the
freshly-loaded record and comparing it to the bytes that actually went out — if something landed underneath, the record
says the copy is behind instead of stamping itself in sync. **Never put an await that leaves the isolate between an
istore.load() and its istore.save(db).** The remaining exposure is honest and pre-existing: the attended publish
still spans its hub write (it always has, and it has no money to lose), and two writers can still interleave inside a
single narrow load→save. Closing that last gap needs the board behind a Durable Object of its own, the way hubs are.
node server.js. Publishing needs the hub Durable Object and R2, so like every /api/hubs/* route it is Worker-only and 501s locally with that reason — which also means the
documented local server cannot exercise the auto-update at all (server.js's webhook answers
republish: 'hosted-only'). Verifying that path means standing the real Worker up with shimmed bindings, which is what
test/cas-hubdoc.mjs does; a green local run proves nothing about it.
test/invoice-api.mjs (201: the type taxonomy per type against the realquantum-flip board shape, the cross-invoice subtraction with its void/sibling/self-exclusion rules, the lifecycle, the
money rules and the webhook's signature verification), test/invoice-doc.mjs (49), test/invoice-settle.mjs (31),
test/invoice-publish.mjs (121 — including the whole v7.62 auto-republish table: a banked payment leaving Paid in full
with no paybtn and no Total due, a draft never auto-published, a retainer child never sent, an unpriced rescan
skipping, and a failed hub write leaving the money banked, the record honest and the ⚠ banner lit; plus v7.63's
canRender parity — a trashed project, a video-section project and an invoice with every line deleted all refused on the
unattended door exactly as the owner's own door refuses them — the mid-edit refusal, the one-audit-entry rule and the
stale-autoError clear), and test/invoice-editor-state.mjs (25 — new in v7.63).
invStale / invAutoWhyNot / invPubState decide when the copy follows and what the ⚠ banner says, they live only in public/app.js, and until v7.63 every suite could stay green while they were
broken outright. test/invoice-editor-state.mjs lifts them out of the shipped file by name (brace-matched, not
copied — if public/app.js stops defining one, the suite fails to build) and pins: the staleness comparison is exact in
both directions with no grace window, mid-edit is a pending state and never a warning, a failure names itself, a
webhook-side skip is surfaced rather than swallowed, a private hub does not get a green tick, and a server-supplied
failure reason is escaped before it reaches the banner.
test/cas-hubdoc.mjs (31) now contests both stores. The original block forces a competing writerin between the hub Durable Object's load and its save — provable nowhere else, since the other suites' mocked env has no
HUB_DO — and asserts withHub's replay double-banks nothing, bumps the rev once and loses the other writer nothing.
The v7.63 block does the same thing to the board, which has no CAS at all: a second webhook banks a payment and
creates an invoice during the hub round-trip, and both must still be there afterwards. That assertion fails on the
v7.62 shape (verified by rerunning it against a republishLive + save(snapshot) persistence: the payment and the
bystander invoice are both gone), which is the only reason it is worth having.
INVOICE_BUILDER_SETUP.md is the owner-facing walkthrough (Stripe checklist, env vars, running the webhook locally, QuickBooks reconciliation). Short version: set the two Stripe secrets on the Worker, then run one live test invoice end to end (card + ACH); enable ACH in the Stripe dashboard first (0.8% capped at $5 vs 2.9% + 30c — cheaper at every amount, flat above $625) and activate the Billing customer portal before any retainer goes out.ts), so date-bounded drafting is partial — stamping doneAt/checkedAt going forward is one line each and unlocks it.db.json, clusters/dedupes/scores the 188 low-confidence notes, writes suggestions back.