๐Ÿ›

Agora โ€” Living Breakdown

The team version of Kosmos ยท agora.appolis.app
โ† Appolis ยท the city of apps

๐Ÿ› AGORA โ€” Living App Breakdown

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.

1. What Agora is (and isn't)

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

2. Identity โ€” Appolis ID from day one

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.

3. Teams โ€” the shared board

ConceptHow it works
Board storageONE 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 channelEvery 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).
AttachmentsKV att:team:<teamId>:<name> โ€” images and screen recordings are the team's too
Membership + rolesThe 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 capabilityagoraRole โ€” 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 doorsTWO 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 doorGET /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.

3a. ๐ŸŽญ THE MIRROR RULE โ€” a person's AI gets exactly that person's permissions

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.

3a-ii. Who may START work (Tyler, 2026-07-24)

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

3b. Assignment (v0.4.0 โ€” who's doing what)

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.

3c. Cross-app + machine doors

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.

DoorWhat 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&projectIdv0.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/mcpv0.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.

3d. ๐Ÿ—‘ The Trash โ€” deleting a project (v0.12.0)

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.

FilterCovers
boardCaps().viewOfThe 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:

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.

4. Team-shaped sections (v4 โ€” ecommerce-first, per Tyler's reactions)

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

SectionPipelineStalls at
๐ŸŽฏ InitiativesIdea โ†’ Scoped โ†’ In Motion โ†’ Review โ†’ Shipped โ†’ Livereview, shipped
๐Ÿ›๏ธ Products & DropsIdea โ†’ Sourcing โ†’ Listing โ†’ Launch โ†’ Sellingsourcing, listing
๐Ÿ“ฃ CampaignsBrief โ†’ Planned โ†’ Producing โ†’ Review โ†’ Live โ†’ Resultsreview
๐ŸŽฌ ContentIdea โ†’ Brief โ†’ Producing โ†’ Review โ†’ Approvedreview
๐Ÿช Store OpsIntake โ†’ Next Up โ†’ Doing โ†’ Review โ†’ Donereview
๐ŸŒ Website ChangesRequest โ†’ Scoped โ†’ Building โ†’ Review โ†’ Livereview
๐ŸŽช EventsIdea โ†’ Planned โ†’ Prep โ†’ Live โ†’ Wrappedprep
๐Ÿค Partners & AffiliatesProspect โ†’ Talking โ†’ Terms โ†’ Onboarding โ†’ Activeterms

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:

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

5. What diverged from the Kosmos engine

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

6. House rules honored

7. Deploy state & blockers

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

ItemState
Worker agoradeployed โ€” custom domain agora.appolis.app + workers.dev fallback
Static assets ASSETS โ†’ ./publicnot_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 = 49c1d0ec0b05464e8ded3f951151aac5the board MIRROR + attachments (att:team:โ€ฆ) + the accounts registry
Durable Object BOARD_DO โ†’ class TeamDocmigration 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 bindingsAPPOLIS_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 varmust 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 secretv0.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 secretUNSET = 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 IDregistered 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 roleP3 (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.

HarnessAsserts
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

8. Changelog

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.

โ‘  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.

Rendered from agora/APP_BREAKDOWN.md ยท this page is generated โ€” edit the markdown, not the HTML
print this page for a PDF