🌌

Kosmos — Living Breakdown

The solo command center · kosmos.appolis.app
← Appolis · the city of apps

KOSMOS — Master Breakdown (Living Doc)

Kosmos (Greek: order out of chaos) — the Appolis command center. Hosted at kosmos.appolis.app (password-gated), runs locally as node server.js. On-disk folder is still mission-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.

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.

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.

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 DOESHide 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.)

🎯 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.)

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 NOTGET /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.)

── ① 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.)

📌 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.)

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.)

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 fileshubmail 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.)

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.)

🎛 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.)

What was wrong, and what the evidence actually says:

⚖️ 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.)

⛔ 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:

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.)

/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.

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.

⚠️ 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 wroteif (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 = nullone 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.jsa 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 surfaceuniqExactIf 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: revKeymoney:{tenant}:{slug}:{day}, plus a new exported revMetaKeymoney: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 repeatedBOARD_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 handlersopen_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.jsthe 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.)


1. WHAT IT IS

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:

2. DATA MODEL (data/db.json, atomic writes)

CollectionWhatKey fields
areasThe 7 top-level categoriesid, name, icon, color, order, desc
projectsTiles on the boardid, name, areaId, icon, tagline, stage, pinned, lastActivity, stalledSince?, stallReason?, links{folder, breakdown, deck, live}, summary
notesImported Keep notes + manual notesid, 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}
todosActionable itemsid, text, done, due?, projectId?, areaId
eventsCalendar entriesid, date, title, kind(milestone/stage/event), projectId?
playbookLearned patterns + insightspatterns[{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).

3. AREAS — the top-level categorizer

🚀 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.

4. PIPELINE

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.

5. IMPORT (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.

6. UI (public/, hash-routed SPA)

7. SERVER (server.js, zero-dep)

RouteDoes
GET /api/dbfull state + live config
POST/PATCH /api/notes[/:id]create, edit, move, pin, archive, confirm
DELETE /api/notes/:id · POST /api/notes/:id/restoredelete junk outright (sits in db.trash, last 100 — Undo in the toast)
POST /api/inbox/acceptbulk-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/previewthe 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/statusis 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 /breakdownlive 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

8. SEEDED PROJECTS (8)

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).

9. HOSTING — kosmos.appolis.app (Cloudflare Worker + KV)

Deployed 2026-07-13. Same codebase runs two ways with no drift — data handlers live in lib/api.js, shared by both:

9a. ACCOUNTS — multi-tenant, admin-created, hard-isolated

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).

9b. STUDIO — Kosmos's own creative brain (over the phantasia-engine binding)

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):

9b2. CROSS-ACCOUNT PROJECT SHARING (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).

9c. CLAUDE CONNECTOR (MCP) + UNIVERSAL IMPORT

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.

9d. APPOLIS HUBS — self-serve project landers (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.app wildcard route OVERRIDES sibling apps' custom domains (Cloudflare wildcard routes beat custom domains). So worker.js keeps an APP_HOSTS map ({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 reach hubs.handleHub. Any new .appolis.app app must be added to APP_HOSTS + a Kosmos service binding, or the wildcard swallows it.

9e. 🧾 INVOICES — draft from real work, edit here, publish read-only to the client

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).

typewhat it readsshape
🏁 completiontagline + summaryone 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
📆 periodexactly what shipped in v7.53 — live lists, done to-dos, cowork in the windowone line per list + tasks + build work
🔁 retainerthe same sources, but as proof, not priceexactly one line; recurring{} pre-armed from the gap to the previous retainer
🔧 maintenancebugs / function-changes / design-changes / launch-checklist only, live lists; dated events → Updates released (a version bump is the deliverable)fixes · changes · tasks · updates · sessions
🚨 incidentcowork only — the one source carrying a real timestampone line per day; bug fixes offered in the report, never auto-added
📍 milestonestage arrows + milestones since the last milestone invoiceexactly one line, itemising nothing
💵 deposit / ␀ blanknothing at allone empty line / none

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.

exactly 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.

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.

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.

Worker 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.

production 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).

document 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”.

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.

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).

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.

409 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.

is 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.

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.

(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 blockok / 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.

token — 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.

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.

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.

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.

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.

quantum-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).

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.

in 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.

10. TODO / NEXT

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