Where the team assembles โ one board, everyone on it. The team-shaped app on the Kosmos engine. This doc is LIVING: update it in the same commit as the change it describes. The in-app twin renders at /breakdown from the real config + data.
config.js โ version is the ONE version number (v0.13.0 ยท 2026-09-10). Shown in the โฐ menu footer; the refresh banner watches the app.js ETag for new deploys.agora workers.dev URL stays on as a fallback)node server.js โ http://localhost:3901 (open auth; data/db.json is a LOCAL copy of the real team_spartan board โ scripts/push-db.js pushes it to production, so pull before you edit). Role gates and ๐ซ tier are invisible locally by default โ a null actor reads as full admin (lib/api.js:224) โ so simulate a real one: AGORA_TIER=basic AGORA_ROLE=contributor AGORA_OWNS=0 node server.js (server.js:34-37). Unset is byte-identical to the old behaviour, which is why the escape hatch costs nothing.node test/run.js โ 14 zero-dependency harnesses, 365 assertions, green (re-measured 2026-09-10). node test/run.js trash filters to one harness. See ยง7 for what each covers.npx wrangler deploy ยท master password: npx wrangler secret put PASSWORD (data fails CLOSED until set) ยท ๐ฆ kill switch: npx wrangler secret put BOARD_OFF = 1 reverts the board to direct KV in ~30 seconds with no deploy ยท before any rollback, check GET /internal/doc-status?teamId=โฆ (ยง3c) โ mirrorDirty means KV is BEHIND the Durable Object and a rollback would serve a stale board.F:\Claude Code\agora โ a full COPY of Kosmos's engine (lib/*, shell) taken at Kosmos v6.40 so the team app can diverge safely (note_649 locked decision #1). Fixes that belong to both engines get applied twice, by hand.The Teams layer's app (Kosmos board 562, design note_649). Kosmos is the SOLO space โ one brain per account, hard isolation. Agora is the TEAM space โ one shared board per team: every member reads and writes the same KV doc. Same engine, different shape. It is a separate app with its own name and skin (never a mode-flag on Kosmos), portaled from Hermes the way Phantasia portals from Kosmos.
Cross-app movement runs through the ๐ Appolis hub button (v0.8.2, todo_683) โ ONE button in every suite app opening a popup of exactly the apps YOUR Appolis ID opens, fed by GET /api/hub-apps โ the ID service's /id/apps. Same-tab navigation, so BACK returns you where you were โ the fetch and the tiles are in openAppolisHub(); the ๐๏ธ button and the โฐ entry render in renderNav()/buildMenu(). The dedicated Kosmos space link was RETIRED here when the hub shipped; dedicated portal buttons survive only inside Kosmos. Do not go looking for a Kosmos link in the โฐ menu โ there isn't one.
Product steer (Tyler, 2026-07-16): Agora is a Basecamp-style remake with team-style Kosmos functions โ NOT a CRM. Ecommerce-first: the first renditions of Hermes AND Agora are tailored to companies that sell things online; Flip My Life is tenant #1 of the hermes/agora/phantasia stack. Other presets come later. Lane boundary: Hermes owns the customer lane (support, retention, subscriptions, refunds) โ Agora never duplicates customer-facing work (Customer Experience section cut on Tyler's reaction, v0.3.0).
Parity doctrine (Tyler, 2026-07-16): Kosmos trains Agora โ new Kosmos engine features and fixes get evaluated for porting into Agora (team-shaped) as a standing step of every Kosmos ship. Same in reverse where it makes sense, but Kosmos leads.
No Studio here. The Studio portals from Kosmos/Hermes only (master-admin-only everywhere until Tyler says ready).
The Kosmos v6.28 pattern, plus four Agora-specific refinements that are all privilege or staleness fixes โ a rewrite that follows the bare pattern literally re-opens them.
APPOLIS_ID service binding + ID_SECRET var (must equal the appolis worker's secret).PASSWORD master secret โ root (Tyler, master admin) โ accepted ONLY when the email field is empty or is root's own email, so member@x.com + the master password can never land someone in the owner's space (worker.js:640). Otherwise the same credentials go to POST /id/check โ an Appolis account with an agora (or *) entitlement signs in right here and gets the shared .appolis.app cookie.appolis_id cookie set by ANY suite app opens Agora via GET /id/resolve. at role admin. The ID service also hands hub INSIDERS an app:'' grant at role member, so a bare '' test mapped any insider with no Agora record onto Tyler's root account โ full master-admin over a real team's board (fixed v0.9.0; worker.js:470-465). tierFromEnts() deliberately keeps the broader '' test: insiders stay exempt from tier stamping without becoming ROOT.provisionFromAppolis), (2) the Appolis Connector door POST /internal/mcp from the signed identity envelope (v0.12.2 โ the bare x-appolis-email header stopped being proof; ยง3c) โ with ents passed as undefined, never [], so it cannot clear a tier stamped by a web resolve (worker.js:844), and (3) teamRoster() reading the registry member list, which provisions a local record for every non-disabled member whether or not they have ever opened Agora (worker.js:326-331). An account here = identity + connector tokens + tier + lastTeamId; no board data. โ ๏ธ Operational consequence: the ๐ฅ Accounts page and GET /api/admin/accounts are not a sign-in log.appolis_id cookie is present, resolveAccount() resolves THROUGH it and lets the live entitlements win, falling back to the Agora session when the ID is unreachable or says no. Throttled by RESOLVE_EVERY_MS (10 min, per account), so it costs one service-binding round-trip per window, not one per request (worker.js:580-574). The 30-day session length is deliberately NOT shortened โ that would force a weekly re-login on everyone. Known limit, not papered over: on the workers.dev host the appolis_id cookie is Domain=.appolis.app, which browsers reject there, so that host never re-resolves. Treat workers.dev as break-glass only.| Concept | How it works |
|---|---|
| Board storage | ONE doc per team, everyone on the team shares it. The AUTHORITATIVE copy is a per-board Durable Object TeamDoc (worker.js:90) whose DO NAME is the key db:team:<teamId>, so every isolate worldwide resolves the same object for the same board. Loads come back fresh carrying a rev; saves are compare-and-swap on it. KV db:team:<teamId> is a write-through mirror โ that is what keeps scripts/pull-db.js, external inspection and a rollback all correct. BOARD_OFF=1 reverts to direct KV. |
| ๐ฆ Write channel | Every read AND write runs inside runOnBoard() (worker.js:296). A CAS conflict throws the whole response away and replays the ENTIRE handler on fresh data โ jittered backoff, 15 attempts, then a loud 503; never a success toast for a write that did not land. A conflict is a FLAG on the store (store.conflicted), never a throw โ lib/mcp.js wraps its whole envelope in try/catch and would swallow a thrown conflict into a 200, leaving the AI door silently losing writes while the web door looked fixed. If you add a route, do NOT call env.AGORA.put() directly: that is the lost-update bug v0.10.0 exists to close. CAS's complement (v0.12.2): what no conflict check can catch is an informed-looking sequential write from a session that never read a teammate's recent work โ so a landed connector write whose project saw cowork/stage activity in the last 72h carries an advisory concurrent_activity block (both doors, wrapped so it can never fail the write). |
| Attachments | KV att:team:<teamId>:<name> โ images and screen recordings are the team's too |
| Membership + roles | The Appolis teams registry, LIVE since Appolis v0.6.0: Owner ยท Admin ยท Dev ยท Lead ยท Basic. Agora READS it and never writes it โ the only team-shaped write in this whole repo is the lastTeamId pointer move. |
| ๐ญ Board capability | agoraRole โ admin ยท manager ยท editor ยท contributor ยท viewer โ resolved per request by agoraRoleFor() (worker.js:545). For a business-linked team it derives from the person's HERMES rank over the HERMES binding (overall_adminโadmin ยท dept_headโmanager ยท dept_managerโeditor ยท dept_userโcontributor); otherwise from the Appolis team role (Ownerโadmin ยท Adminโmanager ยท Dev/Leadโeditor ยท Basicโcontributor). This โ not me.role โ is what boardCaps() enforces on both doors. Per-project assignments can raise or lower the capability on one project. Hermes unreachable โ falls through to the team-role default. |
| What the client is told | /api/db me carries {role, agoraRole, appRole, id, name, email, hasAi, tier, ownsBoard, team, teams, teamsStub} (worker.js:913). The raw Appolis team role survives as me.role and as the ownsBoard signal only. Anything wiring new gates (including the P3 pipeline locks) reads agoraRole. |
| Active team | /api/me returns teams[] + teamId; POST /api/team {teamId} switches (sticky via lastTeamId) |
| Switcher UI | โฐ menu โ Team block: current team + role chip, tap to switch (styled askChoice). Below it is the ๐ Appolis entry โ the suite hub popup, not a Kosmos link (ยง1). |
| AI doors | TWO connector doors, ONE brain โ mcpOpts() builds identical options for both: POST /mcp/<token> (per-account act_ token) and POST /internal/mcp (the Appolis Connector room โ identity by the signed Appolis envelope: x-appolis-id, HMAC + expiry + audience agora; hubOk() is transport trust only and never says WHO a call acts as; lazy provisioning). Both work the account's ACTIVE team board, both resolve the REAL agoraRole via agoraRoleFor and the REAL roster via teamRoster, and both fail CLOSED to contributor. A permission or roster change applied to only one entrance recreates the drift v0.9.1 and v0.11.1 were written to eliminate. |
| ๐ Calendar door | GET /ics/<aical_ token> โ read-only, token-authed, same ACTIVE team board. It does not share mcpOpts (there is nothing to build: it writes nothing), but since v0.12.1 it does go through the same boardCaps().viewOf, so a feed carries only what that person can see. Before that it loaded the RAW board โ the same hole /breakdown had, on the door nobody looks at, and the hardest kind to notice because a calendar subscription refreshes on its own schedule with no in-app affordance. |
P1 CONTRACT IS LIVE (Appolis v0.6.0, 2026-07-16): /id/resolve + /id/check carry teams:[{id,name,role}], GET /id/teams?email= covers master/local sessions, and GET /id/teams/:id/members is the member directory โ teamsFor() speaks all of it, and prod team_spartan ("Spartan Studios", Tyler = Owner, created with the stub's exact id) adopts the existing board with zero migration; teamsStub flips false on resolve. stubTeams() in worker.js + LOCAL_TEAMS in server.js remain ONLY as offline/local fallbacks (registry unreachable โ the shell still walks); the menu chip shows "ยท offline fallback" only when one is actually in force โ it used to say "stub (P1 pending)", a claim about a shipped feature, on every screen. โ ๏ธ STUB_TEAM_ID = 'team_spartan' in worker.js keeps its historical NAME but the VALUE has been production since 2026-07-16; renaming the constant would be the actual bug.
Tyler, 2026-07-24, now a platform doctrine: "people's AI connections should not be able to mess with other people's stuff unless you give them permission to do soโฆ they should never be able to make any changes on anything that isn't theirs." Agora is the reference implementation.
The board enforces capability (v0.8.14): viewer reads ยท contributor works only ASSIGNED projects and their completions await approval ยท editor works anything they can see but NEW adds await a manager ยท manager/admin anything. Assigned-only projects are invisible to the unassigned. Until v0.9.1 the connector door enforced none of it โ a person's AI could read and write what their own screen hid.
boardCaps(actor) is exported from lib/api.js and imported by lib/mcp.js. The web door's own gates are built from it, so the two doors cannot drift โ a rule change lands on both in the same commit. Never re-inline these rules in mcp.js.handleMcp hands every tool a compartmentalized view (perm.viewOf): hidden projects โ and their notes, todos and events โ are simply not in the arrays. That closes all ten readers, every findProject probe and every error-path candidate dump at once, and any FUTURE tool starts safe instead of leaky. A hidden thing reads as not found, never as forbidden, so there is no existence oracle.ctx.raw. The view's arrays are Object.freezed, so a missed repoint throws loudly instead of silently dropping a teammate's work. Anything that allocates an id, counts for a cap, dedupes, or does an array-level write must use the raw doc.pending, and a contributor's connector completion returns awaiting_approval: true rather than lying that the item is done.contributor, never admin). The worker resolves the REAL role via agoraRoleFor at both connector entrances; the previous placeholder would have demoted every real manager.teamRoster() feeds both doors (see ยง3b)./breakdown is compartmentalized too (v0.11.2). The live doc renders REAL board data โ projects, stages, sections, note and to-do counts โ and until v0.11.2 it loaded the RAW doc: signed-in-gated, but with no capability view, so any viewer or contributor who typed the URL read every assigned-only project their own screen hides. It now applies the SAME perm.viewOf capability view the connector door uses, and AGORA_ROLE is resolved on that path as well as /api/* (worker.js:907). Deliberately not a boss-only gate: the live-doc rule makes the page teammate-facing on purpose. Compartmentalize it, don't hide it.test/test-mirror-rule.js โ a role ร project-shape matrix (31 assertions): each role against an open-unassigned project, an assigned project, and an assigned-only project it isn't on, proving both what must be blocked and what must keep working for managers. test/test-breakdown-gate.js covers the route above and carries a control leg that renders the RAW board and asserts it STILL leaks, so a green run can never be a fixture that stopped reproducing the bug. Run everything with node test/run.js (ยง7)."You would only get added to Agora if you were part of a teamโฆ a normal user would not be able to add any projects at all. You can only see what projects get added to your board for you to work on that you are a part of. Unless you are one of the owners or managers of the team space."
Project creation is owner/manager only on BOTH doors โ it had NO role gate at all before v0.9.1 (a viewer could create projects). Archiving, re-filing between pipelines, and โ since v0.12.0 โ deleting, restoring and emptying the Trash are all materially creation moves and carry the same gate (ยง3d). The front-end hides the ๏ผ New tiles from anyone who can't use them: a button that 403s is its own bug.
Per-stage pipeline locks by role (board 560) remain P3.
Board settings are team-shared and the PATCH is ROLE-GATED (v0.8.15, lib/api.js:738-757): contributors and viewers may write only PER-PERSON keys โ currently just hiddenSectionsBy โ and only their own entry inside them; house tunables (rateCard, houseTemplates, stagePlaybook, calendarSubs) are editor+. The first per-member pref shipped in v0.8.15: ๐ hide-any-category drops a section off YOUR dashboard only, via settings.hiddenSectionsBy[you], restorable from the ๐ chip row. A new personal preference added without registering its key in the PERSONAL allow-list will 403 for exactly the contributors and viewers it was written for. Broader per-member layout prefs remain a later refinement.
Anyone on a project's board can work it; assignment makes ownership explicit.
Who may assign (v0.9.2, Tyler): deciding who does what is a LEAD's job. Handing work to ANOTHER person โ or taking it off them โ is owner/manager-only on BOTH doors: to-dos, list items (including the whole-array write that carries assignees), the project crew, and the connector's assign_work + add_todo(assignee). What stays open to everyone: claiming a free task for yourself and putting your own back down โ that is working, not delegating. The ๐ค picker offers a non-boss exactly those two choices instead of a roster they cannot use. One helper, canAssign(cur, next) inside boardCaps (lib/api.js:262-269), is the whole rule; both doors call it and nothing re-implements it.
project.assignees: [{id,name}], edited from the ๐ฅ Crew row on the project page (chained styled picker: tap a name to add/remove). The ๏ผ Assign teammates control is manager-only โ hidden from a non-boss (public/app.js:1811), refused client-side (public/app.js:199) and refused again on the server (lib/api.js:617).todo.assignee: {id,name}|null; ๐ค toggle on every todo row + an Assignee row in the editor.listItems[i].assignee; same ๐ค toggle on working-list rows, rollups, and the item editor.GET /api/team/members โ REAL since v0.6.0: the Appolis registry's GET /id/teams/<id>/members, with each registry member mapped onto a LOCAL account record (lazily provisioned by appolisId/email โ the same record a first sign-in finds/creates, so assignment-chip ids stay stable), disabled IDs excluded from pickers, and the provisioned-accounts roster kept as the stub/offline fallback.teamRoster(env, team, stub) (worker.js:313), called by the HTTP picker door AND both connector doors. Until v0.11.1 mcpOpts invented its own list from every provisioned account (Owner if root else Basic), so an AI could assign work to someone disabled in Appolis ID or on another team โ the two doors disagreed about who exists. Never re-inline this; a second copy is that bug queued up again.#/mine) โ the assigned person's plate: every open to-do + list item handed to you, grouped by project, plus the crews you're on. โฐ menu entry with an open-count badge; a member select flips to any teammate's plate (#/mine/<accountId>) so a lead sees who's carrying what.assign_work (todo or noteId+item โ member, or unassign), add_todo(assignee), open_items(assignee) for one person's plate; outputs carry assignee names. The worker passes the roster as ctx.members.Five /internal/* doors, all sharing one transport rule: header x-id-internal, and a 404 (never 401/403) without it โ an unauthenticated caller must not learn the door exists. No cookies are involved. But it is not one trust model: the two ops doors (doc-status, doc-reload) accept ID_SECRET only and the header alone is their whole gate; the two READ doors (overview, project-feed) take ID_SECRET as transport but since v0.12.3 prefer a signed identity envelope whenever one is present, honoring the legacy unsigned subject only while INTERNAL_REQUIRE_ENVELOPE is unset (ยง7); while POST /internal/mcp accepts EITHER APPOLIS_APP_KEY or the legacy ID_SECRET as transport (hubOk()) and then additionally requires the signed identity envelope to say WHO the call acts as โ holding the machine secret no longer lets a caller act as anybody there.
| Door | What it is for |
|---|---|
GET /internal/overview?email= | v0.4.0. โ {app:'agora', teams:[{id,name,role,projects:[โฆ],openTodos}], stub} for the Kosmos "your companies" strip. Archived AND deleted projects are excluded (worker.js:714). Hermes will want a matching endpoint (Hermes lane). v0.12.3: a signed envelope, when present, supplies the subject and OVERRIDES ?email=. |
GET /internal/project-feed?teamId&projectId | v0.8.7, todo_676. A linked PERSONAL Kosmos project reads this team project's open to-dos (due/assignee), open list items and stage. Deleted โ 404. Reverse linking is deliberately absent: personal boards stay private from team surfaces. v0.12.3: when a signed envelope is present the caller must be a MEMBER of the teamId it asks about; unsigned callers still ride the legacy machine-secret path. v0.12.4: teamId is bounded by the same shared TEAM_ID_RE as the ops doors before kvStore() can turn it into a Durable Object name. |
POST /internal/mcp | v0.8.3, todo_674. The Appolis Connector room โ the same brain as /mcp/<token> (ยง3), lazy provisioning. Identity = the signed Appolis envelope (v0.12.2): x-appolis-id, HMAC + expiry, audience-checked to agora, carrying the person's entitlement role/type and real team memberships. The legacy bare x-appolis-email works only while APPOLIS_APP_KEY is unset โ and it is set in prod, so the bare-header path is dead there (verified live: an impersonation POST now 403s). |
GET /internal/doc-status?teamId= | ๐ฆ The rollback gate. mirrorDirty means KV is BEHIND the Durable Object, so rolling back would serve a stale board. Also reports rev, bytes, updatedAt. GET-only since v0.12.3, and teamId must match the bounded team-id pattern โ a raw ?key= is now refused, because it used to reach idFromName directly and made this door an existence-and-size oracle for any key in the namespace. |
POST /internal/doc-reload?teamId= | ๐ฆ The poke a WARM DO needs after ANY out-of-band KV write (scripts/push-db.js) or a roll-forward. A warm object never cold-starts, so without this it keeps serving โ and then re-mirrors โ its own older copy. Skipping it is how a push silently does nothing. Safe to repeat: adoption is strictly-newer-wins. POST-only since v0.12.3 โ it had no method guard at all, so a GET performed the reload and any link or prefetch could fire an ops mutation. Same derived-teamId rule as doc-status. |
The two ops doors are independent of BOARD_OFF on purpose, so the roll-forward runbook still works while the kill switch is flipped. They 501 (not 404) when there is no BOARD_DO binding, and 501 locally.
Agora had no project-delete path at all until v0.12.0. Delete is SOFT: PATCH /api/projects/:id {deleted:true} stamps p.deleted with a server-minted ISO timestamp plus p.deletedBy (lib/api.js:626). The project leaves every door immediately (Layer A) while staying fully restorable for config.trashDays (30), after which it is purged for good along with its notes, to-dos, events and uploaded blobs.
โฑ The clock is the server's, never the caller's โ on both doors. A retention window handed to the client is a retention window that can be set to 1970.
๐ Boss-only on both doors, gated on isBoss โ never canWork()/effRole(). effRole is deliberately raisable per project via access.assignments, so gating on it would let a contributor holding manager on ONE project bin the team's work. Web: lib/api.js:622-623 (the 'deleted' in body branch) and lib/api.js:654 (the purge). Connector: delete_project / restore_project (lib/mcp.js:188-216), both first-line ctx.perm.isBoss, both resolving against ctx.raw because viewOf hides deleted projects from every handler โ a view lookup could never find one to restore, and a name probe must see projects the caller cannot otherwise see.
Layer A โ the seven read filters. A project visible on even ONE door after delete is worse than no feature, because the board and the AI then disagree about what exists.
| Filter | Covers |
|---|---|
boardCaps().viewOf | The connector read choke point, /breakdown, and (since v0.12.1) /ics. Hides the project and its notes, to-dos, events and deleted-note rows โ from everyone, bosses included. The Trash is its own surface, not a thing that keeps showing up in the pipeline it was removed from. โ ๏ธ The zero-copy fast path here returns the RAW doc for a boss, which was correct while visibility was all this filtered โ a boss sees every project anyway. v0.12.0 made it also the place a DELETED project is hidden, so until v0.12.1 a manager's /breakdown still listed the whole Trash. The fast path now fires only when there is nothing deleted to hide. |
GET /api/db keep-set (lib/api.js:399) | The web board. Bosses used to take a raw-doc shortcut here; they no longer can, because they are exactly the people who can delete. One filter, every role. |
GET /api/db โ trash (lib/api.js:414) | The deleted-NOTES ring buffer, filtered by the same keep-set. This also closed a PRE-EXISTING leak: it shipped unfiltered, so deleted notes from assigned-only projects reached people who cannot see those projects. |
GET /api/db โ projectTrash (lib/api.js:419) | The Trash surface itself โ only shipped to isBoss. A list you cannot restore from or empty is just a list of things you were told about. |
GET /internal/overview (worker.js:714) | The cross-app teams strip. |
GET /internal/project-feed (worker.js:680) | 404 now, rather than live dues until the purge makes it 404 later. |
buildIcs (lib/ics.js:20) | The calendar feed. A subscription refreshes on its own schedule with no in-app affordance, so without this a deleted project's dues live on in every subscriber's calendar until they unsubscribe. |
๐ The key is projectTrash, never trash. db.trash is the deleted-NOTES ring buffer, and schema.nextId() scans it for id allocation โ shadowing it breaks id minting board-wide.
๐งน The 30-day sweep runs on the WRITE path only (sweepTrash, lib/api.js:341, hooked onto store.load once for non-GET methods at lib/api.js:380-388 rather than at ~30 call sites). It is not on the read path, and that is not an oversight: a save inside GET /api/db makes N polling teammates race the same purge, every losing writer replays the whole handler under CAS, and 15 losses turn a plain board READ into a 503 storm. The cost of living on the write path is that a board with no writes for a month purges late โ and after Layer A a deleted project is already invisible everywhere, so that costs storage and nothing else. The sweep returns immediately on a board with no deleted projects.
๐ Blob collection is deferred to post-commit. purgeProjects() (lib/api.js:306) collects the names of the project's logo, its notes' attachments and its items' screenshots and hands them back; handleApi has no KV access, so it parks them on store.dropBlobs and worker.js deletes them only after the doc write actually committed (worker.js:255, 274-279). Under CAS a rejected save is REPLAYED โ deleting a teammate's screenshots for a write that never landed cannot be undone, and no rev check brings bytes back. Doc first, blobs after: a delete-first crash would leave the doc pointing at bytes that are already gone. Same contract the handled-bug media purge already used.
The quiet failures, each closed deliberately:
DELETE /api/projects/:id 409s if the project is not already soft-deleted (lib/api.js:657), so no path goes live โ gone; every delete passes through the restorable window.409 {inTrash:true} with a message that says what to do (lib/api.js:672), instead of an "already exists" for something nobody can see.buried() in the approvals route (lib/api.js:837).playbook.patterns[].seenIn but keeps the insights (lib/api.js:330): a shipped project's retro lesson should outlive its tile, while a purged id in seenIn would permanently inflate "seen in N projects" on the team's accumulated-intelligence surface.The client (public/app.js:647-712): TRASH_DAYS = 30, viewTrash() at #/trash (โฐ menu, boss-only, with a count badge), per-tile days-left and deleted-by, โป๏ธ Restore and ๐ฅ Delete forever. deleteProject() counts what goes with it โ lists, to-dos, and how many teammates' plates it empties โ and puts that number in front of the person BEFORE the confirm, because on a solo app a delete only ever affects the deleter. Undo is wired into the standard wrapUndo stack (public/app.js:4946) as a deleted:false PATCH.
Covered by test/test-trash.js (88 assertions), organised as: A every door hides it ยท B the write path ยท C the quiet failures ยท D the 30-day sweep ยท E the connector (the mirror rule on a destructive tool) ยท F the client is wired to all of it.
Eight online-store pipelines in config.js (colors reuse the engine's stage hues). Cut on Tyler's reactions: Clients & Deals + People (v0.2.0 โ not a CRM) and Customer Experience (v0.3.0 โ Hermes owns the customer lane). Added v0.3.0: Website Changes, Events, Partners & Affiliates. Split v0.4.0: Campaigns and Content are separate sections โ Campaigns ends at Results, Content ends at Approved (Tyler's calls).
| Section | Pipeline | Stalls at |
|---|---|---|
| ๐ฏ Initiatives | Idea โ Scoped โ In Motion โ Review โ Shipped โ Live | review, shipped |
| ๐๏ธ Products & Drops | Idea โ Sourcing โ Listing โ Launch โ Selling | sourcing, listing |
| ๐ฃ Campaigns | Brief โ Planned โ Producing โ Review โ Live โ Results | review |
| ๐ฌ Content | Idea โ Brief โ Producing โ Review โ Approved | review |
| ๐ช Store Ops | Intake โ Next Up โ Doing โ Review โ Done | review |
| ๐ Website Changes | Request โ Scoped โ Building โ Review โ Live | review |
| ๐ช Events | Idea โ Planned โ Prep โ Live โ Wrapped | prep |
| ๐ค Partners & Affiliates | Prospect โ Talking โ Terms โ Onboarding โ Active | terms |
Every new project is born a working surface, scaffolded PER SECTION โ seedProjectLists() + SECTION_SCAFFOLDS in lib/api.js:21-42, wired into BOTH the API create path (lib/api.js:683) and the connector's create_project (lib/mcp.js:179), so a project made by an AI looks like one made by a person:
| Section | Lists seeded at creation |
|---|---|
| ๐ website | ๐ Bugs ยท โ๏ธ Function changes ยท ๐จ Design changes ยท ๐ฎ Future Updates (the "dev four" โ this section ONLY) |
| ๐ฏ initiatives | ๐ Action items ยท ๐ง Blockers ยท ๐ Decisions ยท ๐ฎ Ideas & later |
| ๐๏ธ products | ๐ฆ Samples & sourcing ยท โ Listing checklist ยท ๐ Launch checklist ยท ๐ฎ Product ideas |
| ๐ฃ campaigns | โ๏ธ Copy & creative ยท ๐ฃ Channel checklist ยท ๐ Results & learnings ยท ๐ฎ Ideas & later |
| ๐ฌ content | ๐ก Content ideas ยท โ๏ธ Drafts in progress ยท โ Approval checklist ยท ๐ Publish queue |
| ๐ช ops | ๐ Issues ยท ๐ง Process fixes ยท ๐ฆ Inventory notes ยท ๐ฎ Improvements |
| ๐ช events | ๐ Planning checklist ยท ๐ช Day-of run sheet ยท ๐ฆ Supplies & vendors ยท ๐ Wrap-up notes |
| ๐ค partners | ๐ค Prospects ยท ๐ Terms & contracts ยท ๐ Onboarding checklist ยท ๐ Notes & touchpoints |
| (unknown / future) | DEFAULT_SCAFFOLD: ๐ Working checklist ยท ๐ง Blockers ยท ๐ Decisions ยท ๐ฎ Ideas & later |
Each list carries its scaffold LABEL (bugs, blockers, publish-queueโฆ), and seeding skips any label already present on an unarchived note of that project โ so a re-seed or a backfill cannot double up, and the labels are what the connector's report_bug and ai_worklist key off. sectionId is validated: filing a project into a section that does not exist is a 400 (lib/api.js:632), not a project silently stored where no grid can render it. That check shipped bundled with the v0.9.0 Basic cap and was deliberately KEPT when the cap was removed โ it is real validation, not a cap.
Each section also carries one virtual EXAMPLE project (v0.8.0) โ never a data row. It opens by default while the section is empty, auto-collapses once real projects exist, and is peekable/hideable from the section header (๐) or dismissable from the tile itself (โ, v0.8.4). Tapping it opens a read-only example project page with the pipeline lit at its stage, the scaffold lists filled with believable sample items, and a ๏ผ Create your own CTA. The eight store-team examples: Q3 Retention Push ยท Summer Bundle Drop ยท Back-to-School Email Blitz ยท September UGC Batch ยท Warehouse Restock Week ยท Homepage Hero Refresh ยท Farmers-Market Pop-Up ยท Fitness Influencer Collab. Adding a ninth section means adding its scaffold and its example too, or it will feel broken next to the other eight.
New team boards seed store-shaped AREAS (Ideas ยท To-Dos & Lists ยท Products ยท Marketing ยท Store Ops ยท Partners ยท Reference โ emptyDb() in lib/schema.js) so the categorizer's fallbacks always land; normalizeAreas() remaps any unknown area id onto ideas on every load, in both doors. Categorizer keyword rules ship EMPTY (content-first rule): each team grows its own vocabulary. The ๐ฐ rate calculator lost its section anchor and menu entry with the Clients & Deals cut (#/calc route remains, unlisted; defaultRateCard is engine ballast).
db, db:<acc>) โ per-team (db:team:<id>); accounts.js gained teamDbKey/teamAttKey, lost studioTenant.teamsFor + activeTeam); /api/team switch route; admin account CRUD โ 501 (membership lives in Appolis ID; GET roster stays, and it now carries tier โ on that projection ONLY, because publicAccount() also feeds /api/login, /api/me and /api/profile and widening it there would put a billing attribute on the login response); /api/share* โ 501 (the board IS shared); Studio proxy + /f/ passthrough removed; profile email/password edits point at the Appolis portal. Storage rewritten (v0.10.0): a TeamDoc Durable Object class lives in worker.js; kvStore() is a drop-in {load, save} over it exposing ONE new thing, store.conflicted; runOnBoard() wraps every handler call in CAS replay. Machine doors Kosmos has no analogue for: /internal/overview, /internal/project-feed, /internal/mcp, /internal/doc-status, /internal/doc-reload (ยง3c). Identity extras: agoraRoleFor() asks the HERMES binding for the person's rank on a business-linked team; tierFromEnts() stamps account.tier on every resolve; teamRoster() is the ONE member directory for the picker door and both connector doors; resolveAccount() re-resolves live entitlements on a throttle (ยง2). Extra bindings: KOSMOS_HUB (bug-report intake) and HERMES (rank lookup) alongside APPOLIS_ID.#100e15, gold brand accent, portico icon โ calm-mint buttons and coral-gold-for-destructive kept, house rule); team chip + switcher in โฐ; share buttons removed; accounts page is a read-only roster; Studio nav/menu entries removed; section presets rekeyed; "Your Agora" bubbles reworded for the shared board.agora_auth / agora-session-v1 (an Agora session never opens Kosmos and vice versa; the shared appolis_id cookie is the suite-wide door).node scripts/make-icon.js (zero-dep PNG writer) โ gold portico on plum, mint ground line.server.js carries deliberate STUBS for hosted-only routes, because each one is load-bearing for a shipped feature that otherwise looks broken on someone's machine โ /api/hub-apps (returns bugReporter:true on purpose: false would hide the ๐ FAB and look identical to a broken stub), /api/bug-report (validates like the Worker and logs, plus a 40MB media cap the Worker does not have), /api/my-connector (a real message, not "unknown api route" โ the popup prints whatever it returns straight at the user), the two pure-read /internal/* doors implemented for real, the two DO-backed ones as 501, and the OAuth discovery 404s (a 200 text/html from the SPA fallback is exactly what makes an MCP client start a flow that does not exist). The local me payload carries agoraRole and id โ without agoraRole the client's myRole() falls back to admin so the role-trimmed header and the โณ Approvals gate can never be exercised locally; without id, p.access.assignments[DB.me.id] reads an undefined key so "assigned to me" is never true and the whole v0.8.14 assigned-only model is untestable.Engine files that are STILL portable byte-for-byte: categorize.js, ics.js (bar the ๐ deleted-project filter), md.js, store.js, auth.js. api.js and mcp.js have DIVERGED structurally and no longer port mechanically โ api.js owns the Agora capability model (boardCaps, exported), the item-level op protocol (stampItemIds / resolveItem / applyItemOps), the per-section list scaffolds (SECTION_SCAFFOLDS), the Trash (purgeProjects / sweepTrash), the approvals collection and the role-gated settings PATCH; mcp.js imports boardCaps from it, splits every handler across a frozen db view and ctx.raw, and adds assign_work, delete_project/restore_project and the team-shared SCAN-FIRST instructions. Diff-and-copying a Kosmos api.js/mcp.js fix into Agora would silently drop all of that โ reintroducing v0.9.1's connector-has-no-permissions bug and v0.11.0's vanishing-checkmark bug in one commit. lib/breakdown.js was never reskinned at all โ it is still the Kosmos-era engine file (see ยง6).
/breakdown twin update in the same commit as the change they describe. โ ๏ธ The twin has been in breach since v0.6.1: lib/breakdown.js is still the un-reskinned Kosmos engine file. It tells teammates the store is data/db.json, names sections ("deals โฆ people") that were cut in v0.2.0, renders an empty Clients table and a video-production rate card, and documents /pf/ behaviour the worker no longer has. It is the higher-stakes half of the rule because teammates read it in-app and it is labelled LIVE. (v0.11.2 fixed the disclosure on that route โ it now applies boardCaps().viewOf like every other read โ but not a word of the content.)config.version) + refresh banner. Features deck (with __VERSION__ token) is born when the shell graduates โ not yet.askPrompt/askConfirm/askChoice twins โ no bare prompt/confirm).test/ carry an explicit control leg โ the old code path, asserted to STILL fail โ so a pass can never be a test that quietly stopped exercising the race or the hole it was written for. Add a concurrency or permission test, add its control leg (test/run.js states this as the house rule the directory exists to enforce).agora entitlement slug โ appolis lane. The old "KosmosโAgora switcher โ Kosmos lane" item is superseded by the ๐ hub button (ยง1). The lanes actually open: the Frame.io developer token (Tyler, v0.8.18), the "own at most 1 team" cap (appolis lane, v0.9.0 โ Agora contains no team-creation code at all), and the Kosmos-side companies strip that consumes /internal/overview.Everything below must exist for a deploy to work. A rebuild from an incomplete list produces a worker that 500s on every board read (no BOARD_DO), cannot resolve roles (no HERMES) and cannot forward bug reports (no KOSMOS_HUB).
| Item | State |
|---|---|
Worker agora | deployed โ custom domain agora.appolis.app + workers.dev fallback |
Static assets ASSETS โ ./public | not_found_handling: single-page-application plus run_worker_first: true. Without that flag the asset layer answers first, so a navigation to /breakdown, /attachments/ or /.well-known/ gets the SPA shell instead of the Worker route โ the failure looks like "the page is blank", not like a routing bug. Note this directory is public/, so anything you want served must live there: assets/ in the repo is NOT served by the deployed Worker. |
KV AGORA = 49c1d0ec0b05464e8ded3f951151aac5 | the board MIRROR + attachments (att:team:โฆ) + the accounts registry |
Durable Object BOARD_DO โ class TeamDoc | migration tag v1-teamdoc, new_sqlite_classes โ NOT new_classes: the legacy backend caps a stored value at 128 KiB and a live board is already ~40 KB. The tag is per-worker, so it cannot collide with Kosmos's v1-hubdoc. |
| Service bindings | APPOLIS_IDโappolis ยท KOSMOS_HUBโkosmos (bug intake) ยท HERMESโflip-cms (rank lookup). No STUDIO binding, by design โ the Studio portals from Kosmos/Hermes, never Agora. |
ID_SECRET var | must equal the appolis worker's secret โ the sole machine trust for four /internal/* doors, and one of the two accepted transport keys on /internal/mcp |
INTERNAL_REQUIRE_ENVELOPE secret | v0.12.3 โ the staged cutover for ROTATION_RUNBOOK Step 4. Unset (today) means /internal/overview and /internal/project-feed still accept the legacy unsigned subject under ID_SECRET, because the only caller โ Kosmos's your-companies strip and linked-project feed โ sends ?email= alone and cannot mint an envelope: only Appolis ID holds this app's key. Set it to 1 once those callers route through the ID service, and both doors go envelope-only. A secret rather than a constant so the flip, and its rollback, take seconds with no deploy. |
APPOLIS_APP_KEY secret | โ ๏ธ the identity-envelope key (v0.12.2). The cutover self-flips on its presence: while it is UNSET, /internal/mcp honors the bare x-appolis-email header again โ so a rebuild that skips this secret WORKS and silently reverts the impersonation fix, re-opening act-as-anybody for any holder of the shared machine secret. It is set in prod; set it before anything else. |
PASSWORD secret | โ
SET (verified wrangler secret list, 2026-07-24) โ break-glass only; the real door is Appolis ID. Data fails CLOSED until it is set. |
BOARD_OFF secret | UNSET = normal. Set to '1' to revert the board to direct KV in ~30 seconds with no deploy (the storage kill switch). |
| agora.appolis.app | โ LIVE โ custom-domain route active in wrangler.jsonc |
๐ฆ One write channel (Durable Object TeamDoc) | โ SHIPPED v0.10.0 โ the DO is the board's authoritative store, KV is a write-through mirror; CAS + replay on every request; ops doors + kill switch above |
| ๐ Item-level writes | โ
SHIPPED v0.11.0/v0.11.1 โ PATCH /api/notes/:id {ops:[โฆ]} with set/add/remove; stable non-numeric i_ item ids; all four undo paths are INVERSE OPS. Still whole-array by design: vault seal/release and noteโlist conversion, where the array genuinely IS the change. Sending both ops and listItems is a 400. |
| ๐ Trash + delete projects | โ SHIPPED v0.12.0 โ see ยง3d |
| ๐ซ Basic tier (todo_1013) | STAMP ONLY. The local usage cap was REMOVED in v0.9.2, deliberately reversing v0.9.0. tierFromEnts() still stamps account.tier on every ID resolve and it still rides /api/db me.tier, mcpOpts.tier and the master-admin roster (the suite contract other apps read), but NOTHING in Agora enforces it โ basicCap() no longer exists in the repo. The limits that would matter for a team app (teams OWNED, seats) are minted in Appolis ID, where Agora cannot see them. |
agora entitlement slug in Appolis ID | registered in the ID admin grant picker; grant to members as they join |
| Teams registry (P1 todo_650) | โ
LIVE (Appolis v0.6.0) โ real teams + member directory consumed since Agora v0.6.0; stubTeams()/LOCAL_TEAMS are offline fallbacks only, and the menu chip says "offline fallback" only when one is actually in force |
| Test suite | โ
REAL since v0.11.2 โ node test/run.js, 12 harnesses, 309 assertions, green (re-measured 2026-08-13). Before v0.11.2 the assertion counts in the changelog described harnesses that lived in session scratchpads and no longer existed. |
/breakdown twin | โ ๏ธ IN BREACH of the living-doc rule since v0.6.1 โ the route is now compartmentalized (v0.11.2) but lib/breakdown.js is still the un-reskinned Kosmos file. See ยง6. |
| Per-stage pipeline locks by role | P3 (note_649 ยท board 560) |
| Realtime sync / chat (push ยท websockets) | P4, greenlit, NOT in this shell โ note this is not about Durable Objects, which shipped in v0.10.0 |
What node test/run.js actually covers. Each harness prints its own tally and exits non-zero on failure, so it stays runnable alone (node test/test-trash.js) while you work on one thing.
| Harness | Asserts |
|---|---|
test-mirror-rule.js (31) | ๐ญ role ร project-shape matrix against the CONNECTOR door โ it cannot WRITE what the person can't, and cannot READ what their screen hides |
test-v0121-holes.js (33) | ๐ the three v0.12.1 holes stay closed โ /ics compartmentalization (with a control leg rendering the RAW feed and asserting it still leaks), the viewOf fast-path Trash skip, and the four formerly-ungated write routes |
test-trash.js (88) | ๐ v0.12.0 โ every door hides it ยท the write path and whose clock stamps it ยท orphans/blobs/name-still-taken ยท the 30-day sweep ยท the connector ยท the client |
test-parity-cleanups.js (49) | ๐งน v0.12.0 โ the Kosmos v6.99 port (scroll restore, collapsed-panel width, search clear, undo on login) + tier on the roster + the โณ throttled re-resolve + local-dev parity stubs |
test-caps-v092.js (20) | v0.9.2 โ who may hand work to someone else, on both doors; and that no Basic cap fires |
test-roster.js (21) | ๐ฅ teamRoster() โ one directory, every door; disabled members out, other teams out, fallback intact |
test-item-callsites.js (19) | ๐ each converted client call shape run against a doc a teammate already changed, with a red control leg |
test-flush.js (18) | ๐ every modal teardown path flushes FIRST, through the guarded flushIfOpen(id) helper |
test-worker-identity.js (16) | ๐ซ tierFromEnts normalization + the master/ROOT '*'-at-admin gate |
test-breakdown-gate.js (12) | v0.11.2 /breakdown compartmentalization + a control leg asserting the RAW render still leaks |
test-one-write-channel.js (A/B) | ๐ฆ the 8-writer race run through the real TeamDoc + kvStore + runOnBoard extracted from worker.js โ must FAIL with BOARD_OFF=1 and PASS with the channel on |
test-item-ops.js (A/B) | ๐ two teammates ticking different items off one stale list โ the whole-array leg must still lose one |
ID_SECRET moves OUT of vars to a Wrangler secret, at the SAME value. Config only; no code changed, so nothing a person or another app can observe is different. Same precedent as the 08-18 entry below.) WHY AGORA SPECIFICALLY, and why it was worth a window: after Kosmos's copy moved earlier the same day, agora was the last config that was both git-tracked and carrying the plaintext. Appolis keeps its copy deliberately (the weekly session's refusal checks grep that file specifically โ verified, its scheduled task cds into the appolis repo before reading); hermes and phantasia gitignore their configs so theirs are disk-only; AWS has no vars block at all. So this closes the last one with no reason to be there.
โ ๏ธ WHAT IT DOES AND DOES NOT BUY. It stops future re-exposure โ a git-tracked vars value lands in commit history permanently, and wrangler deploy echoes every var into the terminal. It does NOT undo past exposure: the value is still in this repo's history and remains burned. Real retirement is runbook Step 2 (delete it once every room carries its own APPOLIS_APP_KEY).
THE ORDER, because the runbook's original one is impossible: Cloudflare refuses wrangler secret put while the name is still a plaintext vars binding ("Binding name already in use" [code: 10053]), so the var must be stripped and deployed first โ which is what opens a fail-closed window. Chained deploy && secret put in one command, so the window measured two seconds (15:54:35 โ 15:54:37). Opened only after a 90-second wrangler tail agora showed zero events โ idleness measured, not assumed from the clock.
โ ๏ธ THE PRECONDITION THAT ACTUALLY GATED THIS. Agora's last deploy (2026-08-19T02:32:39Z) sits between its two most recent commits (916a308 02:11:45Z, 8bddde2 02:37:57Z), both worker.js-only and therefore not observable from outside โ so "is HEAD deployed?" could not be answered by probing, and a config-only deploy ships whatever is in the tree. Tyler confirmed HEAD was live before this ran. Do not repeat this move on another repo without answering that question first โ the deploy is not optional, so an unverified tree means shipping someone else's work.
VERIFIED AFTER, and the distinction matters: wrangler secret list shows ID_SECRET alongside APPOLIS_APP_KEY + PASSWORD; agora.appolis.app 200. Those prove present, not correct โ a typo is the one failure this move can introduce, and agora's login door hides it (a wrong secret and a wrong password both end at 401). The proof is the connector: appolis's hub sends 'x-id-internal': env.ID_SECRET || appKey and appolis's own copy is still set, so it always sends the shared value and never falls through to the app key; agora's hubOk() admits APPOLIS_APP_KEY or ID_SECRET, and those are different credentials. A post-cutover agora_overview returning the real board (16 projects across 8 sections) therefore means the newly-stored secret matched byte-for-byte at the transport gate.
worker.js and both are documented in full in appolis/APP_BREAKDOWN.md (v0.9.0 and v0.9.1) and Kosmos note_1689. โ appolisResolve() no longer pre-verifies the shared SSO cookie locally. It used to call acct.verifySession(idToken, env.ID_SECRET) as a "cheap reject" and hard-return null before ever calling /id/resolve. That made Agora a second, silent authority on a signature only Appolis is entitled to judge โ and it pinned the entire suite to one 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 appear in no log and the cause would look like anything but a key change. Removed and deployed before Appolis flipped its key โ the only order that works. Behaviour is unchanged by construction: a bad token used to be rejected locally, now /id/resolve rejects it; the cost is one subrequest for an invalid cookie. This is what unblocked the suite's biggest security fix โ ID_SECRET had been the HMAC key signing every appolis_id cookie, and this file's own v0.12.3 entry already said "ID_SECRET is not a trust boundary". It no longer signs identity at all.
โก /api/my-connector now forwards the person's own signed cookie. That route returns a credential, not information: the amt_ connector token acts fully as the person across every app they are licensed for, needs no header of its own, and no key rotation invalidates it (Appolis rotated its session key and those tokens were untouched). Agora now sends x-id-token alongside the existing ?email=, so Appolis can name the subject from proof. Appolis prefers the token when present and echoes proven: true. The email stays until Appolis's phase C, because a local (non-SSO) session carries no such cookie โ that edge case is the reason C has not shipped.
โ ๏ธ Still open on this board: worker.js:316 still sends the bare legacy secret to /id/teams/:id/members, and APP_CAPS.agora has teams: false, so that call is unauthorised-by-capability and Agora silently falls back to its stub roster with nothing logged. See todo_1692 โ and verify the fix by opening a real assignee picker in a browser, because this caller swallows its own failures.
public/style.css gains {scrollbar-width:none;-ms-overflow-style:none} and ::-webkit-scrollbar{width:0;height:0;display:none} โ the standards property, the WebKit pseudo-element and the legacy Edge property, because any two leave a bar on the third's engine โ and LOSES html{scrollbar-color:#3a2f52 var(--bg)}, a colour for a bar that no longer exists (an old rule left to fight the new one by specificity is a regression waiting for one edit). features.html links no stylesheet and /breakdown is assembled in lib/breakdown.js, so both carry the identical block inline with a comment naming the shared place: every served page either links the shared sheet or carries the block itself. โ ๏ธ THE INVARIANT: the cheap fake is turning an overflow to hidden โ that removes CONTENT, not chrome. Nothing was traded: .menupop, .iconpick, .stageswitch, .stepper, .modal, the app.js roster lists and the breakdown tables all still scroll, and html.menulock,body.menulock{overflow:hidden} โ the derived overlay lock cmp_8c8134e18f requires โ is untouched. ๐งช test/test-no-scrollbars.js (the 14th harness, 8 assertions summed by test/run.js), written before the change and PROVEN TO FAIL on the old tree (A1, A2 naming features.html, A2b naming breakdown.js, A7 naming the colour rule), pins: the shared sheet covers every engine ยท every served page and every code-assembled page is covered ยท the named scroll containers still say auto ยท the space-reserving property appears nowhere ยท the overlay lock survives ยท all five version strings agree ยท no older custom bar remains. โ 365 passed, 0 failed across 14 harnesses. โ
VERIFIED LIVE after deploy: served /style.css carries both rules ยท /features and /breakdown carry the block ยท the โฐ menu footer reads v0.13.0 ยท measured in the pane on a pointer viewport, a scrollable element's bar went from 15px to 0px on both axes while still scrolling both ways.test-internal-doors.js was the only evidence offered that the three guards worked, and it never executed worker.js. Every assertion was a regex over the file's TEXT or a hand-written re-implementation declared inside the test itself. Its three legs labelled RED CONTROL โ the thing the house rule says makes a green trustworthy โ were tautologies: const oldResolve = (params) => params.key || โฆ then asserting it returns params.key proves nothing about the shipped code. The reviewer mutated the worker and the harness reported 43/43 green while: the method guard was wrapped in an env flag; the raw ?key= read was restored using double quotes (the grep only matched the single-quoted form); the guard was moved to AFTER idFromName with a comment containing the text status: 405 left in its place, which satisfied the assertion literally named "ordering, not just presence" because it compared source-text indices; and the teamId pattern was replaced with /^.{1,4096}$/ while the real pattern stayed in a nearby comment โ the harness built its own copy of the regex from a hand-typed literal rather than the reMatch it had just captured, so all six "REJECTS โฆ" assertions stayed green. The rewrite. The harness now boots the real code. worker.js is imported and its exported fetch() is invoked against a fake env whose BOARD_DO.idFromName records every name it is handed โ which is the exact thing the ?key= oracle abused and the exact thing no source-grep can see. The dev server is spawned as a real child process and probed over real HTTP. Proven by mutation, not by assertion count: deleting the method guard โ 4 failures; restoring the raw ?key= โ 3; moving the guard after idFromName โ 1 (the recorded idNames is not empty); relaxing the pattern โ 10; reverting listen(PORT, HOST) to listen(PORT) โ 1; swapping the local gate back behind the method check โ 1; dropping the new project-feed bound โ 4. One mutation still slips through and it is named here rather than hidden: wrapping a guard in if (env.SOME_FLAG !== '0') is inert under the test env, so behaviour is unchanged and no behavioural test can see it. That is a backdoor, not a regression, and it is a different problem from the one this harness exists to solve. The bind test needed a real network. Every probe to 127.0.0.1 succeeds whether the server bound loopback or 0.0.0.0, so reverting the bind was invisible โ the first rewrite still passed it. It now connects to the machine's own non-loopback IPv4 from os.networkInterfaces() and asserts the connection is REFUSED when loopback-bound and ACCEPTED under HOST=0.0.0.0, which is the only way to tell the two apart. On a machine with no non-loopback interface it fails loudly rather than skipping quietly. Three real code fixes came out of the same review. โ /internal/project-feed took an unvalidated, unbounded teamId to BOARD_DO.idFromName via kvStore() โ v0.12.3 bounded the narrow ops door and left the wider read door raw, twenty lines apart. idFromName accepts anything and TeamDoc._adopt() does a durable storage.put on first touch, so every unique string an attacker sent permanently allocated a Durable Object; it was reachable by any signed-in Kosmos account, because that side forwards ?teamId= verbatim. Storage pollution rather than disclosure, so LOW โ but it was the last caller-supplied idFromName input in the worker, every other call site passing a server-resolved id. The pattern is now one shared TEAM_ID_RE constant referenced by both doors, because two copies is precisely how they drifted. โก EXPOSED treated IPv6 loopback ::1 as exposed, so HOST=::1 printed a false "reachable off this machine" warning and closed the internal reads while actually private. โข The local gate ran AFTER the method check, so an ungated caller got a 405 and learned the route exists โ ยง3c's own rule is 404 and never a hint. Also: nine worker.js:NNN citations in this doc were re-anchored (v0.12.3 added ~70 lines and moved six of them; two more had been pointing at a bare } since before this release), and test-one-write-channel.js learned to strip export const โ v0.12.3's shared constant crashed it, which the full suite caught and a single-harness run would not have. 357 green across 13 harnesses. The review also dismissed 11 findings after verification, including three separate reports that the local 405-before-gate ordering was an information leak โ dismissed on impact, fixed anyway because it cost one line./internal/ machine doors. v0.12.2 closed the impersonation hole on /internal/mcp and left three findings from the same 2026-07-26 audit open. All three are closed here, and they share one premise worth stating plainly: ID_SECRET is not a trust boundary. It is the HMAC signing key for every appolis_id SSO cookie, and its value sits in the git history of three repos (appolis, kosmos, agora) โ ROTATION_RUNBOOK says so in as many words. Every door below was gated on "presents the shared secret" and treated that as "is a trusted caller naming a truthful subject". โ The local dev server was serving a real board to the network. server.js returns LOCAL_ROOT for every request whenever PASSWORD is unset โ deliberate, frictionless, and completely unauthenticated โ and data/db.json is a real copy of a real board (scripts/push-db.js pushes it to production). The only thing between the two was the bind, and server.listen(PORT) with no host binds 0.0.0.0*: on any shared office, hotel or cafe network that open board was readable and writable by anyone who could route to the port. No Worker hardening ever touched this, because it is not the Worker. Now HOST defaults to 127.0.0.1, binding outward is a deliberate opt-in, and the startup line says so loudly when it happens. Same file, same class: /internal/overview and /internal/project-feed were implemented locally in v0.12.0 (so their payload shape stays testable off the hosted door) with no gate whatsoever โ not even the machine secret production requires. They now mirror production when ID_SECRET is set, and when it is not they are served only while loopback-bound โ so local testing keeps the doors v0.12.0 added, while binding outward closes them rather than exposing the board. Both are GET-only. โก /internal/doc-reload had no method guard, so a GET performed the reload. It is a mutation: it makes a warm TeamDoc drop its copy and re-adopt from KV. Without a verb check, a link, a browser prefetch, or a pasted address bar could fire an ops mutation against the live board โ the entire attack is visiting a URL. doc-reload is POST-only now (which is what the runbook in scripts/push-db.js always used anyway), doc-status stays GET, and the guard lands before idFromName rather than merely existing. โข The same block took a caller-supplied ?key= straight to BOARD_DO.idFromName. ?key=accounts confirmed that key existed and handed back its bytes and updatedAt through /doc-status โ an existence-and-size oracle over the whole namespace, and a way to poke any Durable Object by name. The key is now derived from teamId through acct.teamDbKey(), with teamId validated against ^team_[A-Za-z0-9_-]{1,64}$ (bounded on purpose: idFromName will happily accept a 200-character string). Nothing legitimate loses a door โ the documented runbook and APP_BREAKDOWN ยง3c have only ever used ?teamId=. โฃ The two READ doors now prefer a SIGNED subject โ staged, not flipped. /internal/overview named a person from an unsigned ?email=; /internal/project-feed carried no person identity at all and would read any team's project dues for anyone holding the secret. Both now resolve appolisIdentity(request, env, 'agora'): on overview a signed envelope overrides ?email= rather than merely agreeing with it, and on project-feed a signed caller must be a member of the teamId they ask about โ membership taken from the envelope Appolis signs, never from anything the caller asserts. Deliberately not yet mandatory, and this is the honest part: the only caller of either door today is Kosmos (mission-control/worker.js, the your-companies strip and the linked-project feed), it sends ?email= alone, and it cannot mint an envelope โ only Appolis ID holds this app's key. Making the envelope mandatory unilaterally would have broken a live cross-app surface, which is exactly the failure mode the carry-over rule was written after. So the legacy path stays until the callers move, behind one new secret: INTERNAL_REQUIRE_ENVELOPE=1 makes both doors envelope-only, in seconds, with no deploy โ the same self-flipping shape as the APPOLIS_APP_KEY cutover, and the Agora half of ROTATION_RUNBOOK Step 4. Until that flip, โ โก and โข are closed and โฃ is prepared, not closed โ the doc says so rather than letting a changelog imply otherwise. Tests: a new 13th harness, test-internal-doors.js, 43 assertions. โ ๏ธ CORRECTED IN v0.12.4 โ that harness did not do what this sentence originally claimed. It asserted regexes over worker.js's text and re-declared each guard as a local copy inside the test, so its "RED control legs" were hand-typed literals that read nothing from the source and could never go red. An adversarial review mutated the shipped worker four ways and it reported 43/43 green every time. The rewrite, and the mutation testing that proves it now fails, are in v0.12.4. Verified by reproduction rather than by grep: the dev server was booted three ways and probed eight times โ netstat confirms 127.0.0.1 on the scratch port used for that probe (3921; the app's own default is 3901) where it used to be 0.0.0.0; loopback with no secret still serves the internal reads (200); POST to one is 405; with ID_SECRET set the right header is 200 and a wrong or missing one is 404; and bound to 0.0.0.0 with no secret the doors are 404 while the app itself still serves 200.runOnBoard's CAS stops two writers RACING, but it cannot stop an informed-looking write from a session that simply never read what a teammate did an hour ago: that is an ordinary sequential write, and no conflict check can catch it. A shared team board is exactly where that costs most, so a landed connector write whose target project saw cowork/stage activity in the last 72h now carries a concurrent_activity block naming up to 3 of those events โ {when, who, what} plus the CHECK-BEFORE-WORK rule text โ with the project resolved from the write's own result (projectId/id) or its project argument (a quiet project, or a write that resolves no project, gets no block โ silence means nobody was there, not that the guard is off). It lives on the dirty-save path inside handleMcp, so BOTH doors get it at once (/mcp/<token> and the Appolis Connector room stay one brain via mcpOpts, per ยง3), and it is advisory by construction: the whole echo is wrapped so it can never fail a write that already landed, and image results are left alone. Took the bug fix with it: Kosmos's version filtered on e.ts โ but only log_work writes a ts; every stage event carries date alone, so that branch was DEAD CODE and the guard silently reported nothing for any session that had not hand-logged (Kosmos's live board as measured on port day: 36 stage events, 0 with a ts). Agora accepts either field, so it does not inherit the blind spot on day one. Suite unchanged: 309 green across 12 harnesses. Also in this release window, committed as-found: the /internal/mcp signed identity envelope hardening (todo_1060/todo_1096, authored by the 2026-07-26 suite-audit session, deployed and verified live before committing โ an impersonation POST with the bare header now 403s with "a signed Appolis identity envelope is required"). The suite hub used to forward a person as a bare x-appolis-email header and this worker took it as proof, so anyone holding the shared machine secret could act as ANYBODY on their team's board, with no connector token and no hub involved. Identity on that door is now a short-lived HMAC envelope (x-appolis-id, keyed to THIS app, audience-checked, carrying the person's entitlement role/type and real team memberships โ which also frees the door from the stub roster); hubOk() is transport trust only and never says WHO a call acts as; and the cutover self-flips on APPOLIS_APP_KEY โ the bare header is honored while it is unset and dead the moment it is set (it is set in prod). ยงยง2, 3 and 3c stopped describing the bare-header trust this closed; NOT fixed by this and still open as todo_1096: /internal/overview, which identifies its subject from an unsigned ?email=, and /internal/project-feed, which carries no person identity at all โ a pure machine-secret door, arguably the wider hole. Ritual note, on the record: this entry was written 2026-08-13 by the weekly session โ v0.12.2 shipped WITHOUT it and the changelog lagged the shipped release by a week, caught by weekly check #2 (the check that exists because Kosmos once shipped two releases without bumping its badge; here the badge was right and the changelog lied). Honesty compels the fuller record: the same-commit ritual had already been bent once before โ v0.12.0's bump (fcb7134) carried no ยง8 entry either; its entry arrived 28 minutes later in the v0.12.1 commit. This was the first time the gap was days rather than minutes, which is the difference between a ritual bent and a doc that lies. Same slip, fixed in the same late commit: config.js versionDate still said 2026-07-26, so the โฐ menu and /breakdown header showed "v0.12.2 ยท 2026-07-26" for a release that shipped 08-06./breakdown twin were rewritten against the source this release; then two adversarial readers were pointed at the rewrites with one instruction โ go read the code and confirm every sentence. What came back was not a list of typos. Writing down what an app does turns out to be a very good way to discover that it does something else. โ /ics never compartmentalized at all. Same class as the /breakdown hole closed in v0.11.2, on the door nobody looks at: read-only, token-authed, and loading the RAW board. A contributor's calendar feed carried dues and events from assigned-only projects their own screen hides โ and a calendar subscription refreshes on its own schedule with no in-app affordance, so there was nothing to notice and no way to notice it. It now resolves the real agoraRole (failing CLOSED to contributor) and renders through the same viewOf as every other read. Both verifiers found this independently, from different starting points. โก viewOf's zero-copy fast path was skipping the Trash. It returned the raw doc for any boss on a non-frozen call, which was exactly right while visibility was the only thing this filtered โ a boss sees every project anyway. v0.12.0 quietly made it also the place a deleted project is hidden, and /breakdown calls viewOf(db, {}), so a manager's live doc still listed the entire Trash. A correct optimization became a leak the moment the function grew a second job. The fast path now fires only when there is nothing deleted to hide, so the zero-copy case survives for the board that has never deleted anything. โข Four write routes had never met the capability model. POST/DELETE /api/events, POST /api/playbook/insights, POST/PATCH /api/clients and POST /api/import/notes predate v0.8.14 and were never brought in, so a viewer โ read-only by definition โ could write to the team calendar, author the team's playbook, add clients, and bulk-import notes onto the shared board. The bulk-import one is the widest, because it writes N notes per call. All four now use the same canWork/cap gates every other write already uses, so there is one rule rather than five. Writing the gate exposed a fourth bug on the spot: denyWork was never destructured out of boardCaps, so the new events gate would have thrown a ReferenceError โ a 500 on a live route โ the first time a non-boss touched it. The test caught it before the deploy did. Also: the last live copy of the Kosmos section id (sectionFor() defaulting to 'apps') is gone โ harmless today because the fallback landed on Initiatives anyway, but it is the function that decides stall stamping, and a silent wrong default in a stall path is the kind of thing discovered a quarter later. The /breakdown twin now filters null rows at the source arrays rather than sprinkling p && downstream (which is how one call site was still throwing on a corrupt doc), and the worker finally passes it the viewer argument it has always accepted โ so the page says "you are signed in as X โ capability editor" instead of describing capabilities in the abstract. The features deck moved from assets/ to public/, because assets/ is not the directory the deployed Worker serves and the deck was therefore unreachable in production. 33 new assertions, including a control leg that renders the raw ICS feed and asserts it still leaks. 309 green across 12 harnesses.p.deleted: viewOf (the connector choke point, which closes all ten readers and every future tool in one line), both branches of GET /api/db, /internal/overview, /internal/project-feed, the ICS feed, /breakdown, and local /pf/. A project still visible on even one door is worse than no feature, because then the board and the AI disagree about what exists. Two things fell out of this. Bosses used to take a raw-doc shortcut through GET /api/db โ they can't any more, because they are exactly the people who can delete. And the same rewrite closed a pre-existing leak: the note-trash shipped unfiltered, so deleted notes from assigned-only projects reached people who cannot see those projects. Invisible only because nothing rendered DB.trash; the moment Trash became a surface it would have been a real disclosure. The response key is projectTrash, never trash. db.trash is already the deleted-NOTES ring buffer, it is filtered by projectId, and schema.nextId() scans it for id allocation. Kosmos reused trash for deleted projects and silently shadowed all three โ it never noticed, because its client never reads DB.trash. Layer B โ the write path. Delete is gated on isBoss, never effRole: that is deliberately raisable per project, so a contributor holding manager on ONE project could otherwise bin the team's work. The timestamp is minted server-side โ Kosmos writes the client's value straight through, which hands a 30-day retention window to whoever's browser fired the request (a skewed clock, a stale tab, or a hand-crafted deleted:'1970-01-01' purges on the next sweep; a non-ISO value makes Date.parse NaN and the project sits in Trash forever). DELETE /api/projects/:id is the hard purge and requires p.deleted already set, so no path goes live โ gone. It cascades notes/todos/events, drops note-trash rows for the same project, and scrubs the dead ids from playbook seenIn while keeping the accumulated insights โ a shipped project's retro lesson should outlive its tile. Blobs are collected, never deleted inline: under CAS a rejected save is replayed, and deleting a teammate's screenshots for a write that never landed cannot be undone, so the purge hands the names back on the store and the worker drops them after the doc write commits. The 30-day sweep rides the WRITE path. Not GET /api/db: a save inside a read makes N polling teammates race the same purge, each losing writer replays the whole handler, and 15 losses turn a plain board read into a 503. The cost of that choice is that a board with no writes for a month purges late โ which, after Layer A, costs storage and nothing else. Hooked on load once rather than at thirty call sites, because "mostly swept" is the kind of half-truth that becomes a support question nobody can reproduce. Also fixed in the write path, each its own quiet bug: restoring a note into a deleted project (an undo that visibly does nothing) or a purged one (an orphan silently relabelled "๐ No project"); the bare 409 on a name a deleted project still holds, which now names the Trash; and approvals acting on a buried project from a stale โณ badge. Layer C โ the surfaces. delete_project / restore_project on the connector, gated isBoss as their first line โ gating on canWork would let an editor's AI bin a project its person cannot bin on screen, which is the mirror rule broken in the most expensive direction available. restore_project reads ctx.raw, because after Layer A a deleted project is by definition absent from every handler's db. No hard-purge tool exists: forever stays a deliberate human two-step. The client gets ๐ inside the amBoss() tier, a boss-only Trash view with days-left, Ctrl+Z support, an inline Undo toast โ and a confirm that counts what it takes off teammates' plates and says they will not be notified, because Kosmos is solo and deleting there only ever affects the deleter. ๐งน The v6.99 Kosmos bundle, all four present here and all four worse on a shared board for one reason: Agora's 45s poll re-renders on any teammate's write, so each fired unprovoked while you were merely reading. Scroll restore (the worst โ a teammate ticking a box 40 lines up threw you to the top of a long board), collapsed-panel width, clear-search-on-hashchange, hide-undo-on-login. Took only the four collapsed-width lines from Kosmos's fitPGrids: its 589#48 user-sized-panel branch depends on data-hset and .gsized CSS Agora does not have, and dragging it in would silently break every panel's height. Agora-only addition beyond the bundle: showLogin() also clears the undo stack โ reverts here are inverse ops against a SHARED note, and showLogin fires on a mid-session 401, so a populated stack could otherwise be replayed by whoever signs in next. ๐ซ (a) tier on the master-admin roster only โ publicAccount also feeds /api/login, /api/me and /api/profile, and a billing attribute has no business on a login response. โณ (b) a revoked grant used to take up to 30 days to land for anyone who used Agora's own login form: the session token is self-contained, with no revocation list, while the SSO cookie re-stamps every request. Rather than shortening the session (a weekly re-login for everyone, regressing a length chosen on purpose), resolveAccount now prefers the live Appolis ID resolve when its cookie is present, throttled to one service-binding round-trip per 10 minutes, falling back to the local session when the ID is unreachable. Known limit, documented rather than papered over: on the workers.dev host the appolis_id cookie is Domain=.appolis.app, which browsers reject there, so that host never re-resolves โ treat it as break-glass only. ๐งฝ (c) comments that actively misled. The dangerous one: scripts/push-db.js line 1 called production "the stub team's board" while line 3 warned it overwrites the live board โ and it never mentioned that since v0.10.0 a push does nothing until you poke /internal/doc-reload, because a warm TeamDoc re-mirrors its own older copy straight over it. Also: server.js hardcoded teamsStub:true so local dev permanently badged the real production board "stub (P1 pending)", and worker.js's header claimed role enforcement was "read/write basics only" forty lines above the code that disproves it. ๐ง (d) server.js drift that made local testing LIE about shipped features โ the worst kind, because it doesn't fail, it misinforms. The gating fix is the body cap: 20e6 bytes of base64 decodes to ~14.3 MiB, so an 18 MB screen recording was rejected before the handler ran and the regex and cap fixes were dead code without it. Plus video MIME types (a stored recording was served as octet-stream and <video> refused it silently), and the three routes that 404'd locally: /api/hub-apps โ which gates both the ๐ FAB and the ๐ hub popup โ /api/bug-report, and /api/my-connector, whose 404 printed the raw string "unknown api route" at the user. The two pure-read /internal doors are now implemented locally and the two DO-backed ones answer 501; OAuth discovery 404s like production, because a 200 text/html is exactly what makes an MCP client start a flow that does not exist. And agoraRole + id now ride the local me payload โ without them myRole() fell back to admin and p.access.assignments[DB.me.id] read an undefined key, so the โณ approvals gate and the entire assigned-only model were untestable on a developer's machine. ๐ The docs caught up too. ยงยง1-7 had not tracked reality since v0.6.1 โ eight releases existed only as changelog lines โ and the /breakdown twin was worse, because teammates read it in-app: it was still the un-reskinned Kosmos engine file, naming data/db.json as the production store and advertising "deals" and "people" sections cut in v0.2.0. Both rewritten against the code. And docs.appolis.app now renders each app's living markdown at build time instead of copying whatever stale HTML sat next to it, so a published breakdown can no longer lag the doc it came from. 276 assertions green across 11 harnesses โ 88 new for Trash (including the connector mirror-rule cases and a client-wiring pass), 49 for the bundle and cleanups. Verified end to end in a browser: delete โ Trash โ restore returns the project, its to-do and all four scaffold lists; purge leaves no orphans./breakdown was leaking assigned-only projects, and the test suite was fiction. The hole. The live breakdown page is a REAL-DATA surface โ it renders the team's actual projects, stages, sections and note/to-do counts. It was signed-in-gated but never applied the capability view, so any viewer or contributor who typed the URL read every assigned-only project their own screen hides. ยง3a promises those "VANISH" for the unassigned; on this one route they did not. The role wasn't even resolved on the path (AGORA_ROLE was computed only for /api/*), so there was no actor to compartmentalize against. Fixed with the SAME viewOf every other read uses โ deliberately not a boss-only gate, because the live-doc rule makes this page teammate-facing on purpose. Compartmentalize it, don't hide it. Found by an adversarial recon pass over the v0.12 surface, unrelated to the feature being planned. The bigger problem it exposed: test/ did not exist. Ten changelog entries cite assertion counts (31, 20, 19, 21, 18โฆ) and every one of those harnesses lived in a session scratchpad โ unrunnable by anyone else, gone when the session ended, and impossible to regression-check. The counts were true when written and unverifiable forever after. All eight are now committed under test/, relativized so they run from any checkout, with a zero-dependency runner: node test/run.js (or node test/run.js roster to filter). 127 assertions, green. Plus 12 new ones for the hole above โ including a control leg that renders the RAW board and asserts it STILL leaks, so a green run can never be a fixture that stopped reproducing the bug.attachments array alongside the stale list, losing screenshots on a second bug filed minutes later; screenshots now ride inside the add op, which appends them server-side. flushNoteEdits โ the note modal's save-on-close โ carried the whole array to save item TEXT, so typing in one row re-asserted every other row, including a checkbox someone had just ticked; it now sends one set per row actually retyped, alongside title/text in the same request. Still whole-array, by design: vault seal/release and noteโlist conversion, where the array genuinely IS the change. (2) ๐ The vault could eat a sentence, permanently. Sealing encrypted whatever the note held when the modal opened. Type a paragraph, hit ๐, and the plaintext was never saved โ and was now behind a key applied to the OLDER copy. Unlike every other write bug in this app, nothing recovered it: no poll, no replay, no undo. Same pass, same class: ๐ Pin, Archive, ๐ Delete and both line-extraction paths also tore the overlay down without saving โ Delete matters more than it looks, because it is undoable, so restoring returned a note missing its last line. All now flush through flushIfOpen(id), which flushes only when the open editor belongs to that note โ flushNoteEdits reads #ed-title/#ed-text straight off the DOM, so an unguarded call from a non-modal context would stamp the open note's text onto a different note, a worse bug than the one being fixed. (3) ๐ค An AI could assign work to the wrong person. mcpOpts built its member list from every provisioned account and stamped everyone Owner-if-root-else-Basic โ a leftover from before the teams registry shipped. So assign_work, add_todo(assignee) and open_items(assignee) ran against a DIFFERENT list from the ๐ค pickers: it included people disabled in Appolis ID and people on other teams entirely, with roles nobody granted. That breaks the platform rule directly โ a person's AI gets exactly that person's permissions, never more โ because the picker and the connector disagreed about who exists. The real directory is now one function, teamRoster(), read by both connector doors and the HTTP picker door, so there is no second copy to drift. Registry unreachable or a stub team still falls back to the provisioned roster rather than throwing. Tests: 19 new call-site cases (each converted shape run against a doc a teammate has already changed, with a red control leg proving the old whole-array write still erases the tick) + 21 roster cases + 18 flush-before-teardown cases. Existing suites re-run green: mirror-rule 31, v0.9.2 20, worker-tier 16, one-write-channel 8/8, item-ops A/B.PATCH /api/notes/:id { ops: [...] } โ with three verbs (set / add / remove; check, rename, flag and assign are all set with a different value key, so there is ONE resolver and ONE permission funnel). Ops resolve against the FRESH doc inside the CAS replay, so a replay re-resolves rather than re-erasing. Items gained stable non-numeric i_ ids, stamped lazily on every server write path โ non-numeric on purpose, because the connector's findItem treats a numeric ref as an array index and a bare number would silently resolve to the WRONG item. Identity is the safety property: an id NEVER falls back. A missing id is proof the item is gone, not licence to guess โ duplicate item text is normal on a ๐ Bugs list, and guessing would tick the wrong task, which is worse than the bug being fixed. A pre-stamp snapshot (every item on the live boards today) resolves transitionally by index VERIFIED against text, then by unique text, then refuses. Verified live: a stale id returns gone and does not touch the item that happens to sit at that index. The client self-heals: every reply carries the fresh note and the browser absorbs it in place, so each tap doubles as a mini-sync and the 45s poll window stops mattering on a busy list. An unresolvable op is a neutral โป toast plus a refreshed list, never a silent no-op (that is the original bug in a new hat) and never a scary error. Also fixed here โ a worse bug than the tick: all four UNDO paths reverted by PATCHing a whole-array snapshot captured before the action. An undo step can sit on the stack for minutes, so Ctrl+Z rolled the list back to that moment and erased every teammate change since, while the person believed they had undone one thing. They are now INVERSE OPS (un-check that item; restore that exact item, id and all, at its index). Converted: all four check-a-box implementations, the item editor, add/delete/assign on the main surfaces, the decision form (the channel an AI polls for a ๐ STOP verdict โ a stale overwrite there could un-check someone's STOP), and the four undos. Still whole-array, by design: vault seal/release and noteโlist conversion genuinely replace a list. Still whole-array, to convert next (~11): item delete in the note modal, itemโto-do, to-doโlist, tracker import append, move-item-to-project, add-via-modal, line extraction, and the quick-add paths โ lower-frequency than ticking, same class of bug. The legacy array path stays supported for them and for any tab running older code mid-deploy; sending both ops and listItems is a 400.TeamDoc) whose NAME IS THE KEY it owns, so every isolate worldwide resolves the same object for the same board. Loads come back fresh carrying a rev; saves are compare-and-swap on that rev, so a writer holding a superseded copy is REJECTED and the whole request is replayed on fresh data (runOnBoard, jittered backoff, 15 attempts, then a loud 503 โ never a success toast for a write that didn't land). KV stays a write-through mirror so pull-db.js, external inspection and a rollback all keep seeing the real board. Same A/B test after: 8 of 8 survive, and the harness fails itself if side A stops losing writes, so a green can never be a test that skipped the race. Beyond Kosmos's HubDoc, deliberately: (1) rev/CAS โ HubDoc's unconditional put narrows the race but still lets both writes land, which Kosmos's own changelog flags as owed; Agora is the workload that makes it mandatory. (2) Reconcile on cold start instead of seed-once, adopting KV only when STRICTLY newer by meta.updatedAt โ monotone, so a stale edge read can never win while a real out-of-band push-db.js always can. (3) blockConcurrencyWhile around boot. (4) The doc moves as text with the rev in a header. The load-bearing detail: a CAS conflict is a FLAG on the store, never a throw โ lib/mcp.js wraps its whole envelope in try/catch and would have swallowed a thrown conflict into a 200, leaving the AI door silently losing writes while the web door looked fixed. Neither handler file changed by a character. Ops: /internal/doc-status (the rollback gate โ mirrorDirty means KV is BEHIND the DO) and /internal/doc-reload (the poke a WARM DO needs after any out-of-band KV write). Kill switch: wrangler secret put BOARD_OFF = 1 reverts to direct KV in 30 seconds with no deploy. โ ๏ธ WHAT THIS DOES NOT FIX, be straight about it: two people checking items off the SAME list at once. The browser PATCHes the whole listItems array from a snapshot up to 45s old, so the staleness is in the PAYLOAD, not the storage โ CAS rejects it, the replay re-applies the same stale array on fresh data, and the checkmark still vanishes, now deterministically instead of racily. This release is the correctness FLOOR everything else stands on; item-level operations (or If-Match on the item) are what actually fix the checkmark case, and that is the next increment. Do not tell the team that one is fixed yet.boardCaps() that BOTH doors import (one implementation, so they cannot drift), a single READ CHOKE POINT that hands every tool a compartmentalized frozen view (hidden projects and everything filed under them are not in the arrays โ closing all ten readers, every findProject probe and every error-path candidate dump at once, with future tools safe by default), and per-handler WRITE gates that mirror the web door including its pending flows (an editor's connector add lands pending; a contributor's completion returns awaiting_approval instead of lying that it is done). The worker now resolves the REAL role at both connector entrances and fails CLOSED to contributor. Also: project creation is owner/manager-only on both doors (it had NO role gate at all โ a viewer could create projects), archiving/re-filing carry the same gate, and the ๏ผ New tiles hide from those who cannot use them. Three traps the design pass caught before they shipped: check_item was defined TWICE and the later copy won (gating the first would have shipped a check that never ran), the role placeholder would have demoted every real manager, and the frozen view turns a missed write-repoint into a loud throw instead of silent data loss. 31-assertion role ร project-shape matrix, plus the earlier 31 tier + 16 identity assertions still green.agora grant with type:'basic' is a USAGE-capped preview: every feature stays open, the volume is small. tierFromEnts() stamps account.tier on the registry at every ID resolve (masters and hub insiders โ who carry an app:'' grant โ are exempt; an unknown type fails OPEN), re-stamped each time so an upgrade in the ID admin lands at next login. basicCap() in lib/api.js enforces 1 ACTIVE project per pipeline, gated on ownsBoard so a Basic account is only ever capped on a board it OWNS and never while contributing to someone else's team. Enforced on BOTH doors โ web (POST /api/projects, plus any PATCH that lands a project active in a pipeline) and connector (create_project via ctx.tier/ctx.ownsBoard from mcpOpts, which covers /mcp/<token> and the Appolis Connector room at once) โ with friendly 403s that say what to do. One honest caveat: the connector door never sees an entitlement list (it passes undefined deliberately, so it can't clear a stored tier), so a Basic account that has ONLY ever used the connector carries no tier until its first web/SSO sign-in stamps one. 20/20 cap-matrix cases tested, including every case where the cap must NOT fire. Three deliberate divergences from the Kosmos reference, all structural: (1) the ownsBoard gate, because an Agora board is shared and a teammate's projects must never consume your allowance; (2) no archived-project cap โ Agora has no project-delete path, so capping archives would wall a pipeline shut with no way out; it lands with the ๐ Trash port, and basicCap already ignores p.deleted so that's a zero-line change; (3) "own at most 1 team" is NOT enforced here โ Agora contains no team-creation or ownership-granting code at all (the only team-shaped write in the repo is the lastTeamId pointer move), so that cap belongs to the Appolis ID lane and is flagged there. Known gap, same as Kosmos: a live native agora_auth session isn't re-stamped, so a tier change applies at next login โ a non-issue in practice because Agora's real door is the shared SSO cookie, which re-stamps on every request. An adversarial review of this diff found five real defects before it shipped; all five are fixed and regression-tested (47 assertions). (1) The cap was defeated in ONE UI CLICK: PATCH /api/projects/:id moved a project between pipelines with no re-check, so creating in an empty pipeline and re-filing via the "Filed under" dropdown gave unlimited projects โ the gate now evaluates where a project ENDS UP (destination section + final archived state, so a combined unarchive-and-move is caught) and rejects an unknown sectionId with a 400 instead of silently storing a section no grid can render. (2) basicCap counted TEAMMATES' projects, so a Basic owner's first-ever create could 403 while pointing at work that wasn't hers โ projects now carry createdBy (both doors) and only your own consume your allowance; pre-existing projects have no stamp and so cap nobody. (3) type is a FREE-TEXT field in the ID admin and that same screen labels a hub role "Basic" with a capital B, so an exact-match on 'basic' would have silently stamped the account unlimited โ matching is now case- and whitespace-insensitive. (4) The note flush was wired only to closeModal, while the phone/browser BACK button (the code's own comment calls it the primary close path) tore the overlay down without saving โ the flush now runs on the popstate path too, snapshots synchronously, covers checklist ITEM text, and sends one request instead of two. (5) Pre-existing privilege escalation, fixed here: the ROOT mapping tested for any app:'' grant, but the ID service also gives hub INSIDERS a '' grant at role member โ so any insider who had never used Agora landed on the master account with admin over a real team's board. It now requires '' at role admin (the master's own grant shape); insiders stay exempt from the cap without becoming root. Shipped alongside, all confirmed-broken before this change: silent data loss on the note modal โ title/body saved on onchange (blur only), so closing or converting a note with the caret still in the box threw the typing away, and on a shared board that text is a teammate's; flushNoteEdits() now runs before close, BACK, noteโlist conversion, move and promote. The area repair is self-healing โ fixing the writer alone would have left every existing row pointing at the phantom apps area, so normalizeAreas() runs on load in both doors and remaps any unknown area to ideas, persisting on the next write (no separate migration burst, which also respects the one-write-channel rule on a shared board). Verified against the live board: both projects and all 15 notes โ including ๐ Bugs, ๐ Action items and ๐ง Blockers โ were invisible in the โ
Lists view and the ๐ Everything-else dashboard, and now resolve. The --mintSoft CSS variable was never declared despite three v0.8.14 buttons using it (โ Approve, ๐ Whole team, Save access), so the calm-mint affirmative fill never rendered on the Approvals and Access modals. The connector wrote areaId:'apps', a Kosmos area that doesn't exist in Agora, making connector-made work invisible in the ๐ Everything-else dashboard โ now 'ideas', with the same fix on five other defaults, plus ๐ฌ Content added to the create_project section list (an AI literally could not file content work). The connector's initialize instructions now lead with a ๐ SCAN-FIRST rule โ read overview + ai_worklist + the item's full text before writing, re-scan each burst, prefer additive moves โ because this board takes concurrent writes from many humans and several AI sessions. And local dev gained an actor escape hatch (AGORA_TIER=basic node server.js) so role gates and caps are testable locally instead of only in production; unset, local behaviour is byte-identical to before.seed marker).scheduleFit() replaces rAF-scheduled grid fits (rAF never fires in hidden tabs โ the unfitted-grid holes bug), outer grid rows quantize at 12px w/ data-colw carrying column width (panels hug content, Tetris-dense backfill), collapsed-panel span clamp 4โ16, width-only resize on collapsed cards (kills snap-back), โผ๏ธ IMPORTANT flag on to-dos + list items (editors, quick-add, row markers, sorts first, important through the connector's ai_worklist + API whitelists), ๐ธ screenshots on quick-add bug reports (team-scoped uploads, thumbnails in the item editor), finished items ALWAYS start collapsed ("show N done" in the note popup + full to-dos view). (2) Five-list scaffold (the Tyler ask): every project of EVERY section now seeds ๐ Bugs ยท โ๏ธ Function changes ยท ๐จ Design changes ยท ๐ฎ Future Updates at creation (seedProjectLists in lib/api.js, wired into the API + connector create paths โ same code shipped to Kosmos v6.49); existing team_spartan board backfilled (4 lists).provisionFromAppolis maps an Appolis account carrying the * (master) entitlement onto ROOT even when the emails diverge โ Kosmos parity; the deploy PASSWORD key is break-glass only, never the identity. Tyler's one login opens Agora as the master account by construction (his ID was already email- and appolisId-linked to root; this makes it doctrine).GET /api/team/members now reads the REAL Appolis registry directory (GET /id/teams/<id>/members over the binding) โ registry members map onto local account records (lazy provisioning by appolisId/email, so existing assignment chips + My Work ids stay valid), disabled registry IDs drop out of the pickers, roster kept as the stub/offline fallback; teams themselves were already real the moment Appolis v0.6.0 shipped (teamsFor() spoke the contract from day one โ prod team_spartan adopted the existing board, teamsStub false). Also CONFIRMED no-op on the deploy-window stale-shell bug (todo_669): Agora serves a plain unhashed /app.js from the static shell โ no hashed bundles ever get deleted, so the Hermes v0.76.1 404 mode can't occur; the ETag refresh banner covers plain staleness.GET /api/team/members stub directory; cross-app door GET /internal/overview (ID_SECRET-gated) so Kosmos can show your Agora teams/projects.