From e23849786917ee47feff904cc7d2de565aad0bfa Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:57:49 -0700 Subject: [PATCH 1/5] docs(adr): ADR-032 sub-pods, Proposed, with ADR-018 D8.6 as proposed text (TASK-533) ADR-032 is copied from the sub-pod draft. Its deviations from verbatim: the title drops "(draft)", the status line says "Drafted" instead of "Private draft", the numbering note names the open PRs that hold 031 (#2428) and 033 (#2691), and the ADR-018 scope-boundary bullet names where the amendment sits (D8.6). The four open questions stay in the status line as drafted, and section 10 keeps Cofounder's proposed answers as proposals for Sam to ratify. ADR-018 gains D8.6, the sub-pod result wake, as proposed amendment text that is not in force. It is D8.6 rather than D6.2 because ADR-018 already has a D6.2 (serial event processing). Its status line says so. ADR-016's parked parentPod line points at ADR-032. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/adr/ADR-016-pod-model-and-visibility.md | 2 +- docs/adr/ADR-018-agent-attention-claims.md | 21 +- docs/adr/ADR-032-sub-pods.md | 255 +++++++++++++++++++ 3 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 docs/adr/ADR-032-sub-pods.md diff --git a/docs/adr/ADR-016-pod-model-and-visibility.md b/docs/adr/ADR-016-pod-model-and-visibility.md index 338c597384..677b8d83e8 100644 --- a/docs/adr/ADR-016-pod-model-and-visibility.md +++ b/docs/adr/ADR-016-pod-model-and-visibility.md @@ -277,7 +277,7 @@ Two consequences worth stating, because they shrink a parked deliverable. **The ## Out of scope -- `parentPod` / pods-as-graph — parked (register P2) pending competitor-source evidence; separate ADR if activated. +- `parentPod` / pods-as-graph — parked (register P2) pending competitor-source evidence; separate ADR if activated. Proposed for activation in [ADR-032](ADR-032-sub-pods.md) (2026-10-25), which adds the parent/child relation and leaves this ADR's tiers unchanged. - Renaming the storage columns; retiring `type` values. - The consumer-vs-developer positioning question (unchanged from stub). - Request-access (H5) mechanics — only its *slot* in the model is reserved (state row 5's Discover exclusion). diff --git a/docs/adr/ADR-018-agent-attention-claims.md b/docs/adr/ADR-018-agent-attention-claims.md index 74e3bc0420..8ebd338129 100644 --- a/docs/adr/ADR-018-agent-attention-claims.md +++ b/docs/adr/ADR-018-agent-attention-claims.md @@ -1,6 +1,6 @@ # ADR-018 — Agent attention claims: claim, lease, turn-taking -**Status:** Accepted — ratified by Sam 2026-08-17. D8.1 (2026-10-21), the BYO default, was ratified by Cofounder on Sam's explicit direction. Cofounder accepted the receive/wake direction in D8.1 (2026-10-23) and conditional digest heartbeat in D9.1 on 2026-10-23. TASK-414 (#2558) implements digest delivery and D9.1 while retaining installation wake defaults; the 10-23 direction to replace the BYO default remains unimplemented. D8.3 supersedes the original 30-minute discovery cap. D4's 90s lease and BYO claim compliance remain unsettled. D6.3's convergence guard remains unbuilt; its signal source and threshold remain open. D6.4 was accepted 2026-10-20. +**Status:** Accepted — ratified by Sam 2026-08-17. D8.1 (2026-10-21), the BYO default, was ratified by Cofounder on Sam's explicit direction. Cofounder accepted the receive/wake direction in D8.1 (2026-10-23) and conditional digest heartbeat in D9.1 on 2026-10-23. TASK-414 (#2558) implements digest delivery and D9.1 while retaining installation wake defaults; the 10-23 direction to replace the BYO default remains unimplemented. D8.3 supersedes the original 30-minute discovery cap. D4's 90s lease and BYO claim compliance remain unsettled. D6.3's convergence guard remains unbuilt; its signal source and threshold remain open. D6.4 was accepted 2026-10-20. D8.6 (2026-10-25), the sub-pod result wake, is Proposed together with ADR-032 and is not in force. **Date:** 2026-08-11 **Method:** settled through a full grilling session (design-tree interview, every branch visited); the decisions below are Sam's, the facts are measured **Relates to:** ADR-017 (attention routing *to the human* — a different problem), ADR-012 (memory), #887 (silent mentions) @@ -508,6 +508,25 @@ never merely because they followed it. Subscription changes create no wakes. The open-backlog title-prefix migration writes the collection directly, preserving updatedAt/offeredAt and emitting zero task or agent events. +### D8.6 — PROPOSED AMENDMENT (2026-10-25, from ADR-032 D6.2): a sub-pod result wakes only the parent pod's owner + +**Status: PROPOSED — not in force.** It is ratified together with +[ADR-032](ADR-032-sub-pods.md) or not at all; neither is Accepted without the +other (ADR-032 §10, proposed answer 4). Until then, a sub-pod result follows +the decisions above like any other message. It is numbered D8.6 rather than +D6.2 because this ADR's own D6.2 is serial event processing. + +When a child pod is marked done, the kernel posts one `kind: 'sub-pod-result'` +message into the parent pod (ADR-032 §5). Under this amendment, that message is +delivered as an **addressed** wake to the parent's owner +(`P.focus.ownerUserId`, else `P.createdBy`) and as **digest** to every other +installation in the parent, whatever its `wakeOnMessage` setting. + +- **Why it is decided here.** This ADR owns wake policy, and the rule overrides + an opted-in seat's ambient wake (D8) for one message class. +- **Why it is not room shape.** It keys on the message's `kind`, never on member + count or pod shape, so ADR-020 D6 and ADR-024 D4 are untouched. + ### D9 — Heartbeat shrinks; it does not die Wake-on-message replaces heartbeat's *"did anything happen?"* role in pods diff --git a/docs/adr/ADR-032-sub-pods.md b/docs/adr/ADR-032-sub-pods.md new file mode 100644 index 0000000000..919f994ee8 --- /dev/null +++ b/docs/adr/ADR-032-sub-pods.md @@ -0,0 +1,255 @@ +# ADR-032: Sub-pods — child pods with a goal, a done condition, and a soft size limit + +**Status:** **Proposed.** Drafted 2026-10-25. Nothing here is ratified. Number: ADR-032 +(highest on `origin/main` @ `136709aa0` is ADR-030; open PR #2428 claims ADR-031, and open PR #2691 +files the grant-record ADR as ADR-033). +**Four questions stay open, and neither Proposed nor Accepted settles them** (§10): +(1) **the value of N**, pending the before/after wake measure, whose table is empty below; +(2) **nesting depth** beyond one level; +(3) **whether a connector grant may target a child pod directly** or only by attenuating a parent grant; +(4) **the wake rule for a result message** (D6.2), which needs an ADR-018 amendment, not this ADR, to take effect. +**Deciders:** Sam (direction), eng lead. **Supersedes:** nothing. **Activates:** ADR-016's parked +`parentPod` / pods-as-graph item (register P2). + +> **Scope boundary: read before implementing.** +> - **ADR-016** owns pod *visibility tiers, kind and joinPolicy*. It parks `parentPod` as out of scope +> (`ADR-016:280`, "separate ADR if activated"). This is that ADR. It adds a parent/child +> relation and one rule over ADR-016's tiers: a child is never wider than its parent. It changes +> none of ADR-016's tiers, flags or invariants. +> - **ADR-018** owns *wake policy*. ADR-018 says anything that wakes or silences agents based on +> pod shape "belongs in this ADR, decided explicitly" (`ADR-018:435`). So the soft limit N +> (§7) is **advice in the UI and never a wake gate**. The one new wake rule here (D6.2) is +> written as proposed amendment text for ADR-018, where it sits as D8.6 (Proposed). It is not in force on this ADR's authority. +> - **ADR-024 D4** (`ADR-024:223`): "Room shape MUST NOT override an owner's setting." Nothing below gates +> delivery on member count. The PR #963 regression, described in `ADR-020:131-134`, is exactly what this boundary prevents. + +## Context + +Sam: one pod shouldn't hold all 24 agents. He wants "smaller pods with a clearer goal boundary", at +most 4 or 5 agents per pod, with one agent allowed in several pods, and a sub-pod mechanism ("it +was in an earlier ADR"). That earlier ADR is ADR-016, which parks the idea, and idea-register P2 +(`docs/plans/idea-register.md:30`) describes it: "`parentPod` and a gated `/children` endpoint already +exist in the backend with **zero** v2 UI." + +**Ruling.** For the **fleet** (an operator policy, not product code): a working pod holds at most 5 +agents, and the coordination room holds leads only. For the **product**: there is **no hard cap**. +Past a default soft limit N, a pod suggests a sub-pod and shows who wakes on what. Goal, done +condition and owner are first-class fields, not chat text. When the done condition is met, the +child posts its result to the parent and is archived, and it keeps its membership and history. + +## 1. What exists today (`origin/main` @ `136709aa0`) + +The backend has the raw relation and nothing more. No v2 surface reads it. +- **Field.** `Pod.parentPod: ObjectId | null`, default null (`backend/models/Pod.ts:61`, `:134`). No index. +- **Create.** `POST /api/pods` → `createPod` reads `parentPod` from the body (`podController.ts:405`) + and writes `parentPod: parentPod || null` (`:442`). **Nothing checks it.** It doesn't confirm the + parent exists, that the caller is a member of it, that it isn't a DM kind, or that it forms no cycle. + The only gate is the DM-type refusal on the *child's* type (`:429-435`). +- **Read.** `GET /api/pods/:id/children` (`routes/pods.ts:601-627`) gates on `canViewPod(parent)` + (`:612-613`), then filters each child through `canViewPod(child)` (`:619-622`). It is human-JWT + only (`req.user?.id`). Listing and get-by-id populate `parentPod` (`podController.ts:252`, `:347`). +- **Agents can't create a child.** The runtime `POST /api/agents/runtime/pods` (`agentsRuntime.ts:3268`) + and the MCP tool `commonly_create_pod` (`commonly-mcp/src/tools.js:459-471`) take no `parentPod`. +- **One writer uses it.** `seed-community-pods.ts:45-78` upserts `Bug Reports` / `Feature Requests` + under the community HQ pod, each with `publicRead: true, communityListed: true` + (`:69-70`). It never reads the parent's tier. +- **Delete orphans children.** `deletePodCascade` (`services/podDeletionService.ts:24-40`) deletes + by `podId` and never touches pods whose `parentPod` points at the deleted one. +- **The goal and owner already have a home.** `Pod.focus = {goal, scope, ownerUserId, nextTaskIds, + revision}` (`Pod.ts:24-32`, `:193-201`), written by `PodFocusService.update` (`podFocusService.ts:199`). + It is human-only (`:203`), creator or admin only (`:209`), and the owner must be a current member (`:214`). + Readable via `GET/PATCH /api/pods/:id/focus` (`routes/pods.ts:342`, `:357`). +- **The legacy v1 shell did the opposite of this ADR.** `ChatRoom.tsx:1015` loads the *parent's* + board inside a child (`boardPodId = parentPodId || roomId`). v2 has zero references. +- Tasks already nest *within* one board (`Task.parentTask`, `models/Task.ts:25`). That is hierarchy among rows. It is not this. + +## 2. Fields: the sub-pod record + +A child pod is an ordinary pod (`type: 'team'` or `'chat'`) with `parentPod` set. **Goal and owner +reuse `Pod.focus`.** A second goal field would be a copy that drifts from the one the board and +runtime context already read (`podContextService.ts:324` selects `focus`). The new fields live in one subdocument: + +```ts +subPod?: { + doneWhen: string; // ≤ 500 chars; required to create a child; the done condition, plain language + status: 'active' | 'done' | 'archived'; + resultMessageId?: string; // the message posted in the PARENT (§5) + resultSummary?: string; // ≤ 2,000 chars, durable copy — see §8 on 30-day chat retention + doneAt?: Date; doneBy?: ObjectId; archivedAt?: Date; + revision: number; // CAS, same pattern as focusRevision (podFocusService.ts:236-241) +} +``` + +- **API view.** The API presents all five fields Sam named (`goal`, `doneWhen`, `owner`, `status`, + `resultMessageId`). `goal` and `owner` are read from `focus.goal` / `focus.ownerUserId`. +- **Create.** `POST /api/pods` with `parentPod` **requires** `goal`, `doneWhen` and `ownerUserId`, and + writes `focus` and `subPod` in the same insert. A child without a goal is the vague room this exists to stop. +- **Owner.** The owner may be a human **or an agent**: `focus` only requires an existing member + (`podFocusService.ts:214-217`). Editing stays human-only, as focus does today (`:203`). An agent + owner can *mark done* (§5) but can't rewrite its own goal. +- **Who judges done.** The kernel doesn't parse `doneWhen`. The owner declares it met, and the declaration is recorded with `doneBy`. + +## 3. Inheritance: a child is never wider than its parent + +**Invariant S1.** For every child C of parent P: `tier(C) ≤ tier(P)` on ADR-016's lattice, +`members(C) ⊆ members(P)`, and C's effective connector surface ⊆ P's. Like ADR-016, every *writer* +enforces this and no reader compensates. + +| Axis | Already enforced today | Must be added | +|---|---|---| +| **Read access** | `canViewPod` is membership-only (`dmService.ts:479-511`), so parent membership grants **nothing** in a child. That is the right direction, and nothing to add. | — | +| **Create** | nothing (`podController.ts:442`) | parent exists; caller is a writing member of P; P is not a DM kind (`DM_POD_TYPES_GUARD`, `agentIdentityService.ts:44`) and not `agent-admin`; P is not itself a child (depth 1, §10-2); C is born `private` | +| **Visibility tier** | the owner route checks owner, Pro and non-DM type only (`routes/pods.ts:703-739`), then sets both flags (`:741`) | refuse `community` on C unless P is `community`; the admin `showcase.publish` writer gets the same check; **un-listing P cascades to its children** (mirrors ADR-016 invariant 1's unpublish-cascades-unlist) | +| **Join** | `joinPod` gates on `isDirectlyJoinable` (`podController.ts:541`); invite redeem pushes the member (`podInvites.ts:268`); agents enter via `ensureAgentInPod` (`agentIdentityService.ts:730`) and the runtime create's join-by-name (`agentsRuntime.ts:3295`) | each of these four paths refuses a non-member of P: `403 parent_membership_required`. No auto-join of P, since that would widen P on C's behalf. | +| **Leave / remove** | `leavePod` / `removeMember` act on one pod (`podController.ts:592`, `:641`) | removal from P cascades to every descendant, **archived ones included**, because archived membership still grants read access | +| **Connector grants** | grants match by `target.id` equality (`nativeOutsiderGrantPolicy.ts:30-37`; `routes/grants.ts:602-603`), so a P grant **never reaches C**. Seat grants follow the seat, and since `members(C) ⊆ members(P)` that set in C ⊆ in P. Pod-grant audience must be current target members (`grants.ts:371-377`). | a grant targeting C must be an attenuation of an active grant on P from the same connection. The subset checks exist in `attenuateGrant` (`roomGrantService.ts:324-388`: tools `:333-341`, audience `:354-363`), but it **pins the child's target to the parent's** (`:379`). Add a retarget-to-child step that checks `C.parentPod === parent.target.id`. Revoking P's grant already cascades (`RoomGrant.revokeCascade`, `RoomGrant.ts:49`). Open question §10-3. | +| **Delete** | none (`podDeletionService.ts:24`) | deleting P is refused while it has non-archived children, or the delete archives them first. No orphans. | + +Every new check is an *entrance* guard, as ADR-016 describes its own. Existing rows aren't +rewritten (§9). The seed script must check that HQ is `community` before writing listed children, +or carry a named exemption, as ADR-016 did for it at `ADR-016:164`. + +## 4. Board: child rows stay on the child board; the parent shows a read-only rollup + +- **Rows live where the work is.** A child task has `podId = C`. Nothing copies it up, and this reverses + the v1 `ChatRoom.tsx:1015` behaviour. +- **The `requirePodMember` constraint.** Every task write requires membership of the row's pod + (`tasksApi.ts:198-219`; write refusal at `:209-211`; create `:322`, claim `:636`, complete `:707`, + updates `:805`, PATCH `:1041`). A parent member who isn't in C **can't write C's rows**, and that + stays. They also **can't read** them: a non-member read falls through to `canViewPod` (`:212-215`), which is membership-only. +- **Rollup.** New `GET /api/v1/tasks/:podId/rollup` returns, for each child the caller passes + `canViewPod` on (the same filter as `/children`, `routes/pods.ts:619-622`): name, goal, `subPod.status`, counts by + status, and the non-done rows (taskId, title, status, assignee). It is read-only, with no claim or + update affordance. A child the caller can't view is **absent**, not redacted, because its existence is + already hidden from `/children`. This is ADR-028 D12's audience floor (`ADR-028:519`) applied to the rollup. +- **The rollup doesn't wake anyone.** It is a read. A child row change wakes only that row's owner, as + today (§6), and parent seats see the change at their next read. + +## 5. Child→parent result, and escalation + +**Result.** When the owner marks the child done (`POST /api/pods/:id/sub-pod/done {summary}`), the kernel does three things in one CAS on `subPod.revision`: +1. It posts one structured message into P, authored by C's owner and marked `kind: 'sub-pod-result'`, carrying + the goal, `doneWhen`, the summary and a link to C. The owner can post there because S1 makes them a member of P. +2. It writes `resultMessageId`, `resultSummary`, `doneAt` and `doneBy`, and sets `status: 'done'`. +3. It archives C (§8). `done` is the transient state between the post and the archive. A failed post leaves C at `done`, and a retry is idempotent on `resultMessageId`. + +A result is **one message, not a thread of updates**. Progress stays in C. + +**Escalation.** A decision C can't make goes up as a `DecisionRequest` (`models/DecisionRequest.ts:31-48`, +classes `strategy | implementation | prioritization`, `:20`) **filed in P**, with a new optional +`escalatedFromPodId: C`. The blocked row in C goes to `status: 'blocked'` with the request id. ADR-018 +D8.4 already requires holds to be stored as `blocked` (`ADR-018:476-492`). When the ruling lands +(`ruling`, `:44`), the kernel mirrors it into C as a message addressed to the requester and unblocks +the row, which wakes its owner under ADR-018 D8.2 (`:464`). Privileged real-world acts still go through `ApprovalAction`, never this path (`DecisionRequest.ts:6-9`). + +## 6. Wakes (ADR-018 governs; this section states the consequences) + +- **D6.1. A parent message doesn't wake child members. That is already true.** Message fan-out selects installations by + the message's own `podId` (`agentMentionService.ts:1637-1640`), and board fan-out does the same + (`taskEventService.ts:263-266`). A seat in both P and C has two installations, and only P's sees a P message. + Its wake or digest mode is P's install setting (`agentMentionService.ts:1515-1516`). Nothing to build. The + child gets a regression test that a P message enqueues zero events for C-only installs. +- **D6.2. A child's result wakes only the parent owner. This is new, and it is proposed ADR-018 text.** It would deliver the + `sub-pod-result` message as an *addressed* wake to `P.focus.ownerUserId` (else `P.createdBy`) and as + **digest** to every other P install, whatever their `wakeOnMessage` setting. This is a rule about a *message + class*, not about room shape, so it doesn't break ADR-024 D4. But it does override an opted-in + seat's ambient wake for that one message, and ADR-018 says that override is ADR-018's to decide (`:435`). + Until the amendment is accepted, the result message follows the normal rules. Agent-authored, so the + D8.1 `humanOnly` default already keeps BYO-default seats at digest (`:420`). +- **D6.3. A seat in several pods is woken by each pod independently. That is already true.** Board wakes fold per (agent, + instance, **pod**) (`taskEventService.ts:194-197`, `:430`), discovery nudges are capped per seat + **per pod** (`ADR-018:474`), and the loop dampener takes `podId` (`agentMentionService.ts:1519-1520`). + **One exception, which should be kept:** the D8.4 continuation wake is capped "one per identity per thirty minutes, across every + pod" (`ADR-018:486-487`, D8.4). That cap is on the seat's total, which is what a 5-pod seat needs. +- **D6.4. The board.** Owner-only board wakes (#2561, `c8563a71d`; `taskEventService.ts:380-394`, non-owners get + `deliveryMode: 'digest'` at `:386-393`) apply inside C unchanged. Rollup reads never enqueue. + +## 7. Soft limit N, and the "who wakes on what" suggestion + +- **N is a UI default, not a check.** When `count(active agent installations in pod) > N`, the pod header + shows one dismissible suggestion: *"N+ agents share this pod. Split a sub-pod with its own goal?"* + The suggestion opens the create-child form with the parent prefilled. No write path, wake path or + install path reads N. A 30-agent pod works exactly as it does today. Per ADR-024 D4, "many agents all awake is the intended product". +- **Who wakes on what.** Next to the suggestion is a table with one row per agent install and three columns. It + is derived from the same fields the agent-states endpoint already reads (`routes/pods.ts:498-541`, + `AgentInstallation.config`): + `chat`: wake on every message / human messages only / mentions only (`wakeOnMessage.enabled`, `.humanOnly`); + `board`: own rows only / all rows (`boardWake.enabled`, which inherits `wakeOnMessage` when absent, `manage-settings.ts:40-41`); + `discovery`: on/off (`workDiscovery.role`, `ADR-018:474`). This shows the cost the split would cut. +- **Default N = 5 provisionally,** matching the fleet rule. It is replaced by the measured value before + this ADR is accepted, and it is configurable per instance. + +**Evidence for N: placeholder, still to be measured. Do not cite numbers from this table until it is filled in.** +Method: one fleet pod at 24 agents (before) vs. the same work split into ≤5-agent children plus a leads-only +room (after). Same 24 h window shape, same seats, counted from `AgentEvent` and spawn logs read-only. + +| Metric | Before (1 pod, 24 agents) | After (children ≤ 5) | Δ | +|---|---|---|---| +| Seat spawns per hour | _TBD_ | _TBD_ | _TBD_ | +| AgentEvents per hour: `chat.mention` | _TBD_ | _TBD_ | _TBD_ | +| AgentEvents per hour: `message.posted` (wake) | _TBD_ | _TBD_ | _TBD_ | +| AgentEvents per hour: `message.posted` (digest) | _TBD_ | _TBD_ | _TBD_ | +| AgentEvents per hour: board wakes (`payload.boardWake`) | _TBD_ | _TBD_ | _TBD_ | +| Board wakes per task edit | _TBD_ | _TBD_ | _TBD_ | +| Context tokens per turn (median / p90) | _TBD_ | _TBD_ | _TBD_ | + +Pick N at the knee where per-turn context and spawns-per-useful-reply stop improving. If the curve has no knee, say so and keep 5 as a judgment call. + +## 8. Archive + +- **Archived = read-only, still present.** `subPod.status: 'archived'`. Members and messages stay. + Message, task and focus writes are refused with `409 sub_pod_archived`. Agent installations in C + are set `status: 'paused'` (not deleted, per CLAUDE.md design rule 8, identity survives), so C generates zero events. +- **Listing.** The sidebar hides archived children by default. `/children` and the rollup return them with `status`, behind an "Archived" fold. +- **History is bounded by retention, which is why `resultSummary` exists.** Postgres chat is deleted + after 30 days (`models/pg/Message.ts:481-485`, `pgRetentionService`). An archived child's chat, and the + P message that `resultMessageId` points at, both age out. `resultSummary` and the child's task rows (Mongo) are the durable record. +- **Unarchive** sets `status: 'active'` and resumes the paused installs. Owner or creator only. It is audited. + +## 9. Migration and phases + +**No migration.** All new fields are optional. A pod with `parentPod: null` behaves exactly as today. Existing +children (the two seed pods, plus any created through the unchecked `createPod`) have no `subPod` and +are reported as "legacy child, no goal" until an owner sets one. Phase 0 counts rows that already +violate S1 (`members(C) ⊄ members(P)`, or tier wider than the parent's) **read-only**, and lists them for a human. +The checks are entrance guards and never rewrite these rows. + +| Phase | Ships | Done when | +|---|---|---| +| 0 | S1 violation count; before/after wake measure (§7 table) | table filled and N chosen | +| 1 | S1 entrance guards (§3 rows: create, tier, join ×4, leave cascade, delete) + tests per guard | each guard has a refusal test; a 3-level revoke test already exists for grants | +| 2 | `subPod` fields, create-with-goal, mark-done → result → archive; MCP `commonly_create_pod` gains `parentPod, goal, doneWhen, ownerUserId` | walked on prod: a child created, done, result visible in parent | +| 3 | v2 surface: child list under the parent, rollup on the parent board, N suggestion + who-wakes table | walked in a real browser on desktop and 390 px | +| 4 | ADR-018 amendment for D6.2; DecisionRequest `escalatedFromPodId` | amendment ratified; an escalation walked end to end | + +The fleet rule (≤5 agents per working pod, a leads-only coordination room) can be applied to the fleet +during Phase 0. It is an operator re-membership with no code dependency. + +## 10. Open questions (named in the status line) + +1. **N.** It is provisional until the §7 table is filled in. +2. **Depth.** Phase 1 allows one level. Do we want grandchildren? Every S1 check becomes a walk up the chain, and the rollup becomes recursive. +3. **Direct child grants.** Should a connection owner be able to grant straight to C, using their own + credential (which is safe for them), or only by attenuating P's grant? The second is the strict reading of "never wider". +4. **The result-wake override.** Should D6.2 be accepted into ADR-018, or should the result stay subject to + each seat's ambient setting? +5. **Done without an owner.** If the owner leaves P (and so, by cascade, C), who can mark C done? The proposal is P's owner. + +**Proposed answers (Cofounder recommendation, for Sam to ratify at review):** +1. N = 5, a UI suggestion only, revisited once the after-measure is in. +2. One level only in v1; the API refuses deeper nesting. +3. A grant reaches a child only by attenuating the parent's grant: never wider, never a direct grant. +4. The ADR-018 D6.2 amendment ships as proposed text in the same PR and is ratified together with this ADR. Neither is Accepted without the other. +5. P's owner (as proposed above). + +## 11. Why a team wants this + +A team that puts its agents in one room gets one room where every agent hears everything. Each agent +reads more than it needs to and wakes for things it doesn't own, and nobody can say what that room is +*for*. Sub-pods let a team carve off a piece of work: "migrate billing to the new API, done when the +old endpoint has zero traffic for a day". The work gets its own owner and a handful of agents, and it +can't see or grant anything the parent room doesn't already have. The people in the parent room +aren't interrupted while the work runs. They see a rollup of its board, they're asked only for +decisions the child can't make, and they get one result message when it's done. The child then +archives with its history intact. That's how a manager already splits work across a team, applied to +a mixed team of agents and people. From 28059703a19d0eeb1077c8d2d2888b8412f62c75 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:07:11 -0700 Subject: [PATCH 2/5] fix(landing): count ADR-032 in the page's ADR total (TASK-533) The landing page states its ADR count as a literal, and v2-layout-invariants.test.ts checks it against the files in docs/adr. ADR-032 makes 31. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- frontend/src/v2/landing/V2LandingPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/v2/landing/V2LandingPage.tsx b/frontend/src/v2/landing/V2LandingPage.tsx index ff16b9f394..7d8c396011 100644 --- a/frontend/src/v2/landing/V2LandingPage.tsx +++ b/frontend/src/v2/landing/V2LandingPage.tsx @@ -33,7 +33,7 @@ const X_HANDLE = 'https://x.com/sam_commonly'; // before install.sh runs; the earlier one-liner skipped both, so anyone who // pasted it got a bare clone and no started stack (TASK-152). const SELF_HOST_COMMAND = 'git clone https://github.com/Team-Commonly/commonly.git && cd commonly && ./install.sh'; -const ADR_COUNT = 30; +const ADR_COUNT = 31; // A count on a marketing page drifts silently: nothing on the page notices a // new ADR, and docs/ is outside this image's build context so the page cannot // count at build time. The TASK-167 row D test counts docs/adr/ADR-*.md and From 9d702dcda7ed2e9de9bf3d089a03fdd508462569 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:15:48 -0700 Subject: [PATCH 3/5] docs(adr): ADR-032 describes canViewPod as it is, admins included (TASK-533) The draft called canViewPod membership-only in two places (section 3's read-access row and section 4's board paragraph). It also admits global admins (dmService.ts:491-498), and for agent-dm pods only, the shared-pod fan-out. A child is never a DM kind, so for a child it admits members and admins. The draft's conclusion holds: parent membership grants nothing in a child, and admins read P too, so C is no wider than P. This is the fifth named deviation from the draft. CodeRabbit found it on review 5642863078. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/adr/ADR-032-sub-pods.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-032-sub-pods.md b/docs/adr/ADR-032-sub-pods.md index 919f994ee8..34c3bfd9eb 100644 --- a/docs/adr/ADR-032-sub-pods.md +++ b/docs/adr/ADR-032-sub-pods.md @@ -97,7 +97,7 @@ enforces this and no reader compensates. | Axis | Already enforced today | Must be added | |---|---|---| -| **Read access** | `canViewPod` is membership-only (`dmService.ts:479-511`), so parent membership grants **nothing** in a child. That is the right direction, and nothing to add. | — | +| **Read access** | `canViewPod` admits members and global admins (`dmService.ts:479-514`; the admin read is `:491-498`), plus an `agent-dm` fan-out that a child can't use, since a child is never a DM kind. So parent membership grants **nothing** in a child, and the admin read doesn't make C wider than P, because admins read P too. That is the right direction, and nothing to add. | — | | **Create** | nothing (`podController.ts:442`) | parent exists; caller is a writing member of P; P is not a DM kind (`DM_POD_TYPES_GUARD`, `agentIdentityService.ts:44`) and not `agent-admin`; P is not itself a child (depth 1, §10-2); C is born `private` | | **Visibility tier** | the owner route checks owner, Pro and non-DM type only (`routes/pods.ts:703-739`), then sets both flags (`:741`) | refuse `community` on C unless P is `community`; the admin `showcase.publish` writer gets the same check; **un-listing P cascades to its children** (mirrors ADR-016 invariant 1's unpublish-cascades-unlist) | | **Join** | `joinPod` gates on `isDirectlyJoinable` (`podController.ts:541`); invite redeem pushes the member (`podInvites.ts:268`); agents enter via `ensureAgentInPod` (`agentIdentityService.ts:730`) and the runtime create's join-by-name (`agentsRuntime.ts:3295`) | each of these four paths refuses a non-member of P: `403 parent_membership_required`. No auto-join of P, since that would widen P on C's behalf. | @@ -116,7 +116,7 @@ or carry a named exemption, as ADR-016 did for it at `ADR-016:164`. - **The `requirePodMember` constraint.** Every task write requires membership of the row's pod (`tasksApi.ts:198-219`; write refusal at `:209-211`; create `:322`, claim `:636`, complete `:707`, updates `:805`, PATCH `:1041`). A parent member who isn't in C **can't write C's rows**, and that - stays. They also **can't read** them: a non-member read falls through to `canViewPod` (`:212-215`), which is membership-only. + stays. They also **can't read** them: a non-member read falls through to `canViewPod` (`:212-215`), which admits only members and global admins to a non-DM pod. - **Rollup.** New `GET /api/v1/tasks/:podId/rollup` returns, for each child the caller passes `canViewPod` on (the same filter as `/children`, `routes/pods.ts:619-622`): name, goal, `subPod.status`, counts by status, and the non-done rows (taskId, title, status, assignee). It is read-only, with no claim or From 4a4c17ed4719892240bf40754cd4086980d73e9f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:14:36 -0700 Subject: [PATCH 4/5] docs(adr): ADR-032 names the admin exception where a parent member reads a child (TASK-533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §4 board paragraph said a parent member outside C can't read C's rows, then explained that canViewPod admits members and global admins. The bold claim now carries the same exception, which completes named deviation 5. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/adr/ADR-032-sub-pods.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/ADR-032-sub-pods.md b/docs/adr/ADR-032-sub-pods.md index 34c3bfd9eb..07ffea4cfa 100644 --- a/docs/adr/ADR-032-sub-pods.md +++ b/docs/adr/ADR-032-sub-pods.md @@ -116,7 +116,7 @@ or carry a named exemption, as ADR-016 did for it at `ADR-016:164`. - **The `requirePodMember` constraint.** Every task write requires membership of the row's pod (`tasksApi.ts:198-219`; write refusal at `:209-211`; create `:322`, claim `:636`, complete `:707`, updates `:805`, PATCH `:1041`). A parent member who isn't in C **can't write C's rows**, and that - stays. They also **can't read** them: a non-member read falls through to `canViewPod` (`:212-215`), which admits only members and global admins to a non-DM pod. + stays. They also **can't read** them unless they are a global admin: a non-member read falls through to `canViewPod` (`:212-215`), which admits only members and global admins to a non-DM pod. - **Rollup.** New `GET /api/v1/tasks/:podId/rollup` returns, for each child the caller passes `canViewPod` on (the same filter as `/children`, `routes/pods.ts:619-622`): name, goal, `subPod.status`, counts by status, and the non-done rows (taskId, title, status, assignee). It is read-only, with no claim or From 59580b950dc56616e898f62270e288062f921f82 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:38:55 -0700 Subject: [PATCH 5/5] docs(adr): ADR-032 names the upward flow and matches section 11's rollup to section 4 (TASK-533) Both gates found two places where the draft contradicts itself about who sees what. Section 5 posts a child's result into the parent and files its escalations there, where parent members outside the child read them, but section 3's "never wider" rule named no exception. Section 3 now names that upward flow as S1's one exception and lists exactly what leaves the child, including the DecisionRequest fields an escalation carries (DecisionRequest.ts:36-40), which the draft left unspecified. Section 5 points at it. Section 11 said everyone in the parent room sees the child's rollup. Section 4 shows a child only to callers who can view it, so section 11 now says those who are also in the child see it. Section 4 is unchanged. These are named deviations 6 and 7 from the draft. Rhea's third item, a re-check at unarchive, stays listed in the PR body for the ratifier. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/adr/ADR-032-sub-pods.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-032-sub-pods.md b/docs/adr/ADR-032-sub-pods.md index 07ffea4cfa..a961310427 100644 --- a/docs/adr/ADR-032-sub-pods.md +++ b/docs/adr/ADR-032-sub-pods.md @@ -105,6 +105,19 @@ enforces this and no reader compensates. | **Connector grants** | grants match by `target.id` equality (`nativeOutsiderGrantPolicy.ts:30-37`; `routes/grants.ts:602-603`), so a P grant **never reaches C**. Seat grants follow the seat, and since `members(C) ⊆ members(P)` that set in C ⊆ in P. Pod-grant audience must be current target members (`grants.ts:371-377`). | a grant targeting C must be an attenuation of an active grant on P from the same connection. The subset checks exist in `attenuateGrant` (`roomGrantService.ts:324-388`: tools `:333-341`, audience `:354-363`), but it **pins the child's target to the parent's** (`:379`). Add a retarget-to-child step that checks `C.parentPod === parent.target.id`. Revoking P's grant already cascades (`RoomGrant.revokeCascade`, `RoomGrant.ts:49`). Open question §10-3. | | **Delete** | none (`podDeletionService.ts:24`) | deleting P is refused while it has non-archived children, or the delete archives them first. No orphans. | +**One flow goes up on purpose.** S1 bounds who can read and act in C. It doesn't stop C's content +from reaching P: §5's result message and an escalation both land in P, where members who aren't in C +read them. Both also show that C exists, which `/children` hides from those members (§4). This is +the one named exception to "never wider", and exactly this leaves C: + +- **The result message:** C's goal, `doneWhen`, the owner's summary and a link to C. +- **An escalation:** a `DecisionRequest` with the fields its requester writes (`title`, `question`, + `context`, two to four `options`, and `decisionClass`; `DecisionRequest.ts:36-40`), plus + `escalatedFromPodId`. + +The kernel attaches nothing else from C: no rows, messages or files. Whatever the requester writes +into those fields is disclosed to P. + Every new check is an *entrance* guard, as ADR-016 describes its own. Existing rows aren't rewritten (§9). The seed script must check that HQ is `community` before writing listed children, or carry a named exemption, as ADR-016 did for it at `ADR-016:164`. @@ -142,6 +155,9 @@ D8.4 already requires holds to be stored as `blocked` (`ADR-018:476-492`). When (`ruling`, `:44`), the kernel mirrors it into C as a message addressed to the requester and unblocks the row, which wakes its owner under ADR-018 D8.2 (`:464`). Privileged real-world acts still go through `ApprovalAction`, never this path (`DecisionRequest.ts:6-9`). +Both the result and an escalation put C's content in P. §3 names them as S1's one exception and +lists exactly what leaves C. + ## 6. Wakes (ADR-018 governs; this section states the consequences) - **D6.1. A parent message doesn't wake child members. That is already true.** Message fan-out selects installations by @@ -249,7 +265,8 @@ reads more than it needs to and wakes for things it doesn't own, and nobody can *for*. Sub-pods let a team carve off a piece of work: "migrate billing to the new API, done when the old endpoint has zero traffic for a day". The work gets its own owner and a handful of agents, and it can't see or grant anything the parent room doesn't already have. The people in the parent room -aren't interrupted while the work runs. They see a rollup of its board, they're asked only for -decisions the child can't make, and they get one result message when it's done. The child then +aren't interrupted while the work runs. They're asked only for decisions +the child can't make, and they get one result message when it's done. Those who are also in the +child see its board as a rollup on the parent (§4). The child then archives with its history intact. That's how a manager already splits work across a team, applied to a mixed team of agents and people.