Commit 0a190af
docs(adr): ADR-022 — persona colleagues, separating who an agent is from where it runs (#950)
* docs(adr): ADR-022 — persona colleagues, separating who an agent is from where it runs
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
* docs(adr): ADR-022 — fold the design lead's decisions, and correct my economics claim
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
* docs(adr): ADR-022 D5+D6 — the allowance, and the per-installation cap trap
**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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
* docs(adr): ADR-022 — fold the fleet review; D1's central claim was false
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
* docs(adr): ADR-022 round 2 — correct my telemetry claim; the $1 is now measured
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
* docs(adr): ADR-022 round 3 — D5 overclaimed; two of four limits are not read
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
* docs(adr): ADR-022 — the zero-token runs are failures, not missing telemetry
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
* docs(adr): ADR-022 context — funnel re-measured 9-16 Aug; bottleneck moved to the landing page
The 21/5/0 table was drafting-time truth, falsified three ways since
(sprint-review 53266; huddle 53360). Kept as the drafting-time reading;
added the 9-16 Aug measurement (19 signups, 11 typed = 58%, landing
leak 178:1) and the all-time depth (103 / 28 / 4 / 1).
Patch authored by fable-lead (53365), anchors verified unique at f0cf580.
Applied on their behalf: their workspace runs the hardened deny-by-default
public-agent profile (no Write/Edit, git push denied) — the security policy
working as designed — and their fallback route, commonly_pr_review, returned
three consecutive 502s while commonly_get_messages succeeded between
attempts. That MCP route wants an ops look; filed separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
* docs(adr): ADR-022 Accepted — ratified by Sam 2026-08-16
Also corrects the ~98% native-run figure the status line carried. Re-measured
2026-08-16: that number was pod-summarizer diluting a healthy engine. Over 7
days Scout is 33/34 (97%) and pod-summarizer is 0/15 (0%). Per Sam,
pod-summarizer is intentionally not working and stays retired — this ADR
deleting it IS the fix, not a regression to repair, and the app gets simpler
rather than gaining a repair task.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>1 parent 46bb1f5 commit 0a190af
1 file changed
Lines changed: 323 additions & 0 deletions
0 commit comments