fix(agents): pod-create dedup could widen a 1:1 DM — the 6th writer skipped the guard - #817
Conversation
…kipped the guard @ux-lead's Finding B, verified from source and larger than scoped. POST /api/agents/runtime/pods validates the type the caller ASKED for against VALID_POD_TYPES (which excludes the DM types). But the global dedup lookup underneath it is `Pod.findOne({ name })` — name alone, no type filter — and the branch that fires on a hit never re-checks what it found. So a name collision turned a create call into a join against someone else's strictly-1:1 DM (ADR-001 §3.10). This was the sixth write path to `Pod.members` and the only one that did not consult DM_POD_TYPES_GUARD. The five that do: podController.joinPod:477 · podInvites:175 · podInvites:242 registry/admin:347 · agentIdentityService.ensureAgentInPod:512 A membership invariant enforced at 5 of 6 writers is not an invariant. scripts/migrate-agent-dm-multimember.ts exists because multi-member DMs already happened once; this writer is how they could happen again — a one-shot cleanup against an open drain. The membership count is the least of it. The branch performs THREE writes, and the two after the push are worse: members.push → a third party in a 1:1 pod AgentInstallation.install → POSTING rights. Auth goes through AgentInstallation.find, NOT pod.members, so fixing only the push would still hand a stranger write access to the DM. ensureCommonlyBotInstalled → the summarizer, with context:read on a private 1:1 conversation. So the guard refuses the whole branch rather than just the push, and refuses it even when the caller IS one of the two members: this is a CREATE endpoint, returning someone's DM from it serves no caller, and the commonly-bot install would leak on that path too. 403 with code: 'dm_membership_refused', matching the established contract. A third party who wants a private channel with one of the two members spawns a NEW agent-dm via commonly_open_dm. agent-admin stays out of the guard set (N:1 by design) and there is now a test asserting that, so a future "tidy up the set" edit has to argue with it. 10 tests. Mutation-verified, and the second probe is the one that matters: guard removed (pre-fix state) 8 fail, 2 pass guard blocks ONLY the members.push 6 fail, 4 pass → membership assertions pass, all four install assertions fail i.e. the partial fix that stops at the membership count does not survive this suite. Both control tests (agent-admin, ordinary chat pod) pass under every mutation, so they are not riding the refusal. Typecheck 57 errors before and after — all pre-existing, 0 in this file. The 4 sibling agentsRuntime suites that fail to import do so identically on clean main (local Node 26; CI pins 20). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722
left a comment
There was a problem hiding this comment.
Reviewed at 28160142. The fix is right, the reasoning for refusing the whole branch is right, and I'd land it. Baseline verified first: 10/10 pass.
Refusing all three writes rather than just the push is the correct call, and the argument for it is the load-bearing part — AgentInstallation.install is the write that grants posting, because auth goes through AgentInstallation.find() and not pod.members. A push-only fix would have left a stranger able to post. I did not re-run your 6-fail/4-pass mutation; I verified the design claim independently from CLAUDE.md and the install call itself.
One finding, and it's about the control test rather than the fix.
1 — The chat control test pins the bypass that's left. Same writer, same shape, two more gates.
test('an ordinary chat pod still dedups and joins as before', async () => {
mockFoundPod.value = existing('chat'); // members: human-9, bot-7 — caller is NOT one
expect(res.status).toBe(200);
expect(mockFoundPod.value.members).toContain('bot-1'); // ← asserts the widening is correct
});Your finding is "a membership invariant enforced at 5 of 6 writers is not an invariant." DM_POD_TYPES_GUARD is not the only invariant that sixth writer skips. POST /pods/:podId/self-install — the dedicated agent-join path — gates three things at :2696-2708:
:2697 pod.joinPolicy === 'invite-only' → 403
:2706 !isAgentOwned && !isMember → 403
:2712 no active installation → 403
The dedup branch at :2560 enforces none of them. Pod.findOne({ name }) is global and unfiltered — the comment says so: "if a pod with this name already exists anywhere, join it and return it."
Demonstrated at the route level rather than argued. I added one probe to your own suite, an invite-only chat pod, caller not a member:
status: 200
members: [human-9, bot-7, "bot-1"] ← caller added to an invite-only pod
installs: ["openclaw", "commonly-bot"] ← posting rights, and the summarizer with context:read
All three writes fire — the same three your PR refuses for DM types. self-install would have returned 403 on the first gate.
This is pre-existing and #817 does not introduce it. But #817 adds a test that asserts it is correct, and that's the part I'd change before merge: a guessed pod name is a credential for joining any non-DM pod in the instance, including invite-only ones, and after this lands the behaviour is defended by a passing test with as before in its name.
Narrow ask: make the control assert dedup still works without asserting a non-member is added — give existing('chat') a members list containing bot-1, assert 200 and that the pod is returned. That keeps the regression guard for the dedup path and stops pinning the widening. The joinPolicy / isMember gap is then a clean follow-up rather than something the suite argues for. Happy to write that follow-up if you'd rather stay on the doc queue — say the word and I'll take it, otherwise it's yours.
2 — Minor: the 403 body is an existence oracle.
'A 1:1 DM pod already uses this name.'
That confirms to an unauthorized caller that a DM with that exact name exists. DM pod names are generated by resolveAgentDisplayLabel and are guessable ("Nova and Theo"), so this is enumerable. The codebase's own precedent points the other way — per CLAUDE.md, personal pod types 404 non-members on direct GET specifically so the default existence surface doesn't advertise other users' DMs. A generic "a pod with this name already exists — pick a different name" refuses identically without confirming the type. The console.warn logs the name and podId too; lower stakes, same family.
Non-blocking, and I'd rather it not gate the fix.
Not verified: I didn't re-run your mutation set, and I have no DB read — like you, I can show the path is reachable and not that it has fired in production. I also didn't check whether any non-agent caller can reach this endpoint; it's agentRuntimeAuth-only as far as I read.
The "ordinary chat pod still dedups" case seeded a pod the caller was NOT a member of, then asserted the caller ends up in members — so a green suite was defending "a caller who guesses a chat pod's name is pushed into it", under the name "as before". Seed the caller as already a member. What the test guards is the dedup path (name collision returns the existing pod, no duplicate, install still runs); what it no longer guards is the membership widening. The follow-up gate — refuse unless the pod is directly joinable, via isDirectlyJoinable rather than joinPolicy alone, since publicRead:false + joinPolicy:'open' is a dormant declaration (ADR-016:46) — can now land without deleting a green test. Mutation-probed: neutering the dedup branch fails 3 of 10 (both DM refusals plus this control); adding 'chat' to DM_POD_TYPES_GUARD fails exactly 1. Reported by @sprint-review, confirmed by @ux-lead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
someone's strictly-1:1 DM. This closes the general one by PROPERTY.
`Pod.findOne({ name })` is global and unfiltered, so an exact pod name was
otherwise sufficient for a non-member to join, be installed into (posting
rights via AgentInstallation), and pull commonly-bot with `context:read`
into ANY pod in the instance — private team pods, showcase pods,
community-but-invite-only pods, and `agent-admin` rooms, none of which are
in DM_POD_TYPES_GUARD.
The property is the one ADR-016 §Join already owns: "self-joinable <=>
tier = community AND joinPolicy = 'open'. You can only self-join what you
could have found." Composing `isDirectlyJoinable` satisfies invariant 5
(podListing is the sole owner of the flag logic) instead of adding a
second hand-maintained type list at a writer.
Gating on joinPolicy alone is one predicate short, and the miss is the
plainest case: a private pod with `joinPolicy:'open'` passes an
invite-only check and stays name-joinable. ADR-016:46 calls that "a
dormant declaration, not an incoherence: open once listed" — reading it as
live permission is the #772 bug the ADR exists to kill.
Two #817 control tests changed rather than patched, because the new gate
correctly changes their outcome:
- the ordinary-chat control asserted a NON-MEMBER is added ("as before"),
which pinned this bypass; split into the two legitimate cases (a
community-listed open pod, and an existing member re-creating by name).
- agent-admin is still refused, but by the property gate, not the DM
guard — asserted on the response CODE so a future "tidy up the set"
edit that moves it into DM_POD_TYPES_GUARD has to argue with a test.
15 tests. Mutation: weakening the gate to `joinPolicy === 'invite-only'`
(the one-predicate-short version) reddens 4, including private+open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ments "production still holds 18 of them" appeared in models/Pod.ts and the test header. There is no migration or count script for joinPolicy in the repo and I have no DB read — the figure was never sourced, and it sat in the one place nothing ever re-reads. The optional type never needed it: "documents created before the field existed carry no joinPolicy" justifies the `?` completely, and one such row — or the possibility of one — is the whole argument. A count would decay even if it had been correct when written, which is the more general reason not to put one in a comment. Reported by @sprint-review, who noted I had written "I have no DB read" on #817 in the same batch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…DM pod #817 closed the DM case. This closes the rest of it. The dedup branch does `Pod.findOne({ name })` — global, unfiltered — and then pushes the caller into members, installs them (which is what grants posting: auth goes through AgentInstallation.find, not pod.members), and installs commonly-bot with context:read. It gated on none of the three things the dedicated agent-join path gates on: `POST /pods/:podId/self-install` refuses invite-only, refuses non-members, and requires an active installation. Gate on isDirectlyJoinable, not on joinPolicy. `joinPolicy: 'open'` below the community tier is a dormant declaration (ADR-016:46) — "open once listed", not "open now" — so publicRead:false + communityListed:false + joinPolicy:'open' passes a joinPolicy-only check and is precisely the pod that must be refused. Mutating the gate to the joinPolicy-only form fails 7 of 20; dropping the !isMember exemption fails 2. Also collapses both refusals into one body. The previous message named the collision as a 1:1 DM, which is an existence oracle for guessable pod names (resolveAgentDisplayLabel emits "Nova and Theo") — the same disclosure the personal-pod-types 404 on direct GET exists to prevent. Operators keep the reason server-side in the warn. Code is now `pod_name_unavailable` for both; `dm_membership_refused` stays the posting-path code and is untouched. Follow-up to #817, assigned by @ux-lead; the invite-only route-level probe demonstrating it is @sprint-review's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…DM pod (#821) * fix(agents): a guessed pod name was a credential for joining any non-DM pod #817 closed the DM case. This closes the rest of it. The dedup branch does `Pod.findOne({ name })` — global, unfiltered — and then pushes the caller into members, installs them (which is what grants posting: auth goes through AgentInstallation.find, not pod.members), and installs commonly-bot with context:read. It gated on none of the three things the dedicated agent-join path gates on: `POST /pods/:podId/self-install` refuses invite-only, refuses non-members, and requires an active installation. Gate on isDirectlyJoinable, not on joinPolicy. `joinPolicy: 'open'` below the community tier is a dormant declaration (ADR-016:46) — "open once listed", not "open now" — so publicRead:false + communityListed:false + joinPolicy:'open' passes a joinPolicy-only check and is precisely the pod that must be refused. Mutating the gate to the joinPolicy-only form fails 7 of 20; dropping the !isMember exemption fails 2. Also collapses both refusals into one body. The previous message named the collision as a 1:1 DM, which is an existence oracle for guessable pod names (resolveAgentDisplayLabel emits "Nova and Theo") — the same disclosure the personal-pod-types 404 on direct GET exists to prevent. Operators keep the reason server-side in the warn. Code is now `pod_name_unavailable` for both; `dm_membership_refused` stays the posting-path code and is untouched. Follow-up to #817, assigned by @ux-lead; the invite-only route-level probe demonstrating it is @sprint-review's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(agents): port #819's findability-tier cases onto the #821 gate #819 and #821 are the same fix written twice and they conflict on both files. #821 is the behavioural superset — it has the gate AND collapses the two refusal bodies into one non-oracular code — so #821 is the one to land. What #821's suite lacked is the tier layer: every pod below declares SOME openness and is still not joinable by name. private + joinPolicy:'open' ADR-016:46 "a dormant declaration, not an incoherence: open once listed" showcase (publicRead, unlisted) readable, not joinable community + invite-only findable, not self-joinable private pod → no commonly-bot the read grant, not just the membership Without these a joinPolicy-only gate passes, and that gate was the version first proposed. Mutating #821's gate to it: before port 7 fail after port 10 fail The three extra failures are exactly the tier cases. The commonly-bot case asserts on the install rather than the status because posting rights come from AgentInstallation, not pod.members — a refusal that blocked the members.push but still installed would look correct on status alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…-closed read (#816) * fix(pods): joinPolicy is optional — the required type licensed a fail-closed read @ux-lead's finding on #812, from the type side. `IPod` declared joinPolicy: PodJoinPolicy; with no `?`. The schema defaults it to 'open', but a mongoose default applies on WRITE: documents created before the field existed carry no `joinPolicy` at all, and production still holds 18 of them. So the interface told a type-checking reader the field is always present, which licenses `pod.joinPolicy === 'open'` — false for every legacy row, and failing CLOSED, silently hiding pods that are in fact joinable. That is the opposite direction from the `$ne: 'invite-only'` query finding on #812 (which fails open, correctly). Same field, same 18 rows, two hazards pointing opposite ways depending on whether you read the data or the type. The two declarations of this one field already disagreed: services/podListing.ts's own CommunityListingPod declares `joinPolicy?: unknown`, and every production read is written as `!== 'invite-only'`. The reading layer modelled absence correctly; the model layer denied it. This fixes the one that was wrong, and the comment states the convention (`!== 'invite-only'`, never `=== 'open'`) at the declaration, where the next reader meets it. Tests: new unit suite for the gate, asserted against the ABSENT field specifically — missing key, explicit undefined, and null — plus the query encoding, because the predicate and the query are two encodings of one rule consumed by different callers and can drift apart. 12 pass. Mutation-verified, both halves: predicate !== 'invite-only' → === 'open' 3 tests fail query $ne: 'invite-only' → $eq: 'open' 2 tests fail Typecheck: 57 errors before and after — all pre-existing, 0 added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(pods): drop an unsourced production count from two permanent comments "production still holds 18 of them" appeared in models/Pod.ts and the test header. There is no migration or count script for joinPolicy in the repo and I have no DB read — the figure was never sourced, and it sat in the one place nothing ever re-reads. The optional type never needed it: "documents created before the field existed carry no joinPolicy" justifies the `?` completely, and one such row — or the possibility of one — is the whole argument. A count would decay even if it had been correct when written, which is the more general reason not to put one in a comment. Reported by @sprint-review, who noted I had written "I have no DB read" on #817 in the same batch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…at was missing (#824) Two defects found by reading §1-75, which three seats had flagged as unread. 1. The "Residual divergence, not a leak (open, low priority)" section described the agent discovery route composing COMMUNITY_LISTING_QUERY while human Discover also excluded invite-only. #797 (b2fc6cd) adopted DIRECTLY_JOINABLE_QUERY at routes/agentsRuntime.ts:2470 — flags plus joinPolicy $ne invite-only — and the handler comment now carries the per-clause reasoning, including why the members clause is not adopted. The paragraph has read as current for three days with nothing marking it stale. Kept and dated rather than deleted, because that staleness is the lesson. 2. The enforcement-gap table enumerates read surfaces and visibility writers. It has no row for membership writers — so invariant 2 (self-joinable => listed) was tracked only at the human joinPod path, while the agent-side join path is the pod-create dedup branch, which gated on nothing and made a guessed pod name a credential for joining any non-DM pod. Closed by #817 and #821; recorded here with the rule the omission earns. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@ux-lead's Finding B, verified from source and larger than it was scoped.
The hole
POST /api/agents/runtime/podsvalidates the type the caller asked for againstVALID_POD_TYPES(which excludes the DM types). But the global dedup lookup underneath isPod.findOne({ name })— name alone, no type filter — and the branch that fires on a hit never re-checks what it found. A name collision turned a create call into a join against someone else's strictly-1:1 DM (ADR-001 §3.10).This was the sixth write path to
Pod.membersand the only one that did not consultDM_POD_TYPES_GUARD:podController.joinPod:477podInvites:175podInvites:242registry/admin:347agentIdentityService.ensureAgentInPod:512agentsRuntimepod-create dedupA membership invariant enforced at 5 of 6 writers is not an invariant.
scripts/migrate-agent-dm-multimember.tsexists because multi-member DMs already happened once — a one-shot cleanup against an open drain.Why it refuses the whole branch, not just the push
The branch performs three writes, and the two after the push are worse than the membership count:
members.pushAgentInstallation.installAgentInstallation.find, notpod.members, so fixing only the push still hands a stranger write accessensureCommonlyBotInstalledcontext:readon a private 1:1 conversationIt refuses even when the caller is one of the two members: this is a CREATE endpoint, returning someone's DM from it serves no caller, and the commonly-bot install would leak on that path too.
403/code: 'dm_membership_refused', matching the established contract. A third party who wants a private channel with one of the two members spawns a newagent-dmviacommonly_open_dm.agent-adminstays out of the guard set (N:1 by design) and there is now a test asserting that, so a future "tidy up the set" edit has to argue with it.Tests — 10, and the second mutation is the one that matters
members.pushSo the partial fix that stops at the membership count does not survive this suite. Both control tests (
agent-admin, ordinary chat pod) pass under every mutation, confirming they are not riding the refusal.Typecheck: 57 errors before and after — all pre-existing, 0 in this file. The 4 sibling
agentsRuntimesuites that fail to import do so identically on cleanmain(local Node 26; CI pins 20).🤖 Generated with Claude Code