From 276b832891ea7e25eb778bed1c67f6fd6ef99814 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:33:35 -0700 Subject: [PATCH 1/7] =?UTF-8?q?docs(adr):=20ADR-022=20=E2=80=94=20persona?= =?UTF-8?q?=20colleagues,=20separating=20who=20an=20agent=20is=20from=20wh?= =?UTF-8?q?ere=20it=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft for design-lead input and Sam's ratification. The catalog sells a RUNTIME as if it were a colleague: picking `claude-code` picks a CLI the user must go launch, so "Hire an agent" hands back a seat that cannot do anything yet and nothing says so. That contradicts a thesis we already hold — CLAUDE.md's "identity is portable, separate from runtime", and ADR-001's model of exactly that split. The UI only ever offered the axis users care about least. Grounded in production measurement, not inference: 21 real signups since 08-01, five ever typed, ZERO ever received a reply to something they said. The 08-14 user is the whole argument in one session — honest four-second answers from Scout in Chinese, then the catalog, then a `claude-code` seat with nobody home, then three unanswered questions, then gone. Decisions: persona is a first-class object; persona and runtime chosen separately in that order; the hosted half ships on the NATIVE runtime we already have (it already runs four personas off one engine, so this needs no part of ADR-021's milestone track); liveness shown at pick time; and the first-party set retired rather than ported — welcomer and task-clerk gone, summarizer reworked into a reader-specific TLDR, scout kept as persona #1. Names the constraint that shapes everything: V2YourTeamPage:124 routes unentitled users to BYO because hosted burns tokens. The broken path is the free-tier default BY DESIGN. A redesign assuming free hosted agents is a pricing proposal in a product costume, so the free allowance is called out as Sam's decision and the consequences section states plainly that a zero allowance makes this a better catalog and not a better activation rate. Design questions left OPEN for fable-lead rather than pre-answered. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8 --- docs/adr/ADR-022-persona-colleagues.md | 107 +++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/adr/ADR-022-persona-colleagues.md diff --git a/docs/adr/ADR-022-persona-colleagues.md b/docs/adr/ADR-022-persona-colleagues.md new file mode 100644 index 00000000..7f249f84 --- /dev/null +++ b/docs/adr/ADR-022-persona-colleagues.md @@ -0,0 +1,107 @@ +# ADR-022: Persona colleagues — separating who an agent is from where it runs + +- **Status:** Draft (design lead input pending; Sam to ratify) +- **Depends on:** ADR-001 (Installable taxonomy — `source` / `components[]`), ADR-021 (hosted runtime, credits) +- **Supersedes when accepted:** the v1 agent catalog surface (`/v2/agents/browse`, `AgentsHub`), and the first-party app set as currently constituted + +## Context + +### What a user actually experiences today + +Measured on production 2026-08-14, from message history, not inference. + +A user registered at 03:09. In their own workspace they asked Scout two questions in Chinese and got substantive answers in four and five seconds — including a refusal to invent an answer it could not know ("我没有办法查看自己底层具体的模型型号…不会瞎猜一个型号给你"). That is the product working. + +Eighteen minutes later they went to add an agent, landed on the v1 catalog, installed the `claude-code` template, never started a local session, and asked it the same question three times across two pods. Silence. They left. + +They are not unusual. Across 21 real signups since 2026-08-01: + +| stage | count | +|---|---| +| signed up | 21 | +| verified email | 21 | +| got a workspace | 21 | +| ever typed anything | 5 | +| **ever received a reply to something they said** | **0** | + +Four of the five who typed had `@`-mentioned a BYO seat with no process running. The fifth greeted a Scout that was in dormant silent-mode at the time. + +### The structural cause + +**The catalog sells a runtime as if it were a colleague.** Picking `claude-code` picks a CLI the user must go launch. "Hire an agent" therefore hands back a seat that cannot do anything yet, and nothing in the flow says so. + +This contradicts a thesis we already hold. CLAUDE.md: *"Agent identity is portable — profile (identity, memory, social history, pod memberships) is separate from runtime."* ADR-001 models exactly that split. The UI never expressed it: the one axis a user is offered is the runtime, which is the axis they care about least and can act on least. + +Meanwhile the thing that demonstrably works — Scout — is a single fixed persona nobody chose. + +### Two further facts the design has to absorb + +**The catalog leaks.** `/api/registry/agents` returns 50 entries, of which 21 are internal or smoke-test rows (`smoke-claude`, `demo-target`, `demo-clean2`, `smokea50698-*`, `pod-architect`, `cl-critic`, `claude-on-dev`, `sam-local-codex`, `hq-support`, `moltbot`). The marketplace path excludes ephemeral rows; this endpoint has no such filter, and it is linked from the logged-out landing footer. + +**Hosted compute is rationed on purpose.** `V2YourTeamPage.tsx:124`: + +```js +const primaryHirePath = isEntitled ? '/v2/agents/browse' : '/v2/agents/byo'; +``` + +Unentitled users are routed to BYO **because hosted agents burn our tokens**. The broken path is the default for free users by design, not oversight. Any redesign that assumes free hosted agents is a pricing proposal wearing a product costume. + +## Decision + +Replace the runtime catalog with a **persona catalog**, and make runtime a separate, later, changeable choice. + +### 1. A persona is a first-class, user-facing object + +Role, system prompt, skills, tools, voice, avatar — the things that make a colleague. It is **not** a runtime, and it does not imply one. + +Exact field set and which parts a user picks vs. we curate: **design lead's call, deliberately open below.** + +### 2. Persona and runtime are chosen separately, in that order + +Pick who → pick where. "Where" offers hosted (native today, pi later per ADR-021) or your own machine (BYO). Changing where must never change who — that is ADR-001's identity-continuity rule, and it is the property that makes the split worth having. + +### 3. The hosted half ships on the runtime we already have + +The native runtime already executes four distinct personas off one engine with different prompts. **It is already persona-parameterized.** A persona picker over the native runtime delivers a working colleague in one click today, with no dependency on ADR-021's milestone track. + +This is the single highest-leverage fact in this document: the hosted experience is not a future capability, it is a shipped one with a fixed cast. + +### 4. Liveness is shown at pick time + +A hosted persona is reachable by construction. A BYO one may be `never-connected` or `gone-dark`. `deriveAgentState` already answers this. The user must see it **before** they invest, not discover it through silence. + +### 5. The first-party set is retired, not ported + +Sam's assessment, adopted: + +| app | disposition | why | +|---|---|---| +| `pod-welcomer` | **retire** | Scout already welcomes, and does it better | +| `task-clerk` | **retire** | agents read and write tasks through MCP now; a dedicated clerk is a v1 workaround | +| `pod-summarizer` | **rework, do not keep as an always-on agent** | becomes a user- or auto-triggered TLDR for a long thread a *specific reader* has not caught up on — a feature of the room, not a resident | +| `scout` | **keep** | the one that works; becomes persona #1 rather than the only persona | + +Retirement must honour ADR-001 identity continuity: uninstalling never deletes the Agent component's User row or memory. + +## Open — design lead (fable-lead) owns these + +1. What a persona *is* as a user-facing object, such that "Code Reviewer" reads as a colleague and not a config blob. +2. The pick-then-place flow, and where it lives: replacing `/v2/agents/browse`, folded into Your Team, or reached from the pod invite. +3. How personas differ in-room. Scout's voice works; what generalises, and what must vary per role so three colleagues do not read as one model wearing hats. +4. How rationing appears without feeling like a paywall bolted onto an empty room. + +## Open — Sam + +5. **The free hosted allowance.** Some non-zero amount of hosted persona use for unentitled users, or personas gated behind entitlement and free users still landing on BYO. This is the decision the rest depends on, and it is a money decision. + +## Consequences + +- A new user can have a working colleague without installing anything. That is the activation path we have never had. +- The v1 catalog, its 5,039-line component, and the leak of internal agents all go away as a side effect rather than as separate cleanup. +- BYO stops being the default first experience and becomes the graduation step it should always have been — which is also where today's honesty work (#943, #945, #947) pays off, since by then the user has a working agent and is choosing to add another. +- We take on curation cost: personas are content, and bad ones are worse than none. +- If the allowance is zero, this ADR delivers a better-shaped catalog and **not** a better activation rate. Worth stating plainly so the outcome is not misread later. + +## What this does not decide + +Whether personas are user-authorable or curated-only; marketplace publishing of personas; per-persona memory scoping. All deferred until the shape above is settled. From 34e6e5e1c8c60e3278d51a22a95c85eea7d73f35 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:43:44 -0700 Subject: [PATCH 2/7] =?UTF-8?q?docs(adr):=20ADR-022=20=E2=80=94=20fold=20t?= =?UTF-8?q?he=20design=20lead's=20decisions,=20and=20correct=20my=20econom?= =?UTF-8?q?ics=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fable-lead answered all four open questions and corrected the ADR on a load-bearing point. **The correction.** I wrote that the persona picker has "no dependency on ADR-021's milestone track." That is true of the ENGINE and false of the ECONOMICS: "a free user hires a second hosted colleague" is substantively M4 (user-created cloud agents + credit metering) arriving early through the UI door. The ADR now names the path that ships without inheriting M4's timeline — curated `builtin` Installables hired as additional installs of first-party definitions, Scout's perUser machinery generalized, rationed by seat count + dailyRunCap, both already scheduler-enforced, NO credit metering. Stated explicitly because an implementer reading "hosted" without it blocks on credits v1 does not need. **The dependency I missed.** Retiring pod-welcomer orphans #834's "pod support agent" — Scout is per-user and My-Workspace-only, so shared pods lose their only candidate resident. A pod hiring a Host persona is what makes that designation real; one connecting line keeps #834 shippable. Design decided (D1–D4): persona = agentName (mold), hire = instanceId (colleague) — the pair the runtime already keys sessions on, so two Code Reviewer hires are different colleagues with separate memory; cards carry evidence (first-person line, what it does first, two-turn sample, liveness) not attributes, with zero runtime vocabulary; the entitlement fork moves from route level to STEP level, killing V2YourTeamPage:124 as the deepest defect — everyone sees one catalog, entitlement decides only what the where-step offers; house style extracted to a shared preamble with identity living in wake policy > tools > deliverable shape > edges, because when a persona speaks is more identity than how it phrases; and rationing denominated in SEATS never messages, no meter on conversation, no buy-button in chat. Two things to do regardless of ratification: filter internal/ephemeral rows out of /api/registry/agents (live leak, marketplace has the filter to copy), and settle a v1 cast of Scout + Code Reviewer + at most one more against three tests — tools that exist today, a two-turn demonstrable deliverable, and a reason to be a resident rather than a feature. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8 --- docs/adr/ADR-022-persona-colleagues.md | 83 ++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/docs/adr/ADR-022-persona-colleagues.md b/docs/adr/ADR-022-persona-colleagues.md index 7f249f84..214b64a4 100644 --- a/docs/adr/ADR-022-persona-colleagues.md +++ b/docs/adr/ADR-022-persona-colleagues.md @@ -1,6 +1,6 @@ # ADR-022: Persona colleagues — separating who an agent is from where it runs -- **Status:** Draft (design lead input pending; Sam to ratify) +- **Status:** Draft — design decided (fable-lead 2026-08-14); **one open decision for Sam: the free hosted-seat allowance** - **Depends on:** ADR-001 (Installable taxonomy — `source` / `components[]`), ADR-021 (hosted runtime, credits) - **Supersedes when accepted:** the v1 agent catalog surface (`/v2/agents/browse`, `AgentsHub`), and the first-party app set as currently constituted @@ -54,17 +54,21 @@ Replace the runtime catalog with a **persona catalog**, and make runtime a separ Role, system prompt, skills, tools, voice, avatar — the things that make a colleague. It is **not** a runtime, and it does not imply one. -Exact field set and which parts a user picks vs. we curate: **design lead's call, deliberately open below.** +Exact field set, and the curated-vs-picked split: **decided in D1 below.** ### 2. Persona and runtime are chosen separately, in that order Pick who → pick where. "Where" offers hosted (native today, pi later per ADR-021) or your own machine (BYO). Changing where must never change who — that is ADR-001's identity-continuity rule, and it is the property that makes the split worth having. -### 3. The hosted half ships on the runtime we already have +### 3. The hosted half ships on the runtime we already have — as seats, not credits -The native runtime already executes four distinct personas off one engine with different prompts. **It is already persona-parameterized.** A persona picker over the native runtime delivers a working colleague in one click today, with no dependency on ADR-021's milestone track. +The native runtime already executes four distinct personas off one engine with different prompts. `NativeAgentDefinition` in `backend/config/native-agents/` **is** the persona object already: prompt, tools, model, caps, wake policy. A picker over it needs zero ADR-021 M1–M3 work. -This is the single highest-leverage fact in this document: the hosted experience is not a future capability, it is a shipped one with a fixed cast. +**But "no dependency on ADR-021" is true of the engine and false of the economics** (fable-lead). "A free user hires a second hosted colleague" is, in substance, ADR-021 **M4** — user-created cloud agents with credit metering — arriving early through the UI door. The path that ships now without inheriting M4's timeline: + +> v1 personas are **curated `builtin` Installables, hired as additional installs of first-party definitions** — Scout's `perUser` machinery generalized — rationed by **seat count + `dailyRunCap`**, both already scheduler-enforced. **No credit metering.** + +This is stated explicitly because an implementer who reads "hosted" without it will block on credits that v1 does not need. ### 4. Liveness is shown at pick time @@ -83,12 +87,66 @@ Sam's assessment, adopted: Retirement must honour ADR-001 identity continuity: uninstalling never deletes the Agent component's User row or memory. -## Open — design lead (fable-lead) owns these +**Retiring `pod-welcomer` orphans a dependency, and the catalog is what repays it** (fable-lead). #834's "the pod's support agent" loses its only candidate resident in *shared* pods — Scout is per-user and My-Workspace-only. A pod that hires a **Host** persona gets a welcome-wake target, which is what makes that designation real rather than vestigial. #834 stays shippable because of this ADR, not despite it. + +## Design — decided (fable-lead, 2026-08-14) + +### D1. A persona is a role template that becomes a colleague at hire + +**We curate, the user never edits in v1:** system prompt, tool allowlist, model, caps, wake policy, deliverable shape — every cost-, safety-, and quality-bearing field. ("Bad personas are worse than none" is the argument for holding all of them.) + +**The user picks:** which role, what to call it, an avatar from a set, where it runs, which room. Plus at most one length-capped free-text **focus line**, appended like the pod-context frame — *"our stack is React + Node"* is the cheapest thing that makes it **your** reviewer. + +**The rename is not cosmetic:** persona = `agentName` (the mold), hire = `instanceId` (the colleague) — exactly the pair the runtime already keys sessions on. Two "Code Reviewer" hires in different pods are different colleagues with separate memory from one mold. That is what makes it staff rather than config. + +**The card carries evidence, not attributes:** a first-person one-liner of what it does in the room; what it will do first when placed ("I'll introduce myself and ask for the repo"); a two-turn sample exchange; the liveness dot. **Zero runtime vocabulary on the card** — hosted/local exists only at the where-step. Model, caps and raw prompt live behind a "how I work" disclosure. + +### D2. One catalog, one flow — the entitlement fork moves from route level to step level + +**The deepest defect is not catalog contents.** It is that `V2YourTeamPage:124` decides *which store you may see*. Kill that fork: **everyone sees the same persona catalog**; entitlement decides only what the **where-step** offers. + +``` +Your Team "Hire" → persona grid (5–7 curated; six is a team you are building, +fifty is a directory) → card → "Where should ⟨name⟩ run?" + · in the cloud — works immediately (availability = the allowance) + · on your machine — free, your own Claude Code/Codex, one-time setup, + and "answers only while your session runs" stated + HERE, before investment +→ room (defaults to My Workspace; pre-filled from the pod invite) +→ it speaks first +``` + +Keep the `/v2/agents/browse` route, replace its contents. AgentsHub's 5,039 lines retire whole. The BYO page stops being a destination and becomes the where-step's local branch — #943 / #945 / #947 connect honesty carries over unchanged. + +**"Speaks first" extends into the room.** A hosted intro fires on placement. A BYO hire renders as an **awaiting seat in-room** — member card shows `deriveAgentState`, owner-only `fixCommand` per the #891 split — so a dead seat is visible *before* anyone types at it. The 03:30 user typed at silence three times across two pods; the room knew and did not say. + +**Liveness must mean "will answer," not "can execute."** `native = reachable by construction` becomes the same lie `lastUsedAt` told for gateways the moment a native persona is capped out or disabled. The chip needs those two states — our fifth typed-something user greeted a Scout in dormant silent-mode. + +### D3. House style is shared; identity lives in wake policy, tools and deliverable shape — not adjectives + +Extract the *company-voice* half of Scout's prompt into **one shared preamble every manifest composes with**: chat-shaped brevity, match the user's language, do-don't-narrate, silence as a valid turn, never invent product facts, propose-don't-do (ADR-020). Tone fixes then propagate to the whole cast instead of N prompts drifting. + +What differs per role, in order of how much it actually differentiates: + +1. **Wake policy** — Scout wakes on every message because a private workspace is 1:1-shaped (ADR-018 D8); a reviewer in a shared pod is mention/event-only. *When* it speaks is more identity than how it phrases. A persona that interjects everywhere reads as the same nosy model in a hat. +2. **Tools** — the allowlist is the role. What it can do is what it is. +3. **Deliverable shape** — one recognizable output form each (verdict-with-findings / TLDR / short answer + done action). Form is recognizable across messages; "professional but friendly" is not. +4. **Edges** — a colleague refuses outside their lane and names who to ask instead. One handoff line per prompt. A persona that answers everything is the model again. -1. What a persona *is* as a user-facing object, such that "Code Reviewer" reads as a colleague and not a config blob. -2. The pick-then-place flow, and where it lives: replacing `/v2/agents/browse`, folded into Your Team, or reached from the pod invite. -3. How personas differ in-room. Scout's voice works; what generalises, and what must vary per role so three colleagues do not read as one model wearing hats. -4. How rationing appears without feeling like a paywall bolted onto an empty room. +**Prompts differentiate; claims deconflict.** Three colleagues not piling onto one message is ADR-018 claims/lease, mechanically — never a prompt instruction. + +### D4. Ration seats and capacity, never conversation + +ADR-021's own ratified principle — *credits buy infra, not tokens* — worn as UX. Paywall-feel has exactly two sources: gating the storefront, and metering the relationship. Avoid both. + +- Full catalog visible to everyone; **personas themselves are never gated**. +- The finite thing is the **hosted seat**, surfaced at the where-step. +- Any persona is hireable free on BYO. +- Denominate the allowance in **seats** — "1 hosted colleague included — Scout", "+1 free hosted seat" — **never in messages**. +- A granted seat converses with **no visible meter**. `dailyRunCap` stays the invisible backstop, surfaced only on hit, in-persona, with a reset time: *"I've hit today's cap — back tomorrow."* A colleague resting, not a coin slot. +- **No buy-button in chat, ever.** Upgrade lives on the seat card and the where-step. + +**If the allowance lands at zero beyond Scout:** the hosted option says so plainly and BYO is offered *with the awaiting-seat UX*. Same economics as today's `:124`, but the constraint now arrives attached to a colleague the user already chose — which is the entire psychological difference between "paywall on an empty room" and "this hire needs a seat." The consequence below still holds: activation continues to hinge on BYO connect conversion. This redesign moves persona-attachment *before* the setup cost; it cannot delete the cost. ## Open — Sam @@ -102,6 +160,11 @@ Retirement must honour ADR-001 identity continuity: uninstalling never deletes t - We take on curation cost: personas are content, and bad ones are worse than none. - If the allowance is zero, this ADR delivers a better-shaped catalog and **not** a better activation rate. Worth stating plainly so the outcome is not misread later. +## Do now, regardless of ratification (fable-lead) + +1. **Filter internal and ephemeral rows out of `/api/registry/agents`.** It ships 21 smoke and internal rows today and the logged-out landing footer links it. The marketplace path already has the filter to copy. This is a live leak, not a redesign step. +2. **Settle the v1 cast: Scout + Code Reviewer + at most one more.** Each must pass three tests — tools that exist in today's MCP surface; a deliverable demonstrable in a two-turn sample; and a reason to be a **resident** rather than a feature. `pod-summarizer` fails the third test, which is precisely why reworking it into a triggered TLDR is the right call rather than porting it. + ## What this does not decide Whether personas are user-authorable or curated-only; marketplace publishing of personas; per-persona memory scoping. All deferred until the shape above is settled. From fca06239e70f43015f8d15b30d447217d1d15c6d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:01:08 -0700 Subject: [PATCH 3/7] =?UTF-8?q?docs(adr):=20ADR-022=20D5+D6=20=E2=80=94=20?= =?UTF-8?q?the=20allowance,=20and=20the=20per-installation=20cap=20trap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **D5 — allowance decided (Sam): ~$1/day/user, one seat, never shown as currency.** That figure is not a new budget; it is what the shipped config already enforces. Scout runs deepseek-v4-flash with dailyRunCap 60, maxTurns 6, maxTokens 12000 — a worst-case run near 50k tokens, so a flat-out workspace lands under a dollar a day. The earlier $10/day idea would have LOOSENED a working limit by 10x. **The trap, and the reason this is written down.** nativeRuntimeService:621 counts on {podId, agentName, instanceId} — PER INSTALLATION. That equals per-user today only because Scout is perUser with exactly one install. The moment a user hires several personas, N hires = N x 60 and "per user" silently becomes "per user per persona." A seat-denominated allowance therefore needs a per-USER ceiling alongside the per-installation cap, which stays as the runaway-loop guard for a single conversation. Also recorded: the abuse surface is account creation, not usage. At ~0.3% utilization instance-wide, honest users are nowhere near the cap; exposure is linear in ACCOUNTS at ~$1/day each, with open registration. The control is rate-limiting or entitling seat grants, not tightening the cap. **D6 — Scout steers work out of My Workspace; shared-pod personas are mention-only.** wakeOnMessage:true bills every line in My Workspace whether or not it was addressed to Scout — but the code's own rationale for that policy is "a private 1:1-shaped room," so a team working there is in the WRONG ROOM and the cost is the symptom. Scout currently creates pods only reactively ("Asked for a new pod →"); it should notice work starting and offer the room. That is also the better funnel: workspace → Scout → a real pod for real work, rather than → pushed toward BYO connect. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8 --- docs/adr/ADR-022-persona-colleagues.md | 27 ++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-022-persona-colleagues.md b/docs/adr/ADR-022-persona-colleagues.md index 214b64a4..9ec118e9 100644 --- a/docs/adr/ADR-022-persona-colleagues.md +++ b/docs/adr/ADR-022-persona-colleagues.md @@ -1,6 +1,6 @@ # ADR-022: Persona colleagues — separating who an agent is from where it runs -- **Status:** Draft — design decided (fable-lead 2026-08-14); **one open decision for Sam: the free hosted-seat allowance** +- **Status:** Draft — design decided (fable-lead 2026-08-14), allowance decided (Sam 2026-08-14: ~$1/day/user, one seat, never shown as currency). Ready for ratification. - **Depends on:** ADR-001 (Installable taxonomy — `source` / `components[]`), ADR-021 (hosted runtime, credits) - **Supersedes when accepted:** the v1 agent catalog surface (`/v2/agents/browse`, `AgentsHub`), and the first-party app set as currently constituted @@ -148,9 +148,20 @@ ADR-021's own ratified principle — *credits buy infra, not tokens* — worn as **If the allowance lands at zero beyond Scout:** the hosted option says so plainly and BYO is offered *with the awaiting-seat UX*. Same economics as today's `:124`, but the constraint now arrives attached to a colleague the user already chose — which is the entire psychological difference between "paywall on an empty room" and "this hire needs a seat." The consequence below still holds: activation continues to hinge on BYO connect conversion. This redesign moves persona-attachment *before* the setup cost; it cannot delete the cost. +## D6. Scout steers work OUT of My Workspace, and shared-pod personas are mention-only + +Two rules, one cost reason and one correctness reason, and the correctness one matters more. + +`wakeOnMessage: true` means **every message in My Workspace costs a Scout turn**, addressed to it or not. A team doing real work in there is billed per line. But the code's own rationale for that policy is *"a private **1:1-shaped** room"* — so a team working there is not merely expensive, it is **in the wrong room**, and Scout answering everything is the symptom rather than the problem. + +1. **Scout steers.** Today it creates pods only reactively — its prompt says *"Asked for a new pod → propose create_pod."* It waits to be asked. It should instead notice that real work has started and offer the room for it. This is also the better funnel: land in My Workspace → talk to Scout → **Scout helps you make a real pod for real work**, rather than → get pushed toward BYO connect. +2. **A persona in a shared pod is mention-only, never wake-on-message.** This is D3's "wake policy is identity" as an enforceable rule: a persona that wakes on everything is a 1:1 assistant, one that wakes on mention is a colleague. Same model, different creature — and the shared-pod variant is also the cheap one. + ## Open — Sam -5. **The free hosted allowance.** Some non-zero amount of hosted persona use for unentitled users, or personas gated behind entitlement and free users still landing on BYO. This is the decision the rest depends on, and it is a money decision. +*(D5 settled the allowance. Nothing here blocks the design; these are sequencing calls.)* + +5. Whether the per-user ceiling ships **with** the first multi-persona hire or before it. It is not needed while Scout is the only hire, and it is required the day it is not. ## Consequences @@ -160,6 +171,18 @@ ADR-021's own ratified principle — *credits buy infra, not tokens* — worn as - We take on curation cost: personas are content, and bad ones are worse than none. - If the allowance is zero, this ADR delivers a better-shaped catalog and **not** a better activation rate. Worth stating plainly so the outcome is not misread later. +## D5. The allowance is one seat and ~$1/day/user — and the existing cap does NOT scale to it + +**Decided (Sam, 2026-08-14): roughly $1/day per user, never shown as currency.** + +That number is not a new budget; it is what the shipped configuration already enforces. Scout runs `deepseek-v4-flash` with `dailyRunCap: 60`, `maxTurns: 6`, `maxTokens: 12000` — a worst-case run around 50k tokens, so a flat-out workspace lands under a dollar a day. An earlier proposal of $10/day would have **loosened a working limit by 10×**, which is the opposite of what a ceiling is for. + +**The trap this ADR must not walk into.** `nativeRuntimeService:621` counts runs on `{ podId, agentName, instanceId }` — **per installation**. Today that equals per user only because Scout is `perUser: true` with exactly one install per workspace. **The moment a user can hire several personas, N hires means N × 60**, and "per user" quietly becomes "per user per persona." + +So a seat-denominated allowance needs a **per-user ceiling in addition to the per-installation cap**. The per-installation cap stays — it is the runaway-loop guard for a single conversation. The per-user ceiling is what makes "1 hosted colleague included" a promise we can price. + +**The abuse surface is account creation, not usage.** At ~0.3% utilization instance-wide (about 4 native turns a day against 1,260 available across 21 users), honest users are nowhere near the cap. The exposure is linear in *accounts*, at ~$1/day each, and registration is open. Rate-limiting or entitling seat grants — not tightening the cap — is the control that matters. + ## Do now, regardless of ratification (fable-lead) 1. **Filter internal and ephemeral rows out of `/api/registry/agents`.** It ships 21 smoke and internal rows today and the logged-out landing footer links it. The marketplace path already has the filter to copy. This is a live leak, not a redesign step. From 0c4a9102ff7da9bf7651aa3dc9996f37863fda1d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:09:18 -0700 Subject: [PATCH 4/7] =?UTF-8?q?docs(adr):=20ADR-022=20=E2=80=94=20fold=20t?= =?UTF-8?q?he=20fleet=20review;=20D1's=20central=20claim=20was=20false?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pod-architect and ux-lead reviewed. Five findings; one invalidates a decision. **BLOCKING (pod-architect Q3).** D1 said "two Code Reviewer hires in different pods are different colleagues with separate memory." False by construction: authController:185 derives instanceId as u{sha256(userId)[0:10]} with NO pod component, while ADR-003 keys memory on (agentName, instanceId). Two hires = two install rows, ONE colleague, ONE mind. Verified at source before folding. Resolution adopted: one colleague per user per persona, present in N rooms, one memory. Not a climbdown — it is the better product and matches the portable-identity thesis; a colleague you work with in several rooms is still one person who remembers you. Ships with two consequences: the hire flow says "add your Code Reviewer to this pod", never "hire another" (the UI must not promise isolation the data model lacks), and cross-pod memory bleed becomes a real surface to check in ADR-003 scoping BEFORE a second hire is offered. **pod-architect Q1** — the curated half holds; the picked half does not. Name, avatar and focus line land in AgentInstallation.config, a Map of Mixed, so persona-vs-hire is a convention over an untyped bag. Typing it is v1 work: an untyped bag is exactly how the config.runtime Map silently defeated three separate readers earlier today. **pod-architect Q2** — better than feared AND a live leak. Wake policy is already per-install (agentMentionService:816/:876, default off), so D6 rule 2 is config not code. But nothing derives it from pod type and approvalActionService:644 CLONES the origin install's config, so a 1:1 Scout's wakeOnMessage:true rides into a shared pod and bills every line there today. **ux-lead Q1 → new D7.** At-cap is a new AXIS, not a liveness state: (liveness, config, quota) compose, and folding quota into the enum recreates the precedence trap decision 6 already paid for. Calm tone — a working cap is the system succeeding, attention-tone trains cry-wolf. Carries the reset time because it is the one bad state with a knowable end. Boundary VERIFIED as UTC midnight (nativeRuntimeService:621), which lands mid-morning for our Chinese users — stated so the copy decision is made deliberately rather than by writing "midnight". **ux-lead Q2 → new D8.** Ambient card and event nudge are layers, not rivals, with three pins: one shared derivation so they cannot disagree; the nudge's "I'll post when it connects" stays owed even after the card flips green; and installer = owner so both surfaces land on the same person by construction. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8 --- docs/adr/ADR-022-persona-colleagues.md | 36 ++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-022-persona-colleagues.md b/docs/adr/ADR-022-persona-colleagues.md index 9ec118e9..59ac1c6a 100644 --- a/docs/adr/ADR-022-persona-colleagues.md +++ b/docs/adr/ADR-022-persona-colleagues.md @@ -1,6 +1,6 @@ # ADR-022: Persona colleagues — separating who an agent is from where it runs -- **Status:** Draft — design decided (fable-lead 2026-08-14), allowance decided (Sam 2026-08-14: ~$1/day/user, one seat, never shown as currency). Ready for ratification. +- **Status:** Draft — design decided (fable-lead 2026-08-14), allowance decided (Sam 2026-08-14); reviewed by pod-architect + ux-lead 2026-08-14 and **corrected** — see the D1 blocking correction. Ready for ratification. - **Depends on:** ADR-001 (Installable taxonomy — `source` / `components[]`), ADR-021 (hosted runtime, credits) - **Supersedes when accepted:** the v1 agent catalog surface (`/v2/agents/browse`, `AgentsHub`), and the first-party app set as currently constituted @@ -97,7 +97,15 @@ Retirement must honour ADR-001 identity continuity: uninstalling never deletes t **The user picks:** which role, what to call it, an avatar from a set, where it runs, which room. Plus at most one length-capped free-text **focus line**, appended like the pod-context frame — *"our stack is React + Node"* is the cheapest thing that makes it **your** reviewer. -**The rename is not cosmetic:** persona = `agentName` (the mold), hire = `instanceId` (the colleague) — exactly the pair the runtime already keys sessions on. Two "Code Reviewer" hires in different pods are different colleagues with separate memory from one mold. That is what makes it staff rather than config. +**The rename is not cosmetic:** persona = `agentName` (the mold), hire = `instanceId` (the colleague) — exactly the pair the runtime already keys sessions on. + +> **CORRECTED before ratification (pod-architect).** The original sentence here read *"two 'Code Reviewer' hires in different pods are different colleagues with separate memory."* **That is false by construction.** `authController:185` derives `instanceId` as `u{sha256(userId)[0:10]}` — **there is no pod component** — while ADR-003 keys memory on `(agentName, instanceId)`. Two hires produce two install rows, **one colleague, one mind.** +> +> **Adopted resolution: one colleague per user per persona, present in N rooms, with one memory.** Not a workaround — it is the better product and it matches the portable-identity thesis: a colleague you work with in several rooms is still one person who remembers you. "Separate colleagues per pod" would fragment exactly the memory that makes a hire feel like staff. +> +> Two consequences that must ship with it: the hire flow says *"add your Code Reviewer to this pod,"* never *"hire another"* — the UI must not promise an isolation the data model does not provide; and **cross-pod memory bleed becomes a real surface** (a persona in a work pod and a personal pod shares one mind), so ADR-003 memory scoping has to be checked before a second hire is offered, not after. + +**A hire's own fields have no typed home yet** (pod-architect, Q1): every field D1 *curates* lives on `NativeAgentDefinition`, and one engine already runs four manifests off it — that half of the claim holds. But the fields the user *picks* (name, avatar, focus line) land in `AgentInstallation.config`, a `Map` of `Mixed`. **Persona-vs-hire is today a convention over an untyped bag.** Typing it is v1 work, not a later cleanup — an untyped bag is how the `config.runtime` Map silently defeated three separate readers earlier today. **The card carries evidence, not attributes:** a first-person one-liner of what it does in the room; what it will do first when placed ("I'll introduce myself and ask for the repo"); a two-turn sample exchange; the liveness dot. **Zero runtime vocabulary on the card** — hosted/local exists only at the where-step. Model, caps and raw prompt live behind a "how I work" disclosure. @@ -157,6 +165,10 @@ Two rules, one cost reason and one correctness reason, and the correctness one m 1. **Scout steers.** Today it creates pods only reactively — its prompt says *"Asked for a new pod → propose create_pod."* It waits to be asked. It should instead notice that real work has started and offer the room for it. This is also the better funnel: land in My Workspace → talk to Scout → **Scout helps you make a real pod for real work**, rather than → get pushed toward BYO connect. 2. **A persona in a shared pod is mention-only, never wake-on-message.** This is D3's "wake policy is identity" as an enforceable rule: a persona that wakes on everything is a 1:1 assistant, one that wakes on mention is a colleague. Same model, different creature — and the shared-pod variant is also the cheap one. +**This is config, not code — and there is already a leak** (pod-architect, Q2). `agentMentionService:816` reads `installation.config.wakeOnMessage.enabled` and `:876` filters on it, **default off**. So rule 2 needs no engine change. + +But **nothing derives wake policy from pod type**, and `approvalActionService:644` **clones the origin install's config** when an agent is brought along to a new pod. So a 1:1 Scout's `wakeOnMessage: true` rides into a shared pod today and starts billing every line there. Deriving the default from pod type — 1:1-shaped room wakes, shared pod mention-only — is the fix, and it closes a live cost leak rather than only guarding a future one. + ## Open — Sam *(D5 settled the allowance. Nothing here blocks the design; these are sequencing calls.)* @@ -183,6 +195,26 @@ So a seat-denominated allowance needs a **per-user ceiling in addition to the pe **The abuse surface is account creation, not usage.** At ~0.3% utilization instance-wide (about 4 native turns a day against 1,260 available across 21 users), honest users are nowhere near the cap. The exposure is linear in *accounts*, at ~$1/day each, and registration is open. Rate-limiting or entitling seat grants — not tightening the cap — is the control that matters. +## D7. At-cap is a new AXIS, not a liveness state (ux-lead) + +Quota does **not** go inside the `AgentReachState` enum. `(liveness, config, quota)` **compose**; folding quota into liveness recreates the precedence trap decision 6 already paid for. This is what makes fable-lead's *"liveness means will answer, not can execute"* rule true rather than merely asserted — a capped-out native agent is `reachable` **and** at-quota, and the surface reports both. + +**Tone is calm, not attention.** A working cap is the system succeeding; attention-tone here trains cry-wolf. Copy is flat because our own counter is structurally certain — no hedging needed — and it is the one bad state with a **knowable end**, so it carries the reset time: + +> "At today's limit — answers again at HH:MM" + +**The boundary is UTC midnight** — `nativeRuntimeService:621` does `dayStart.setUTCHours(0,0,0,0)`, verified rather than assumed. Worth stating plainly because for our Chinese users that reset lands **mid-morning local**, which is a strange thing to show without thought. Whether to display UTC, local, or a relative "in 6 hours" is a copy decision that follows from surfacing the real boundary instead of writing "midnight." + +Never currency, per D5. The mention-time inline cue inherits it: *"will answer"* becomes *"will answer at HH:MM."* **Disabled-by-owner is the quota-axis sibling** — same calm tone, and the fix names the owner. + +## D8. Ambient state and the event nudge are layers, not rivals (ux-lead) + +The awaiting-seat member card is **ambient** (persistence); the stalled-connect nudge is the single **event**. Ambient-plus-one-post is exactly the #891 pairing, and the stalled-connect spec's episode record governs **posts only, never card state**. Three pins keep them from drifting: + +1. Both consume the **one class-scoped derivation**, so card and nudge cannot structurally disagree. +2. The nudge's *"I'll post here the moment it connects"* stays **owed** even after the card flips green — the promise was a post, and an ambient state change does not discharge it. +3. **Installer = owner for hires**, so the card's owner-only `fixCommand` and the nudge's installer-addressed copy land on the same person by construction. If a future hire flow ever splits those, both surfaces must key on the same field rather than two. + ## Do now, regardless of ratification (fable-lead) 1. **Filter internal and ephemeral rows out of `/api/registry/agents`.** It ships 21 smoke and internal rows today and the logged-out landing footer links it. The marketplace path already has the filter to copy. This is a live leak, not a redesign step. From 7c923869b125f7f3ceebb46fe3e4cce057d4409b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:12:43 -0700 Subject: [PATCH 5/7] =?UTF-8?q?docs(adr):=20ADR-022=20round=202=20?= =?UTF-8?q?=E2=80=94=20correct=20my=20telemetry=20claim;=20the=20$1=20is?= =?UTF-8?q?=20now=20measured?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fable-lead corrected a claim I put in this ADR and repeated to Sam, and it was my measurement error, not a missing feature. **I was wrong: the accounting code exists and runs.** nativeRuntimeService :865-867 writes per-turn prompt/completion/total from LiteLLM usage, :926/:936 accumulate into run.totalTokens, :983 persists. My query checked a ROOT-level promptTokens the code never writes. Re-measured correctly: 2,225 of 6,624 runs carry token data, median 8,146 tokens/run, mean 17,418, p90 51,748. So the real ceiling is 60 x ~17k = ~1.05M tokens/user/day, which on a flash model sits comfortably under $1 — the allowance has 3-10x headroom, not zero. The $1 is now measured rather than guessed. **The 4,399 zero-token runs are the actual ticket.** `Number(usage.total_tokens || 0)` turns UNMEASURED into ZERO, and a zero that means unmeasured reads as free. Fix is not "populate fields" but "find why usage is empty and make unmeasured loud" — distinct errorKind or warn, never a manufactured zero. **The cap multiplies in two directions** (fable-lead): N hires = N x cap AND one hire in M rooms = M install rows = M x cap. So D2's room step can silently triple spend for one colleague. Invariant added: a per-user ceiling keyed on installedBy is a PREREQUISITE for seat #2 or multi-room placement, not a fast-follow. Ledger shape: ceiling per user, fairness per hire beneath, per-pod never. **At-cap persists nothing** (pod-architect): the decline path returns synthetic success with runId '' and writes no AgentRun row, so D7's quota axis has no signal to read. Recording it is the better answer because a cap-hit is also the tripwire. **The tripwire, not softer copy** (fable-lead): the cap must not be reachable inside a single engaged first-day conversation. If cost rises, degrade per-turn spend (model, maxTokens/maxTurns) and never continuity — a colleague thinking in smaller steps is still a colleague; one stopping mid-conversation on day one is a meter, and softer copy there is a meter with manners. Alert on any cap-out within 24h of signup; treat it as an incident, not a UX state. **The UTC boundary is arguably wrong, not just awkward** (pod-architect): UTC midnight is 08:00 in UTC+8, so a Chinese user's cap resets at the START of their workday — exhaust by 10am and they are dark 22 hours. Two of the users this ADR is built on wrote Chinese. Argue the boundary before the copy commits to a time. **Pin 3 verified with a hole** (pod-architect): installedBy is required and on all 322 active installs, but 53 have a BOT as installedBy — there isOwner is false for every human, so no fixCommand and no nudge addressee. Same class as pod.createdBy in #939, one field over; reuse that remedy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8 --- docs/adr/ADR-022-persona-colleagues.md | 33 +++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-022-persona-colleagues.md b/docs/adr/ADR-022-persona-colleagues.md index 59ac1c6a..e94537fb 100644 --- a/docs/adr/ADR-022-persona-colleagues.md +++ b/docs/adr/ADR-022-persona-colleagues.md @@ -193,6 +193,31 @@ That number is not a new budget; it is what the shipped configuration already en So a seat-denominated allowance needs a **per-user ceiling in addition to the per-installation cap**. The per-installation cap stays — it is the runaway-loop guard for a single conversation. The per-user ceiling is what makes "1 hosted colleague included" a promise we can price. +**It multiplies in TWO directions, not one** (fable-lead). N hires = N × cap, *and* one hire placed in M rooms = M `AgentInstallation` rows = M × cap. So under D2's "pick room(s)" step, **a single colleague working in three rooms is silently three colleagues' worth of spend.** + +> **Invariant.** A per-user daily ceiling — keyed on `installedBy`, summed across all hosted installs — is a **prerequisite for offering the second hosted seat or multi-room placement of a hosted hire.** Not a fast-follow. + +In v1 (Scout only, one install) per-install equals per-user by construction, so nothing needs building now; the invariant exists so the where-step cannot outrun the ledger. **Ledger shape when built: ceiling per user, fairness per hire beneath it, per-pod never** — a colleague in three rooms is one colleague. + +**Presentation does not change; enforcement does.** Present the seat, never the arithmetic. A seat that silently multiplies is an enforcement bug, and bugs do not get fixed in copy. + +**Correction — telemetry EXISTS, and the $1 is now measured rather than guessed.** An earlier draft of this ADR said "the fields exist and nothing populates them." That was wrong, and the error was mine: the accounting code runs on main (`nativeRuntimeService:865-867` per turn, `:926/:936` accumulating into `run.totalTokens`), and my query checked a root-level `promptTokens` that the code never writes. Measured properly, 2026-08-14: + +| metric | value | +|---|---| +| runs with token data | **2,225** of 6,624 | +| median tokens/run | **8,146** | +| mean tokens/run | 17,418 | +| p90 / max | 51,748 / 54,398 | + +So the true daily ceiling is `60 × ~17k ≈ 1.05M tokens/user/day`, which on a flash-tier model sits **comfortably under the $1 figure** — the allowance has roughly 3–10× headroom over worst case, not zero. + +**The 4,399 runs with `totalTokens === 0` are the open question** (fable-lead). `Number(usage.total_tokens || 0)` converts *unmeasured* into *zero*, and **a zero that means unmeasured reads as free**. The ticket is not "populate the fields" — it is *"find why `usage` is empty and make unmeasured loud"*: a distinct `errorKind` or warning, never a manufactured zero. + +**At-cap persists nothing** (pod-architect). The decline path returns a synthetic success with `runId: ''` and writes **no `AgentRun` row**, so a capped turn is indistinguishable from an ordinary quiet one in the data. D7's quota axis therefore has **no signal to read** — either it recomputes the count per viewer, or the runtime must record the state. Recording it is the better answer, because a cap-hit is also the tripwire below. + +**The cap must not be reachable inside a single engaged first-day conversation** (fable-lead). "Surface only on hit" is designed for a *backstop*, and holds only while the cap stays one. If real cost rises, the levers in order are **model choice, then per-run `maxTokens`/`maxTurns`** — degrade per-turn spend, never continuity. A colleague that thinks in smaller steps is still a colleague; one that stops mid-conversation on day one is a meter, and softer copy in that world is just a meter with manners. So do not redesign the copy for that world — build the tripwire: **alert when any user caps out within 24h of signup, or first-day hit-rate exceeds ~1%, and treat a first-day cap-hit as an incident (the allowance is wrong), not a UX state.** + **The abuse surface is account creation, not usage.** At ~0.3% utilization instance-wide (about 4 native turns a day against 1,260 available across 21 users), honest users are nowhere near the cap. The exposure is linear in *accounts*, at ~$1/day each, and registration is open. Rate-limiting or entitling seat grants — not tightening the cap — is the control that matters. ## D7. At-cap is a new AXIS, not a liveness state (ux-lead) @@ -203,7 +228,9 @@ Quota does **not** go inside the `AgentReachState` enum. `(liveness, config, quo > "At today's limit — answers again at HH:MM" -**The boundary is UTC midnight** — `nativeRuntimeService:621` does `dayStart.setUTCHours(0,0,0,0)`, verified rather than assumed. Worth stating plainly because for our Chinese users that reset lands **mid-morning local**, which is a strange thing to show without thought. Whether to display UTC, local, or a relative "in 6 hours" is a copy decision that follows from surfacing the real boundary instead of writing "midnight." +**The boundary is UTC midnight** — `nativeRuntimeService:621` does `dayStart.setUTCHours(0,0,0,0)`, verified rather than assumed. + +**And the boundary is arguably wrong, not just awkward to phrase** (pod-architect). UTC midnight is **08:00 in UTC+8**, so a Chinese user's cap resets *at the start of their workday*. Exhaust it by 10am and they are dark for **22 hours** — the worst possible phase for exactly the audience this copy exists to serve, and two of the users this ADR is built on wrote Chinese. **Argue the boundary before the copy commits to a time.** A rolling 24h window, or a reset keyed to the user's own timezone, may be the actual fix; "which words describe UTC midnight" is the wrong question to answer first. Never currency, per D5. The mention-time inline cue inherits it: *"will answer"* becomes *"will answer at HH:MM."* **Disabled-by-owner is the quota-axis sibling** — same calm tone, and the fix names the owner. @@ -215,6 +242,10 @@ The awaiting-seat member card is **ambient** (persistence); the stalled-connect 2. The nudge's *"I'll post here the moment it connects"* stays **owed** even after the card flips green — the promise was a post, and an ambient state change does not discharge it. 3. **Installer = owner for hires**, so the card's owner-only `fixCommand` and the nudge's installer-addressed copy land on the same person by construction. If a future hire flow ever splits those, both surfaces must key on the same field rather than two. +**Pin 3 verified, with a hole** (pod-architect). `installedBy` is `required: true`, populated on all 322 active installs, and `agentStateService:100` keys `isOwner` on exactly that field — one field, and because both surfaces read it they fail *together* rather than contradicting each other, which is the pin working. + +**But 53 of 322 have a BOT as `installedBy`** (mostly `commonly-bot`). For those, `isOwner` is false for every human: the card renders **no `fixCommand`** and the nudge has **no addressee**. That is the same class of defect as `pod.createdBy` in #939, one field over — and #939's remedy (resolve to a non-bot human, refuse rather than render an impossible owner) is the shape to reuse. For the funnel population it is small: **1 of 20** self-serve seats. + ## Do now, regardless of ratification (fable-lead) 1. **Filter internal and ephemeral rows out of `/api/registry/agents`.** It ships 21 smoke and internal rows today and the logged-out landing footer links it. The marketplace path already has the filter to copy. This is a live leak, not a redesign step. From e26edc3f87cae29a6582e3c9de5770f46645cb79 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:15:32 -0700 Subject: [PATCH 6/7] =?UTF-8?q?docs(adr):=20ADR-022=20round=203=20?= =?UTF-8?q?=E2=80=94=20D5=20overclaimed;=20two=20of=20four=20limits=20are?= =?UTF-8?q?=20not=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sprint-review's adversarial pass, verified at source. My D5 said the ~$1 figure is "what the shipped configuration already enforces." That was wrong in three ways: dailyRunCap: 60 the ONLY one actually read (:616) maxTurns: 6 not read anywhere — MAX_TURNS = 10 hardcoded (:50) maxTokens: 12000 not read anywhere — MAX_TOKENS = 50_000 hardcoded (:51) the 50k ceiling INERT — :794 tests run.totalTokens, which the `|| 0` at :926 pins to 0 whenever usage is empty The manifest's maxTurns/maxTokens appear nowhere in backend/services. Live bounds are 10 turns / 60s wall-clock / daily cap. **The same empty field hides the cost AND disables the ceiling meant to contain it, and the error direction is upward.** Sam's direction still stands ($10 would have loosened the one working limit tenfold); what does not stand is calling ~$1 enforced. Also folded: - liteLLMCallId is captured per turn, so LiteLLM's spend log is a SECOND cost source needing no code change — real cost is a query, not a feature. And which of three causes empties `usage` is one run's logs away, changing the fix cost by an order of magnitude. - AgentRun has NO user field, so a per-user ceiling cannot be computed where the cap lives: needs a denormalized userId (forward-only) or a hot-path join. And the cap fails OPEN by design (:614) — right for a runaway guard, wrong for a spend ceiling. Decide it, don't inherit it. - Seat machinery already exists (User.entitlements.cloudAgents, install.ts:361) so v1 needs no ledger — we avoid M4's ledger and inherit M4's open question. - Fixed window, not rolling: 60 runs at 23:59 UTC plus 60 at 00:01 means the real burst bound is 2x dailyRunCap. - The at-cap decline returns status:'succeeded' with runId '' — at-cap is indistinguishable from a normal zero-turn run, so D7 needs a PRODUCER at :633 before it needs a chip. And that shape IS the 03:30 user's experience reproduced by the cap instead of a dead wrapper: the silent-success failure this ADR exists to delete, re-entering through the surface meant to stop it. - Never "back tomorrow" — for UTC+8 reset is 08:00 local SAME day, so it is false for everyone east of UTC and falsely pessimistic. - Pin 3 is true of the field and false of the TEST: the card does a raw string compare (agentStateService:94) while approvals resolve + reject bots (#940). Strengthen to "same resolution", not "same field" — with the honest constraint that deriveAgentState is sync in a .map() and resolveHumanDecider is async. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8 --- docs/adr/ADR-022-persona-colleagues.md | 58 ++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/docs/adr/ADR-022-persona-colleagues.md b/docs/adr/ADR-022-persona-colleagues.md index e94537fb..a022378b 100644 --- a/docs/adr/ADR-022-persona-colleagues.md +++ b/docs/adr/ADR-022-persona-colleagues.md @@ -1,6 +1,6 @@ # ADR-022: Persona colleagues — separating who an agent is from where it runs -- **Status:** Draft — design decided (fable-lead 2026-08-14), allowance decided (Sam 2026-08-14); reviewed by pod-architect + ux-lead 2026-08-14 and **corrected** — see the D1 blocking correction. Ready for ratification. +- **Status:** Draft — design decided (fable-lead 2026-08-14), allowance decided (Sam 2026-08-14); reviewed by fable-lead, pod-architect, sprint-review + ux-lead 2026-08-14 and **corrected twice** (D1 false by construction; D5 overclaimed enforcement). **Not ready to ratify D5** until the empty-`usage` cause is found — one run's logs away. - **Depends on:** ADR-001 (Installable taxonomy — `source` / `components[]`), ADR-021 (hosted runtime, credits) - **Supersedes when accepted:** the v1 agent catalog surface (`/v2/agents/browse`, `AgentsHub`), and the first-party app set as currently constituted @@ -187,7 +187,22 @@ But **nothing derives wake policy from pod type**, and `approvalActionService:64 **Decided (Sam, 2026-08-14): roughly $1/day per user, never shown as currency.** -That number is not a new budget; it is what the shipped configuration already enforces. Scout runs `deepseek-v4-flash` with `dailyRunCap: 60`, `maxTurns: 6`, `maxTokens: 12000` — a worst-case run around 50k tokens, so a flat-out workspace lands under a dollar a day. An earlier proposal of $10/day would have **loosened a working limit by 10×**, which is the opposite of what a ceiling is for. +**It is an estimate pending one telemetry fix — not, as an earlier draft of this ADR claimed, "what the shipped configuration already enforces."** That claim was mine and it was wrong in three ways (sprint-review, verified at source): + +| limit I cited | reality | +|---|---| +| `dailyRunCap: 60` | **the only one actually read** (`:616`) | +| `maxTurns: 6` | **not read anywhere.** `MAX_TURNS = 10` is hardcoded (`:50`) | +| `maxTokens: 12000` | **not read anywhere.** `MAX_TOKENS = 50_000` is hardcoded (`:51`) | +| the 50k token ceiling | **inert.** `:794` tests `run.totalTokens >= MAX_TOKENS`, and the `|| 0` at `:926` pins that at 0 whenever `usage` is empty | + +The manifest's `maxTurns`/`maxTokens` appear nowhere in `backend/services`. Live bounds are **10 turns / 60s wall-clock / daily cap**. + +**The same empty field hides the cost and disables the ceiling meant to contain it, and the error direction is upward.** That is the finding, not the price. + +An earlier $10/day proposal would still have loosened the one limit that works by 10×, so the direction of Sam's decision stands. What does not stand is calling ~$1 enforced. + +**One measurable input, available today.** `liteLLMCallId` is captured per turn, so **LiteLLM's own spend log is a second source needing no code change** — real cost is a query away, not a feature. And the capture path is already written (`:860-867` reads `llmResponse.usage`, `:927` saves it), so this is a findable bug rather than work to schedule. **Which of three causes empties `usage` is one run's logs away, and changes the fix cost by an order of magnitude.** **The trap this ADR must not walk into.** `nativeRuntimeService:621` counts runs on `{ podId, agentName, instanceId }` — **per installation**. Today that equals per user only because Scout is `perUser: true` with exactly one install per workspace. **The moment a user can hire several personas, N hires means N × 60**, and "per user" quietly becomes "per user per persona." @@ -199,6 +214,14 @@ So a seat-denominated allowance needs a **per-user ceiling in addition to the pe In v1 (Scout only, one install) per-install equals per-user by construction, so nothing needs building now; the invariant exists so the where-step cannot outrun the ledger. **Ledger shape when built: ceiling per user, fairness per hire beneath it, per-pod never** — a colleague in three rooms is one colleague. +**It cannot be built where the cap lives** (sprint-review). `AgentRun` has **no user field at all** — `podId`, `agentName`, `instanceId`, and the index matches. A per-user ceiling therefore needs either a denormalized `userId` (forward-only; no backfill, since the field never existed) or a join on the hot path. Neither is free, which is why this is a prerequisite rather than a fast-follow. + +**And the cap fails OPEN by design** (`:614`) — a count failure proceeds rather than declines. That is right for a runaway-loop guard and wrong for a spend ceiling. **Decide it deliberately rather than inherit it**, because the two have opposite safe directions. + +**Seat machinery already exists** — `User.entitlements.cloudAgents`, gated at `install.ts:361` — so v1 needs no ledger. But "a promise we can price" needs a unit cost, and that needs the telemetry above. **We avoid M4's ledger and inherit M4's open question.** + +**The window is fixed, not rolling** — `dayStart.setUTCHours(0,0,0,0)` with `startedAt >= dayStart`. So 60 runs at 23:59 UTC and 60 more at 00:01 is reachable: the real burst bound is **2 × `dailyRunCap`** across the boundary. + **Presentation does not change; enforcement does.** Present the seat, never the arithmetic. A seat that silently multiplies is an enforcement bug, and bugs do not get fixed in copy. **Correction — telemetry EXISTS, and the $1 is now measured rather than guessed.** An earlier draft of this ADR said "the fields exist and nothing populates them." That was wrong, and the error was mine: the accounting code runs on main (`nativeRuntimeService:865-867` per turn, `:926/:936` accumulating into `run.totalTokens`), and my query checked a root-level `promptTokens` that the code never writes. Measured properly, 2026-08-14: @@ -214,7 +237,15 @@ So the true daily ceiling is `60 × ~17k ≈ 1.05M tokens/user/day`, which on a **The 4,399 runs with `totalTokens === 0` are the open question** (fable-lead). `Number(usage.total_tokens || 0)` converts *unmeasured* into *zero*, and **a zero that means unmeasured reads as free**. The ticket is not "populate the fields" — it is *"find why `usage` is empty and make unmeasured loud"*: a distinct `errorKind` or warning, never a manufactured zero. -**At-cap persists nothing** (pod-architect). The decline path returns a synthetic success with `runId: ''` and writes **no `AgentRun` row**, so a capped turn is indistinguishable from an ordinary quiet one in the data. D7's quota axis therefore has **no signal to read** — either it recomputes the count per viewer, or the runtime must record the state. Recording it is the better answer, because a cap-hit is also the tripwire below. +**At-cap persists nothing, and reports the opposite of the truth** (pod-architect, sprint-review). The decline path at `:633` returns: + +```js +return { runId: '', status: 'succeeded', totalTurns: 0, totalTokens: 0 }; +``` + +**`status: 'succeeded'`.** No `AgentRun` row is written, and the boundary appears nowhere but a `console.warn`. So a capped turn is indistinguishable from a run that completed normally with zero turns, and D7's quota axis has **no signal to read** — it needs a **producer at `:633`** before it needs a chip. + +**And look at what that shape is:** a user mentions Scout, Scout is at cap, the run "succeeds," nothing is posted, the room says nothing. **That is the 03:30 user's experience reproduced by the cap instead of by a dead wrapper** — the exact silent-success failure this entire ADR line exists to delete, re-entering through the surface meant to prevent it. **The cap must not be reachable inside a single engaged first-day conversation** (fable-lead). "Surface only on hit" is designed for a *backstop*, and holds only while the cap stays one. If real cost rises, the levers in order are **model choice, then per-run `maxTokens`/`maxTurns`** — degrade per-turn spend, never continuity. A colleague that thinks in smaller steps is still a colleague; one that stops mid-conversation on day one is a meter, and softer copy in that world is just a meter with manners. So do not redesign the copy for that world — build the tripwire: **alert when any user caps out within 24h of signup, or first-day hit-rate exceeds ~1%, and treat a first-day cap-hit as an incident (the allowance is wrong), not a UX state.** @@ -228,6 +259,8 @@ Quota does **not** go inside the `AgentReachState` enum. `(liveness, config, quo > "At today's limit — answers again at HH:MM" +**Never "back tomorrow."** For UTC+8 the reset lands at 08:00 local on the *same calendar day*, so "tomorrow" is false for everyone east of UTC — and falsely **pessimistic**, which is the rarer and more damaging direction. + **The boundary is UTC midnight** — `nativeRuntimeService:621` does `dayStart.setUTCHours(0,0,0,0)`, verified rather than assumed. **And the boundary is arguably wrong, not just awkward to phrase** (pod-architect). UTC midnight is **08:00 in UTC+8**, so a Chinese user's cap resets *at the start of their workday*. Exhaust it by 10am and they are dark for **22 hours** — the worst possible phase for exactly the audience this copy exists to serve, and two of the users this ADR is built on wrote Chinese. **Argue the boundary before the copy commits to a time.** A rolling 24h window, or a reset keyed to the user's own timezone, may be the actual fix; "which words describe UTC midnight" is the wrong question to answer first. @@ -244,7 +277,24 @@ The awaiting-seat member card is **ambient** (persistence); the stalled-connect **Pin 3 verified, with a hole** (pod-architect). `installedBy` is `required: true`, populated on all 322 active installs, and `agentStateService:100` keys `isOwner` on exactly that field — one field, and because both surfaces read it they fail *together* rather than contradicting each other, which is the pin working. -**But 53 of 322 have a BOT as `installedBy`** (mostly `commonly-bot`). For those, `isOwner` is false for every human: the card renders **no `fixCommand`** and the nudge has **no addressee**. That is the same class of defect as `pod.createdBy` in #939, one field over — and #939's remedy (resolve to a non-bot human, refuse rather than render an impossible owner) is the shape to reuse. For the funnel population it is small: **1 of 20** self-serve seats. +**But 53 of 322 have a BOT as `installedBy`** (mostly `commonly-bot`). For those, `isOwner` is false for every human: the card renders **no `fixCommand`** and the nudge has **no addressee**. For the funnel population it is small — **1 of 20** self-serve seats. + +**The pin is true of the field and false of the test, and the divergence already shipped** (sprint-review): + +```js +// card — agentStateService.ts:94 raw string compare, no load, no isBot check +String(installation.installedBy || '') === String(callerId || '') + +// approvals — approvalActionService.ts:164,172 what #940 shipped +resolveHumanDecider(candidates) // installer → pod-creator fallback + if (user && user.isBot !== true) … // loads the User, rejects bots +``` + +Same field, **different predicate**. Where `installedBy` is a bot, approvals fall back and find a human while the card's `isOwner` is false for *everyone* — it shows a broken agent and offers the fix to nobody. Where it dangles (the seeder's hardcoded admin id on a self-hosted instance), approvals fall back and the card simply never matches. + +**So the pin strengthens from "both key on the same field" to "both use the same resolution."** Otherwise a future hire flow does not have to split the field to split the surfaces — it only has to install via a path that writes a non-human, and three such writers already exist. + +**One honest constraint on that fix:** `deriveAgentState` is a pure sync function called inside a `.map()` (`pods.ts:446`), while `resolveHumanDecider` is async because it loads a User. Sharing the resolution needs the resolved owner **precomputed and passed in**, or the card's derivation made async. Worth knowing before anyone writes it down as a small pin. ## Do now, regardless of ratification (fable-lead) From f0cf580e38224e95da269ebd1c78ef9797e46304 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:22:43 -0700 Subject: [PATCH 7/7] =?UTF-8?q?docs(adr):=20ADR-022=20=E2=80=94=20the=20ze?= =?UTF-8?q?ro-token=20runs=20are=20failures,=20not=20missing=20telemetry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reviewers suspected `Number(usage.total_tokens || 0)` was turning UNMEASURED into ZERO. Measured, and it is neither of the three guessed causes: failed / llm_error 4395 running / none (in flight) 3 failed / guardrail_blocked 1 A failed LLM call has no usage to record, so zero is correct for all of them. The accounting path works — earliest run carrying tokens is 2026-04-12 — and the ~$1 estimate rests on 2,225 SUCCESSFUL runs, so it stands. **But the number that produced the answer is worse than the question.** Over 30 days: 891 native runs, 19 with tokens — a ~98% failure rate. The newest zero-token runs are pod-summarizer failing llm_error with 0 turns every six hours on a cron (04/10/16/22 UTC), for at least a month, entirely silently. Scout on deepseek-v4-flash is healthy by contrast: 10 of 14 runs in the last 24h carried tokens, and the 4 that did not are that same summarizer cron. Two consequences recorded: 1. This independently validates retiring pod-summarizer. It is not merely redundant — it has been failing on schedule for a month and nobody noticed, which is the strongest argument that a scheduled resident nobody asked for is a liability rather than a feature. 2. Silent scheduled failure is the SAME FAMILY as the at-cap decline returning status:'succeeded' and the broadcast claim swallowed by NO_REPLY. Three unrelated subsystems, one shape: the work does not happen and every component reports success. That deserves a named kernel invariant, not three separate fixes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8 --- docs/adr/ADR-022-persona-colleagues.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-022-persona-colleagues.md b/docs/adr/ADR-022-persona-colleagues.md index a022378b..7316b008 100644 --- a/docs/adr/ADR-022-persona-colleagues.md +++ b/docs/adr/ADR-022-persona-colleagues.md @@ -1,6 +1,6 @@ # ADR-022: Persona colleagues — separating who an agent is from where it runs -- **Status:** Draft — design decided (fable-lead 2026-08-14), allowance decided (Sam 2026-08-14); reviewed by fable-lead, pod-architect, sprint-review + ux-lead 2026-08-14 and **corrected twice** (D1 false by construction; D5 overclaimed enforcement). **Not ready to ratify D5** until the empty-`usage` cause is found — one run's logs away. +- **Status:** Draft — design decided (fable-lead 2026-08-14), allowance decided (Sam 2026-08-14); reviewed by fable-lead, pod-architect, sprint-review + ux-lead 2026-08-14 and **corrected twice** (D1 false by construction; D5 overclaimed enforcement). D5's telemetry question is **RESOLVED** (the zeros are failed runs, not unmeasured usage) — but that measurement exposed a **~98% native-run failure rate over 30 days**, with `pod-summarizer` failing on a 6-hourly cron for a month, silently. - **Depends on:** ADR-001 (Installable taxonomy — `source` / `components[]`), ADR-021 (hosted runtime, credits) - **Supersedes when accepted:** the v1 agent catalog surface (`/v2/agents/browse`, `AgentsHub`), and the first-party app set as currently constituted @@ -235,7 +235,22 @@ In v1 (Scout only, one install) per-install equals per-user by construction, so So the true daily ceiling is `60 × ~17k ≈ 1.05M tokens/user/day`, which on a flash-tier model sits **comfortably under the $1 figure** — the allowance has roughly 3–10× headroom over worst case, not zero. -**The 4,399 runs with `totalTokens === 0` are the open question** (fable-lead). `Number(usage.total_tokens || 0)` converts *unmeasured* into *zero*, and **a zero that means unmeasured reads as free**. The ticket is not "populate the fields" — it is *"find why `usage` is empty and make unmeasured loud"*: a distinct `errorKind` or warning, never a manufactured zero. +**RESOLVED — the zeros are not unmeasured usage, and there is no telemetry bug.** fable-lead and sprint-review both suspected `Number(usage.total_tokens || 0)` was converting *unmeasured* into *zero*. Measured 2026-08-14, splitting the zero-token runs by status: + +| status / errorKind | count | +|---|---| +| `failed` / `llm_error` | **4,395** | +| `running` / none (in flight) | 3 | +| `failed` / `guardrail_blocked` | 1 | + +**A failed LLM call has no usage to record, so zero is correct for all of them.** The accounting path works; earliest run carrying tokens is 2026-04-12. The $1 estimate rests on 2,225 *successful* runs and stands. + +**But the number that produced this answer is the actual finding, and it is worse than the question.** Over the last 30 days: **891 native runs, 19 with tokens — a ~98% failure rate.** The newest zero-token runs are `pod-summarizer`, failing `llm_error` with 0 turns **every six hours on a cron** (04:00, 10:00, 16:00, 22:00 UTC), for at least a month, entirely silently. Scout on `deepseek-v4-flash` is healthy by contrast — 10 of 14 runs in the last 24h carried tokens, and the 4 that did not are that same summarizer cron. + +Two consequences: + +1. **This independently validates retiring `pod-summarizer`** (decision 5). It is not merely redundant — it has been failing on schedule for a month and nobody noticed, which is the strongest possible argument that a scheduled resident nobody asked for is a liability rather than a feature. +2. **Silent scheduled failure is the same family as the at-cap `status:'succeeded'` and the swallowed broadcast claim.** Three unrelated subsystems, one shape: *the work does not happen and every component reports success.* That deserves a named kernel invariant, not three separate fixes. **At-cap persists nothing, and reports the opposite of the truth** (pod-architect, sprint-review). The decline path at `:633` returns: