Skip to content

fix(identity): refuse a shared-connection write under an undeclared identity - #535

Merged
atlas-from-plumb merged 11 commits into
mainfrom
atlas/fix-513-undeclared-identity
Oct 1, 2026
Merged

atlas-from-plumb merged 11 commits into
mainfrom
atlas/fix-513-undeclared-identity

Conversation

@atlas-from-plumb

@atlas-from-plumb atlas-from-plumb commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Why

On a connection shared by several logical agents, a state-changing call carrying a per-call identity that no session_start had declared was admitted. It might be a model typing plumb_agent: "my-session", or a client sending _meta it never announced. refuse admitted any non-empty id, and shardFor made the id a fresh shard seeded from the connection's root, so a relative write landed in whatever checkout the connection held. That is the misroute (#512) the fail-closed ceiling exists to prevent.

Change

  • Gate (internal/cli/conn_logical_agent.go, refusal). On a connection that is shared once the caller is counted, a state-changing call is refused when its identity's linkage (linkageIDOf, the conversation half) was never declared through a successful session_start. This is the same sharedWith predicate shardFor routes on, so the gate refuses exactly the calls that would be given a per-agent shard. The undeclared refusal has its own message: call session_start with that session_id first. session_start is not state-changing, so that step is always reachable. Reads are never refused. A single-agent connection is unaffected.
  • Declaration (internal/cli/conn_logical_agent_declared.go). session_start's session_id counts as a declaration (via linkExternalID, so recordAttach declares). So does the per-call identity that a successful session_start ran under (after-tool hook). A failed session_start declares nothing, and no other tool does. A hook-stamped subagent <conv>/<agent> is admitted on its conversation's declaration.
  • Restart (sessionstate v9 declared_linkage, restored in onProxySession). Declarations persist under the proxy session and come back on reconnect. So does the identity record's own linkage, which Prune never reclaims. Restoring can only admit more, never refuse more, so it cannot cause the kind of lockout that led to seedLogicalAgentsFromState being unwired. logical_agent rows are deliberately not used as a source: they record every observed identity, so an invented id that made one admitted read would come back declared. Known limit, documented in the code: a declaration older than the TTL that a restart pruned, from a conversation that is not the identity record's linkage, has to re-declare. The refusal tells it how.
  • Docs (tools.md, architecture.md, threat-model.md) and a CHANGELOG entry.

Tests

  • Unit tests at the gate (conn_logical_agent_declared_test.go):
    • an invented id is refused with the remedy, and a read under it is not;
    • the declared agents and <declared-conv>/x are admitted, while <undeclared>/x is refused;
    • only a successful session_start declares;
    • a single-agent connection is unaffected;
    • an undeclared second identity is refused from its first write, and the refusal records nothing;
    • declarations survive a restart, and an only-observed id does not come back declared;
    • the identity-record linkage survives a pruned row, with the pruned peer as the control.
  • Desktop harness (desktop_connector_identity_integration_test.go, InventedIdentityIsRefusedNotMisrouted). Two agents are declared, and a third plumb_agent id's relative write is refused, lands in neither checkout, and leaves no identity behind. Controls: the declared agent's write lands in its own worktree, and a subagent conv-x/sub is admitted and recorded. Calling session_start then admits the invented id.
  • sessionstate: round trip plus Prune with the live exemption, and v9 on the v1 upgrade path.
  • Existing tests that treated a merely-stamped identity as admitted now declare it first (the semantics changed on purpose).
  • Red on origin/main: the harness subtest fails there. The invented write was admitted and created INVENTED.md in the main checkout. A gate test built on the origin/main API fails the same way.
  • Mutation: I made 14 mutants of the new code. These covered: admitting undeclared ids; the len(seen)>1 predicate; keying on the full id; a failed session_start or any tool declaring; restore not wired; each restore source removed; the declaration not persisted; the attach channel not declaring; Prune keeping rows; restoring from logical_agent; a refused call recording its identity; the v9 migration missing. All 14 went red.
  • go test ./... -count=1 and go test -tags=integration ./internal/cli/ both exit 0. The changelog placement check passes.

Review fold-in (dfa557e)

  • B1: subagents start where their conversation works. shardFor seeded <conv>/<x> from the connection pin, so a subagent of a parent that had re-pinned itself to a worktree wrote into another agent's checkout. It is now seeded from the parent's shard when the parent chose or restored a root, or from the parent's persisted pin after a restart. It also inherits the parent's refused declaration until it chooses a root of its own. This applies to relative paths and to git's default repository (workspaceFor). The desktop control now uses conv-y/sub and checks that its write lands in conv-y's worktree.
  • One conversation needs no declaration. Some connections only ever carry one linkage: a main thread that never called session_start, plus its own subagents (the Claude Code CLI topology). Such a connection is admitted. An identity with another linkage breaks this and is refused.
  • After-tool declaration is now exercised. The desktop harness wires OnAfterTool. A _meta-only session_start with no session_id then declares its identity.
  • Restart claims are now honest. An admitted state-changing call from a declared linkage refreshes its row (update only, throttled). The docs and CHANGELOG now say that a declaration survives a restart only while the conversation is active within persist_state_ttl_minutes, and not at all with persist_state off.
  • Refusal wording. For a subagent, the refusal now names its conversation's id as the session_id to declare.
  • Failed session_start declares nothing. session_start validates detail before linking, so a call that fails on a bad detail no longer declares.
  • Degraded restore. A degraded identity recovery re-runs the declaration restore when it converges.
  • Mutation testing. I re-ran all 14 original mutants plus 17 new ones from a private temp dir. All 31 went red.

Round-2 fold-in (62b1235, rebased on main)

  • N1: a subagent follows its conversation. Previously a subagent's shard only followed its conversation when the shard was created. Now, when repinAgent moves a conversation, followParentShard runs after the unlock. It re-seeds every <conv>/* shard still on the old root that never chose a root of its own and was not restored from its own saved pin. The new root applies to relative paths and git's default repository alike.
  • N1b: connection moves. followConnectionShards no longer drags a subagent sitting on its conversation's chosen root. It checks the parent's flag, read before the child is locked, or, if the parent is not in memory after a restart, a flag the child recorded when it was seeded from the parent's persisted pin. No path ever holds two shards' locks at once.
  • Refresh. A failed refresh now hands back its claimed slot. Tests pin one database write per refresh interval, and the legacy-heal branch of the identity-restore retry now also restores declarations.
  • Docs. The CHANGELOG and docs now describe the subagent behaviour, and the refresh slack of up to min(TTL/4, 1 h), precisely.
  • Mutation testing. Every round-2 mutant went red except the defensive root != prevRoot check in followParentShard. I believe that mutant is equivalent: a subagent that never chose a root always sits on its conversation's previous root. The code comment says the check is defensive.

Taken over (review of the merge with #533)

The original session went idle, so a second session took over the PR. Main is merged in three times, by merge commits with no rebase: #533 and #550, then #534 and #557, then #555. None of those merges conflicted.

  • B1: the connection-scope report could name the wrong workspace. fix(session_start): report which pin a re-pin moved (#517) #533's connScopeCallerRoot decided whether the caller follows the connection from the shard's own flags (selfPinned, restored). This PR's followConnectionShards also leaves a subagent on its conversation's chosen root (parentSeeded, or the parent's flag). So a subagent of a conversation working in a worktree could move the connection with scope: "connection" and be told its relative paths now went to the new root. They still went to the worktree. The fix is followsConnectionLocked, now the single predicate used by both the move and the report. It reads the parent's flag under shardsMu before the shard's lock is taken, so no path nests two shards' locks. The report now also does what the caller's next call would do:

    • A subagent with no shard yet is seeded from its conversation's root.
    • An inherited refused declaration reports "nothing", which is what workspaceFor resolves.

    A restored shard is still dragged off the connection's previous root, as on main. Whether it should be is Pins no agent chose are persisted (followConnectionShards) or replayed (agent-scope session_start) #527's question, and this PR leaves it unchanged. The move and the predicate now live in conn_agent_shard_follow.go, because the fix(session_start): report which pin a re-pin moved (#517) #533 merge had taken conn_agent_shard.go to 610 lines. With fix(roots): no attach after close; coalesce roots/list_changed (#514) #534, a move on a closed connection fails in attachOrRepinTo (errConnClosed) before the report runs.

  • N2: wording since fix(daemon): stop pruning session state at start-up #550. Nothing is pruned at daemon start any more, and the reaper spares connected serves. The code comments, the test comment, the CHANGELOG, architecture.md and threat-model.md therefore no longer say that a restart ages declarations out. A declaration is reclaimed only when a reaper pass finds the serve disconnected and the row older than the TTL. The refresh is kept. It covers a pass that catches the serve between connections. It costs one throttled UPDATE per linkage, and it skews nothing: no query windows by declared_linkage.updated_at. The "Known limit" in the Restart bullet above is superseded by this.

  • N3. Store.BackdateSession now ages declared_linkage. seedAgedSession and expendableRows seed and count a declaration, so the fix(daemon): stop pruning session state at start-up #550 start-up and reaper tests cover the table. The reaper test fails if the table is left out (mutant M7).

  • N5. The undeclared-identity refusal now also asks for workspace, because declaring alone can leave the agent on another agent's checkout. tools.md and the CHANGELOG say the same.

Tests

  • conn_repin_scope_subagent_test.go has three cases, a positive control, a persisted-parent case and a rendered check. Each case moves the connection from a subagent and asserts that the report's next root equals workspaceFor. The cases are: on the conversation's chosen root (the reviewer's repro), no shard yet, a refused declaration inherited from the conversation, and seeded from the conversation's persisted pin. A positive control covers a conversation that never chose a root, where the subagent follows. A rendered end-to-end check uses session_start itself. All of these were red before the fix: four mismatches plus the rendered line. The control was green.
  • Mutation. Nine hand mutants were all killed. They covered: the report using fix(session_start): report which pin a re-pin moved (#517) #533's predicate; the report ignoring the parent; the helper dropping parentSeeded or the parent's flag; no inherited-declaration check; no shard materialisation; dropping the restored exception; BackdateSession without declared_linkage; and the refusal without workspace. Two were also run through plumb's mutation_test over all of ./internal/cli/, and both were killed.
  • The following all pass (exit 0): go test ./... -count=1, go test -tags=integration ./internal/cli/, -race (-count=5) on the report, follow and subagent tests, golangci-lint run ./..., and make check-size check-brief check-changelog check-changelog-placement. They ran with GOWORK=off, GOTMPDIR=.testcache and a private XDG_DATA_HOME.

CI

The previous head's integration (macos-latest) failure was TestProjectWatchManager_PlumbDirSwapKeepsWatching. That test belongs to the TestProjectWatchManager_* family, which flakes on macOS runners independently of this PR. The same family failed in runs 36698756418 (main), 36797533087 (#534), 34302623228 and 34069246977. This PR touches no watcher code, and a separate fix for the family is in progress.

Out of scope here, still open: #527 parts 1 and 2.

Closes #513

🤖 Generated with Claude Code

golimpio pushed a commit that referenced this pull request Sep 30, 2026
…ion works

B1: a hook-stamped subagent <conv>/<x> is admitted on its conversation's
declaration, but shardFor seeded its shard from the CONNECTION pin, so a
subagent of a parent that had re-pinned itself to a worktree wrote into
another agent's checkout. The shard is now seeded from the parent's shard
when the parent chose or restored a root, or from the parent's persisted
pin after a restart, and inherits the parent's refused declaration until
it chooses a root of its own.

Also from the review:
- a connection whose identities all share one linkage (a main thread that
  never called session_start plus its own subagents) is admitted; another
  linkage breaks that and is refused;
- the after-tool declaration is exercised through the desktop harness with
  a _meta-only session_start that carries no session_id;
- an admitted state-changing call refreshes (UPDATE only) its declared
  linkage's row, and the restart limits are documented honestly;
- the refusal says when the session_id it names is the conversation's;
- session_start validates `detail` before it links, so a failing call
  declares nothing;
- a degraded identity recovery restores declarations when it converges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
golimpio pushed a commit that referenced this pull request Sep 30, 2026
…ion works

B1: a hook-stamped subagent <conv>/<x> is admitted on its conversation's
declaration, but shardFor seeded its shard from the CONNECTION pin, so a
subagent of a parent that had re-pinned itself to a worktree wrote into
another agent's checkout. The shard is now seeded from the parent's shard
when the parent chose or restored a root, or from the parent's persisted
pin after a restart, and inherits the parent's refused declaration until
it chooses a root of its own.

Also from the review:
- a connection whose identities all share one linkage (a main thread that
  never called session_start plus its own subagents) is admitted; another
  linkage breaks that and is refused;
- the after-tool declaration is exercised through the desktop harness with
  a _meta-only session_start that carries no session_id;
- an admitted state-changing call refreshes (UPDATE only) its declared
  linkage's row, and the restart limits are documented honestly;
- the refusal says when the session_id it names is the conversation's;
- session_start validates `detail` before it links, so a failing call
  declares nothing;
- a degraded identity recovery restores declarations when it converges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
golimpio pushed a commit that referenced this pull request Sep 30, 2026
…sation

A subagent's shard was seeded from its conversation only at creation. One
that had made any call while its conversation was still on the connection
seed (a background or continued subagent) stayed there when the
conversation then re-pinned itself, so its relative writes and git's
default repository stayed in another agent's checkout. repinAgent now
re-seeds, after the unlock, every <conv>/* shard still on the old root
that never chose or restored one (followParentShard). A connection-scope
move no longer drags a subagent sitting on its conversation's chosen root
(followConnectionShards skips a parent-seeded shard, and one whose parent
chose or restored its root; the parent's flag is read before the child is
locked).

Also: a failed declaration refresh releases its claimed slot; tests pin one
DB touch per refresh interval and the legacy-heal retry's restore; the
CHANGELOG and docs state the subagent behaviour and the refresh slack
precisely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@golimpio
golimpio force-pushed the atlas/fix-513-undeclared-identity branch from dfa557e to 62b1235 Compare September 30, 2026 21:25
atlas-from-plumb and others added 4 commits October 1, 2026 07:44
…dentity

On a connection shared by several logical agents, a state-changing call
carrying a per-call identity that no session_start had declared was
admitted, handed a fresh shard seeded from the connection's root, and its
relative write landed in the connection's workspace (#513).

The gate now also refuses, on a shared connection counting the caller, an
identity whose linkage (the conversation half) was never declared through a
successful session_start: its session_id, or the per-call identity it ran
under. A hook-stamped <conv>/<agent> rides its conversation's declaration.
Declarations persist under the proxy session (sessionstate v9,
declared_linkage) and are restored on reconnect, together with the identity
record's own linkage, so a restart does not refuse an agent that declared.

Closes #513

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion works

B1: a hook-stamped subagent <conv>/<x> is admitted on its conversation's
declaration, but shardFor seeded its shard from the CONNECTION pin, so a
subagent of a parent that had re-pinned itself to a worktree wrote into
another agent's checkout. The shard is now seeded from the parent's shard
when the parent chose or restored a root, or from the parent's persisted
pin after a restart, and inherits the parent's refused declaration until
it chooses a root of its own.

Also from the review:
- a connection whose identities all share one linkage (a main thread that
  never called session_start plus its own subagents) is admitted; another
  linkage breaks that and is refused;
- the after-tool declaration is exercised through the desktop harness with
  a _meta-only session_start that carries no session_id;
- an admitted state-changing call refreshes (UPDATE only) its declared
  linkage's row, and the restart limits are documented honestly;
- the refusal says when the session_id it names is the conversation's;
- session_start validates `detail` before it links, so a failing call
  declares nothing;
- a degraded identity recovery restores declarations when it converges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sation

A subagent's shard was seeded from its conversation only at creation. One
that had made any call while its conversation was still on the connection
seed (a background or continued subagent) stayed there when the
conversation then re-pinned itself, so its relative writes and git's
default repository stayed in another agent's checkout. repinAgent now
re-seeds, after the unlock, every <conv>/* shard still on the old root
that never chose or restored one (followParentShard). A connection-scope
move no longer drags a subagent sitting on its conversation's chosen root
(followConnectionShards skips a parent-seeded shard, and one whose parent
chose or restored its root; the parent's flag is read before the child is
locked).

Also: a failed declaration refresh releases its claimed slot; tests pin one
DB touch per refresh interval and the legacy-heal retry's restore; the
CHANGELOG and docs state the subagent behaviour and the refresh slack
precisely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… move

Round-2 confirmation of #535: the `sh.root != prevRoot` check in
followParentShard was not equivalent, as had been claimed. A forced
connection move racing the parent's own move could drag the subagent off
prevRoot after followConnectionShards read parentChose=false. The check
then skipped it, stranding the subagent on the connection's new root
(possibly another agent's workspace) while its parent sat elsewhere. The
reviewer reproduced this 3 of 10 times with the window widened; with the
check dropped, 10 of 10 converged and the integration suite passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@golimpio
golimpio force-pushed the atlas/fix-513-undeclared-identity branch from 62b1235 to b15efd2 Compare September 30, 2026 22:11
atlas-from-plumb and others added 5 commits October 1, 2026 10:10
 review)

Merged with #533, session_start's report of a connection-scoped move
could name a workspace the caller does not use. The report decided
"follows the connection" from the shard's own flags, while
followConnectionShards also leaves a subagent on its conversation's
chosen root. A subagent of a conversation working in a worktree that
moved the connection was told its relative paths now went to the new
root; they still went to the worktree.

followsConnectionLocked is now the one predicate, used by the move and
by the report, with the parent's flag read under shardsMu before the
shard is locked. The report also does what the caller's next call does:
a subagent with no shard yet is seeded from its conversation's root,
and an inherited refused declaration reports nothing, as workspaceFor
resolves. A restored shard is still dragged off the connection's
previous root as before; whether it should be is #527's question.

The merge with #533 had taken conn_agent_shard.go to 610 lines, so the
move and the predicate now live in conn_agent_shard_follow.go.

Also from the review:
- N2: since #550 nothing is pruned at daemon start and the reaper spares
  connected serves, so the code, test and docs no longer say a restart
  ages declarations out. The refresh stays: it covers a reaper pass that
  finds the serve between connections, costs one throttled UPDATE, and
  touches no evidence that anything windows by.
- N3: BackdateSession now ages declared_linkage, and seedAgedSession
  seeds one, so the start-up and reaper retention tests cover it.
- N5: the undeclared-identity refusal also asks for workspace, since
  declaring alone can leave the agent on another agent's checkout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: gentle-cobra
Brings in #534 (roots handler lane) and #557 (shared-daemon friction).
No conflicts. A closed connection now fails attachOrRepinTo with
errConnClosed, so a connection-scoped move on one returns before
connScopeCallerRoot runs and materialises no shard.

Plumb-Session: gentle-cobra
Brings in #555 (per-agent task config). No conflicts.

Plumb-Session: golden-moose
…t settle

The confirming review of #535 found two claims stronger than the code.
"The report cannot disagree with the move" holds for WHICH shards follow,
not for every outcome: on a connection's first pin the move has no
previous root to drag from, so a fresh shard stays at "" while the report
names the new root (filed as #567). "The one window left" omitted a
second, report-only window: the conversation self-pinning between the
parentChose read and the shard read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: crisp-eagle

@golimpio golimpio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken over to merge at the owner's request. Review rounds: the author's own two, my round with one blocker (B1, a report/reality mismatch), the takeover fix, and a confirming round with no blockers that included lock-order race stress and mutants. Comment and doc overclaims were softened; the first-pin case is filed as #567.

@atlas-from-plumb
atlas-from-plumb merged commit ab0ab4f into main Oct 1, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shared connection admits a write under a never-declared per-call identity (fresh shard on the connection root)

2 participants