Skip to content

Design: Claude cloud sessions, and contract v2 - #603

Open
cheapsteak wants to merge 14 commits into
mainfrom
claude/tbd-cloud-sessions-ji6w7p
Open

Design: Claude cloud sessions, and contract v2#603
cheapsteak wants to merge 14 commits into
mainfrom
claude/tbd-cloud-sessions-ji6w7p

Conversation

@cheapsteak

Copy link
Copy Markdown
Owner

Docs only. No Swift, no behavior change — see the handoff comment below for why, and for what a session on a Mac needs to pick up.

Lets TBD create, watch, steer, archive, and land Claude cloud sessions — Claude Code sessions running on Anthropic's hosted infrastructure, reachable today only from claude.ai, the mobile and Desktop apps, and claude --cloud in a terminal. It rides the existing remote agent backend contract rather than introducing a parallel concept, so cloud sessions become remote sessions like any other: same mirror table, same sidebar, same health and auth machinery.

The interesting part is that this is TBD's second provider, and a second implementation exposed places where a contract written against one implementation is unsound.

What's here

  • docs/specs/2026-08-07-claude-cloud-sessions-design.md — the design. Four pieces shipping together: contract v2, a provider compiled into the daemon, a land bridge from remote session to local worktree, and per-repo declaration of which remotes a repository runs on.
  • docs/remote-provider-contract.md — revised to v2. States v2 as it stands, with no change notes, per the repo's no-revision-history rule.
  • docs/remote-provider-contract-changes.md — migration guide for provider authors. Leads with the point that matters: a v1 provider is already a valid v2 provider, and the whole adoption step is declaring contract_versions: [1, 2].

One live bug this fixes, independent of the feature

v1's drift rule retires a session after two consecutive successful snapshots omit it. RemoteSessionStore.applySnapshot increments missingCount for every row it didn't see, with no notion of whether the provider could see everything. That's correct only for a provider that enumerates its own inventory, and wrong for one that can enumerate only part of it — it tombstones live sessions. v2 adds complete to the snapshot envelope; incomplete snapshots may adopt and update but never retire, and never count as refreshing freshness (which would have re-opened the mutation gate 2026-08-01-remote-stale-snapshot closed).

Other contract v2 changes

  • archived becomes a third Session axis, orthogonal to liveness and attention, with archive/unarchive capabilities. Archived sessions must stay in list — filtering them makes them look absent to the drift rule and denies the caller the inventory a revive flow browses. This gives remote sessions the lifecycle vocabulary local worktrees already have, including both revive modes.
  • stop becomes a capability and narrows to "terminate." v1 fused terminating compute with retiring from the inventory; one provider could do both in one call, so nothing forced them apart. A platform that reclaims idle sessions with no client-facing kill can retire but not terminate.
  • transcript, distinct from log — agent transcript JSONL on stdout, continuation cursor as a JSON envelope on stderr. That split is the single exception to "stderr is diagnostic only" and exists so a truncated data stream stays recognizable as truncated.
  • land, with the validation a caller must perform on provider-supplied branch and remote_url before either reaches git.

Security note worth reading

Part 4 defines a committed, repo-authored file (.tbd-remotes.json) that TBD parses. An earlier draft called it inert data. It is not: repo-declared params are matched against the provider's create_params, which include prompt — so an unreviewed declaration could set the opening instruction of an agent holding repo write access, network egress, and the user's credential, and a single declared remote silently became the default location.

Declarations now take effect only after a trust-on-first-use gate that displays every param value verbatim, with prompt shown in full. Approval is stored as the SHA-256 of the canonicalized declaration, so any edit re-prompts — approving once never blanket-approves what lands on the branch next month. The authoritative copy is the registered root checkout's, not the selected worktree's, so a branch under review can't redefine where sessions run merely by being selected.

Flags

Nothing ships enabled. claude_cloud_enabled is a new default-off config column, and it's a second gate inside remoteGate() rather than a bypass — cloud requires both flags. It stays separate from remote_backends_enabled because that flag was written to be deletable after soak, on the reasoning that the feature is inert without a registered provider file; a provider compiled into the daemon is never inert, so folding them would silently convert a disposable flag into a permanent one.

Brainstorming

Ran per the repo convention, with a human answering. The spec was then reviewed by a fresh-context agent that verified its codebase claims against source and found three design faults — the prompt-injection channel above, an attach shim whose exit-code translation had no lawful input, and a ledger union that would have made "TBD launched it once" mean "it exists permanently." All three are fixed in 8b4e89b.

🤖 Generated with Claude Code

https://claude.ai/code/session_015xU92u65ynrwqnugtegCab


Generated by Claude Code

cheapsteak and others added 5 commits August 7, 2026 13:57
Adds the design spec for creating, watching, steering, and landing Claude
Code sessions that run on Anthropic's hosted infrastructure.

Four changes ship together:

- Contract v2: snapshots declare whether they are complete (the v1 `gone`
  rule tombstones live sessions when a provider can only enumerate part of
  its inventory); a `transcript` capability distinct from `log`, for
  providers whose conversation is structured messages rather than
  scrollback; a `land` capability; and `stop` demoted from required to
  declared, since a provider whose sessions are platform-reclaimed cannot
  implement it.
- A `claude-cloud` provider compiled into the daemon as a second
  `RemoteProviderInvoking` conformance, with a ledger of locally launched
  sessions unioned with discovery.
- A land bridge turning a remote session into a local worktree, showing a
  fork as a fork.
- Repo-declared remotes in a committed, inert `.tbd-remotes.json`, with
  this repository's own declaration as the worked example.

Gated behind a new default-off `claude_cloud_enabled` config column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab
Attach is the one verb a daemon-compiled provider cannot serve in-process:
the app spawns it on the pane's PTY and its exit code never reaches the
daemon's runner, so a built-in provider has no executable to hand over.

Handing the app a bare vendor argv is the alternative the contract rejects,
and the reason that survives for a built-in provider is exit codes.
RemoteAttachExitClass reads the attach process's exit through
ProviderFailureClass, which the vendor CLI does not speak — an account
ineligible for interactive attach would classify as transient and retry
forever against a permanent condition.

The attach argv becomes a new `tbd remote-attach` subcommand that execs the
vendor CLI on the inherited PTY and translates its outcome into contract
exit classes. TBDCLI is already installed, so this adds no install step and
keeps the contract's live-shim property.

Also records that attach and transcript are two windows onto one
server-stored conversation rather than a fork, unlike landing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab
A fresh-context review verified the spec's codebase claims and found several
that source contradicts, plus three problems that were design faults rather
than wording.

Security. The repo declaration was described as inert data that cannot cause
execution. It is not: repo-declared params are matched against the provider's
create_params, which include `prompt`, so an unreviewed declaration could set
the opening instruction of an agent holding repo write access and the user's
credential — and a single declared remote silently became the default
location. Declarations now take effect only after a trust-on-first-use gate
that shows every param value verbatim and stores the approved content hash, so
any edit re-prompts. The authoritative copy is the registered root checkout's,
not the selected worktree's.

Attach. The exit-code-translating shim had no lawful input: the signal
distinguishing an ineligible account from a dropped transport is terminal
output, which both the no-TUI-scraping rule and the contract forbid parsing.
The shim is gone; the vendor CLI is spawned directly, eligibility becomes a
cached preflight, and RemoteAttachExitClass gains a `permanent` case, since
today `.permanent` collapses into `.unexpected` and arms auto-reconnect.

Ledger. A ledger row omitted by a complete snapshot is now retired rather than
re-asserted forever, ledger-only rows report `state: unknown` instead of
fabricating liveness, and `complete: false` no longer stamps freshness — which
would have re-opened the mutation gate that 2026-08-01-remote-stale-snapshot
closed.

Also: idempotency rewritten against the as-built handler, which mints a fresh
key per call and retries once; the reserved name skips its entry instead of
throwing the whole registry file; land validates provider-supplied branch and
remote_url before they reach git; remote transcripts get a TBD-owned root
rather than colliding with the Claude projects path guard; flag composition
stated explicitly; and v2 contract negotiation noted as machinery to build.

Adds archive as a third Session axis with archive/unarchive capabilities,
giving remote sessions the lifecycle vocabulary local worktrees already have,
including both revive modes. Records the single-account assumption that puts
concurrent drivers out of scope.

Corrects the claim that Package.swift's platforms declaration blocks a Linux
build; the real blockers are AppKit/SwiftUI, Security, and os.Logger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab
Adds a guide for people maintaining remote agent provider implementations,
covering what contract v2 changes and what they need to do about it.

It leads with the point that matters most: a v1 provider is already a valid v2
provider with no code changes, because v2 adds no required verbs and makes one
less required. The whole adoption step is declaring contract_versions [1, 2].
Every new capability is opt-in, and each section states what happens if it is
ignored.

Covers `complete` on list and snapshot envelopes, `archived` on the Session
object, the `archive`/`unarchive`, `transcript` and `land` capabilities, `stop`
becoming optional, and version negotiation. Ends with an ordered checklist that
puts correctness ahead of features.

Notes one constraint that follows from v1 requiring `stop`: a provider that
drops it entirely can no longer claim v1, so it declares [2] rather than [1, 2].

The normative contract revision follows in a separate commit; this guide exists
so the normative text can state v2 as it stands without carrying change notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab
Makes the normative contract state v2 as it stands, with no change notes —
the migration guide added alongside it is where a v1 implementer learns what
is different.

Three verbs are required rather than four: `stop` is capability-gated, and its
meaning narrows to ending the running compute. Retiring a session from the
inventory is the new `archive`/`unarchive` pair, replacing v1's rule that
archival was state the caller kept on its own side. A platform that reclaims
idle sessions with no client-facing kill must not declare `stop`, and a caller
must not offer an undeclared capability.

Snapshots carry `complete`. An incomplete snapshot may adopt and update, but
must not retire anything and must not count as refreshing freshness — the
drift rule now reads "two consecutive successful complete snapshots", and
presence in an incomplete snapshot still resets accrued absences, since a
positive observation is real evidence whatever else the provider could not see.
Completeness is with respect to the full set including archived sessions, so an
active-only enumerator reports false.

Sessions carry `archived` as a third axis, orthogonal to liveness and
attention, and archived sessions must stay in `list` — filtering them makes
them look absent to the drift rule and denies the caller the inventory a revive
flow browses.

Adds `transcript`, returning agent transcript JSONL on stdout with its
continuation cursor as a JSON envelope on stderr; that split is the single
exception to "stderr is diagnostic only" and exists so a truncated data stream
stays recognizable as truncated. Adds `land`, with the validation a caller must
perform on `branch` and `remote_url` before either reaches git.

Records the conformance asymmetry around dropping `stop`: declaring a major
means conforming to it, so a provider that removes the verb declares [2] alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab

Copy link
Copy Markdown
Owner Author

Handoff — for a session running on a Mac

This branch was driven from a Claude Code cloud session, which is Linux. No Swift was written, deliberately. scripts/swift-safe build and scripts/test.sh cannot run there — TBDApp is SwiftUI/AppKit, TBDDaemon reaches Security, and every target imports os — so anything compiled-looking would have been unverified by construction, against the repo's own rule. The design and both contract documents are done and reviewed; the implementation is not started.

The implementation plan lived in docs/plans/, which is gitignored, so it doesn't travel. It's reproduced below — that's the point of this comment.

Two prerequisites

P1 — a machine that can build. Everything below needs a real build and test run.

P2 — capture the undocumented endpoints. Phases 7 and 8 call claude.ai endpoints nobody has written down. Someone with the account has to observe what the official clients do and record, for discovery, transcript fetch, and archive/unarchive: method, path, auth header shape, request and response bodies, pagination, and how the session status field enumerates. Phases 1–6 don't need this and are most of the feature.

One assumption was granted rather than tested: that claude --cloud <id> writes an ordinary transcript JSONL locally. It's plausible — --no-session-persistence is documented as working only with --print, implying interactive sessions always persist — but unverified. Phase 8.3 carries a test that fails loudly if it's wrong, rather than degrading silently. Test it early: attach to a cloud session and look for a new JSONL under ~/.claude/projects/. If it doesn't appear, the transcript verb becomes the only path and Phase 8 gets bigger.

Phases

Test-first throughout; each task is a standalone commit that builds and passes. 1 gates everything; 2 gates 4–7; 3, 5 and 6 are independent of each other.

1 — Contract v2 plumbing. Negotiated version becomes provider state: ProviderRunner.run hardcodes TBD_CONTRACT_VERSION=1, describeProvider hard-requires 1, and RemoteProviderInvoking.run(_:verb:stdin:timeout:) has no parameter to carry one — so this is a protocol change, not a free ride. Then archived and complete on the TBDShared types; completeness gating the drift rule in RemoteSessionStore.applySnapshot; completeness gating freshness in RemoteProviderManager.apply (it currently calls markHealthy and stamps lastSuccessfulSnapshotAt unconditionally); events parity in RemoteEventParser.

2 — Migrations, each following the shared-model rule in one commit: claude_cloud_session (the ledger, with idempotency key state), config.claude_cloud_enabled default false, repo.remotes_declaration_trusted_sha nullable.

3 — Attach. RemoteAttachExitClass folds .permanent, .contractBug and .transient into .unexpected, which arms auto-reconnect — split permanent out and teach RemoteReconnectPolicy not to arm on it. Add a cached, fail-open eligibility preflight. Add stop capability gating to RemoteSessionActionMenu (attach is gated, stop isn't).

4 — Land bridge. Validation as a pure function first: ref-name pattern on branch, reject a leading -, compare remote_url rather than pass it to git (the ext:: family executes commands). Then remote.land, preconditions checked before anything is created. git worktree add <path> <branch> refuses a branch already checked out, so a second landing derives <branch>-2 from the same commit. Provenance recorded both ways; neither side retired.

5 — Repo-declared remotes. Parse .tbd-remotes.json from the registered root checkout, not the selected worktree. Keep only provider, label, params; drop params absent from create_params. Trust gate on the canonicalized SHA-256. Resolution order: explicit choice, approved declaration, global default, local.

6 — Built-in provider, documented half. Dispatcher routing RemoteProviderInvoking by name. Reserved-name handling — note RemoteProviderRegistry.load currently throws for the whole file on a duplicate and both callers swallow it, one with try?, so one bad entry silently removes every provider; skip the entry and surface it instead. Then describe/create/send/attach shelling out to claude behind an injected process seam, ledger writes, and ledger-only listing.

7 — Built-in provider, undocumented half. (needs P2) Client behind a protocol so no test hits the network; discovery; the three union rules; archive/unarchive; the preflight backing 3.2.

8 — Transcript. (8.1–8.3 don't need P2) TBDConstants helper for ~/tbd/remote-transcripts/<provider>/<sessionID>/ honoring TBD_HOME; widen the transcript RPC path guard to admit that root as a second permitted root, never an unguarded path; point attach at it so a cloud conversation doesn't surface as a local session via ClaudeSessionScanner; then the verb itself; then suppress local file linking for remote rows.

Judgment calls a human may want to revisit

Three things were decided without explicit sign-off and are cheap to change:

  • Presence in an incomplete snapshot resets accrued absences. The spec says only that an incomplete snapshot must not increment missingCount. Freezing the count entirely would tombstone a session observed moments earlier, so presence is treated as real evidence. One bullet in Identity & drift if you disagree.
  • New verb timeouts — 30s, 60s for transcript — follow the existing shape of session-mutating versus bulk verbs. The spec states none.
  • Re-prompting on any declaration edit. Keyed to the content hash, so a legitimate edit costs a re-approval. That's what makes the trust gate actually hold, but it's the chattiest reading.

Conventions this branch is already following

Verify with scripts/swift-safe build before each commit and scripts/test.sh for anything touching daemon or shared code. Both flags need both branches tested. New delays take an injected clock. Plans stay in docs/plans/ and are never committed.


Generated by Claude Code

@cheapsteak
cheapsteak marked this pull request as ready for review August 7, 2026 15:00
@tbd-claude-reviewer

This comment has been minimized.

…window

Addresses the review gate's findings on the design spec.

The spec claimed both RPCs reaching TranscriptParser.parse enforce that the
path lives under the Claude projects store. Only handleSessionMessages does.
handleTerminalTranscript reads a terminal row's transcriptPath verbatim, and
the only validation that field ever receives is an absoluteness check at write
time in the sessionEvent handler. The guarded call site's own comment asserts
it shares a trust boundary with the unguarded one, which is how the asymmetry
stays invisible — and is where the spec's claim came from.

This matters because the plan said the guard would be "widened" to admit a
second root for remote transcripts, which presumed a guard uniformly present
to widen. handleTerminalTranscript gains a boundary check it does not have
today, and the spec now states that closing the asymmetry is a prerequisite
rather than a side effect: a second permitted root is only safe where a
boundary is actually checked.

Also names the pending-create resolution window at ten minutes with the
trade-off stated, since every other timing constant in the document is named
precisely and an unnamed compiled duration is a decision nobody visibly makes.
Too short strands a slow-provisioning session outside the inventory; too long
leaves a failed create pending with nothing to act on.

Specifies the ref-name grammar for `land`'s branch validation in the normative
contract, so the protection third-party providers must conform to is reviewable
from the documents rather than left to each caller's judgement. Corrects a
miscount of RemoteProviderRegistry.load's call sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab

Copy link
Copy Markdown
Owner Author

All four findings addressed in 7419713. The HIGH one was correct and points at something in shipped code that outlives this PR.

HIGH — transcript guard. Verified against source; the spec was wrong. Only handleSessionMessages constrains the path to the Claude projects store. handleTerminalTranscript reads a terminal row's transcriptPath verbatim, and the only validation that field ever gets is an absoluteness check at write time in the sessionEvent handler.

The root cause is worth recording: the guarded call site's own comment says it enforces "the same trust boundary as handleTerminalTranscript." That claim is false, and it's where the spec's claim came from. A comment asserting a symmetry that doesn't exist is how the gap stayed invisible.

The spec now says handleTerminalTranscript gains a boundary check rather than having one widened, and that closing the asymmetry is a prerequisite of this work rather than a side effect — a second permitted root is only safe where a boundary is actually checked.

MEDIUM — pending-create window. Named at ten minutes, with the trade-off stated rather than just the number: too short strands a slow-provisioning session outside the inventory, since capacity is allocated on demand and a setup script runs before the session is usable; too long leaves a genuinely failed create sitting as pending with nothing to act on. Written as a threshold to revise on evidence, not a constant to defend. The test bullet now also specifies it runs on an injected clock rather than elapsed wall time.

Minor — ref-name grammar. Fair, and the reason given is the right one: third-party providers have to conform to it, so leaving it unspecified made the protection unreviewable from the documents. The normative contract now spells out the grammar under land — ASCII letters, digits, ., _, -, /, with the usual git ref exclusions — as a strict subset of git check-ref-format --branch, so a provider can predict acceptance without matching one git version's behavior. The migration guide points at it instead of restating it.

Minor — call-site miscount. Fixed: three call sites, two swallowing with try?.


One thing this surfaces that is out of scope here and needs a decision separately. handleTerminalTranscript's missing boundary check exists today, independent of this feature. transcriptPath is written from a sessionEvent RPC and only checked for absoluteness, so a crafted event can point a terminal row at an arbitrary absolute file, which that RPC will then read and return parsed to the app. It is read-only and needs the ability to send a sessionEvent, so it isn't dramatic — but it is the same shape as the pane-identity forging fixed in 9ba2f3d, and the misleading comment means nobody would find it by reading.

I haven't touched it: this branch is docs-only, and the session that produced it runs on Linux where TBD cannot be compiled or tested, so any fix would be unverified. Flagging it for a decision — fold it into the implementation work as Phase 8's prerequisite, or take it separately as a fix that stands on its own merits.


Generated by Claude Code

@tbd-claude-reviewer

This comment has been minimized.

The ten-minute timeout is a compiled numeric threshold gating a failure
judgment, argued for without running theory-placement's battery — while the
section two paragraphs above cites that battery explicitly for the decision to
compile the provider itself.

Running it: the tunable-number test does flag the shape, but the
two-reasonable-projects test is what separates this from the supervision
thresholds it resembles. "When is an agent stuck" is contested because one
repository's forty idle minutes is another's normal test run; how long a vendor
takes to provision a machine is a property of that vendor's infrastructure, not
of anyone's working style. The named-consumer test agrees — no repository wants
a different provisioning-latency assumption, and a real change in latency should
change the value for everyone rather than produce a knob. It stays compiled as a
fact TBD asserts about a vendor.

That argument does not buy being right, so the constant now ships with the floor
its failure mode requires: a session appearing in a later complete snapshot that
matches a pending row already cleared as failed is adopted rather than ignored,
and the adoption is logged. A window set too short costs a delayed sidebar row
instead of a lost session, and the log is the evidence that would justify
changing the number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab
@tbd-claude-reviewer

This comment has been minimized.

The pending-create row said a timed-out create is marked failed "and cleared",
while the paragraph after it matched a late-arriving session against a row
already cleared — two incompatible mechanisms. The row now transitions to
failed and stops asking for attention immediately, but is retained for 24 hours
before deletion, which is what gives the late-adoption floor something to match.

The built-in provider implements no `stop`, and major 1 requires it, so its
describe reports contract_versions [2] rather than the [1, 2] idiom every other
example in these documents shows. That is the one place copying the common form
would be wrong, and it was never stated.

Defines the `environment` create param, which was used in the declaration
example without ever being specified: it names a cloud environment configured
on the account, typed string rather than enum because describe answers offline
and the set is only knowable from the account, with absent meaning the account
default and an unrecognized name being the provider's error to report.

Gives the resolution order the rationale the document's other contestable
decisions get. A repository's declaration outranking the user's global default
is not defensible on its own — what settles it is the trust gate, since a
declaration only resolves after the user read and approved it, making tier 2 an
accepted suggestion rather than a stranger's file. The ordering and the gate are
one mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab

Copy link
Copy Markdown
Owner Author

claude-review is red on 2bce6e6 for an infrastructure reason, not a verdict. Recording it because it has now happened twice on the same commit and it is not fixable from this branch.

Both failures are validate.py failing closed because the orchestrator finished without all specialists having written their findings files.

First attempt — neither specialist reported:

error: no specialist findings files match 'findings-*.json'
error: expected specialist(s) produced no findings file: correctness, conventions
       — that review lens never ran (orchestrator may have merged before all
       specialists completed); failing closed

After re-running failed jobs, one of the two got through:

error: expected specialist(s) produced no findings file: correctness
       — that review lens never ran ...
ok: findings-conventions.json (0 finding(s))

Two things follow. The conventions lens returned zero findings on this head, so the four items from the previous round are resolved as far as that specialist is concerned; only correctness has yet to report. And the partial improvement between attempts — nothing, then one of two — is consistent with the race the error message itself names, rather than a deterministic failure.

Worth noting the same diagnostics block in all three preceding reviews reported the orchestrator's ScheduleWakeup call failing with a missing prompt parameter and falling back to a blocking Bash wait for the specialists' output files. A wait mechanism already running on its fallback path is a plausible home for an orchestrator that stops waiting early.

Why this can't be fixed here. The job does rm -rf .github/workflows/claude-review-v2 and git checkout $BASE_SHA -- .github/workflows/claude-review-v2 before validation, so the review scripts always come from the base branch. A change pushed to this branch would not be used by this PR's own review, which makes fixing it here both out of scope and ineffective for unblocking this PR.

I have re-run the failed jobs a second time. If it goes red again for the same reason, this needs a human — either an orchestrator fix on main or an admin merge — and I will not keep re-running past that.


Generated by Claude Code

@tbd-claude-reviewer

This comment has been minimized.

The transcript boundary was described per-RPC and named two handlers. There
are three reads of a terminal row's transcriptPath — parse, parseTail, and
lookupDetail serving item-full-body requests — and enumerating them is what
produced the current state: the guard was written for one call site and the
others were added without it. The boundary now lands at a single resolver every
read passes through, so a future entry point cannot start unguarded by default.

The completeness rule conflated two questions and would have created a trap.
apply(snapshot:) stamps lastSuccessfulSnapshotAt and calls markHealthy together;
withholding both on an incomplete snapshot means a provider whose steady state
is complete:false never recovers from one transport failure — health stays
degraded, hasStaleSnapshot stays true, and Create and Send are blocked
permanently, the opposite of the degradation this design claims. Health asks
whether the provider is reachable, which an answered call establishes whatever
it could enumerate; freshness asks when TBD last held a full inventory, which a
partial view does not. Incomplete snapshots now clear health and never advance
freshness.

Specifies how the contract's keystroke-oriented send byte stream becomes a
message for a session with no terminal: a single trailing carriage return or
newline is stripped as the submit gesture, interior newlines are preserved, and
the caller's side of the wire is unchanged.

Hashes the declaration's exact bytes rather than a canonicalized form. Any
normalization that reordered the remotes array would let a committer change
which remote is offered first — the default — without moving the hash or
re-prompting, defeating the gate at the decision it exists to guard.

Names the soft edge in the pending-create argument: a repository-authored setup
script drives provisioning latency, so a setup-script-heavy repository is a real
named consumer of a longer window. The floor answers it rather than a knob —
that repository gets a row that arrives late and is then correct, bounding the
cost of a wrong constant to latency rather than correctness. Also justifies the
24-hour retention against the horizon the contract already asks providers for.

Adds the missing test bullets for double-click create protection and send
terminator handling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab
@tbd-claude-reviewer

This comment has been minimized.

…t gaps

The ledger retired a row after a single complete snapshot omitted it, while the
mirror's gone rule and the normative contract both require two consecutive
absences for the stated reason that transports flake and a snapshot can call
itself complete having transiently missed something. Retiring on one absence is
worse in the ledger than in the mirror: the ledger is what keeps TBD-launched
sessions visible when discovery is unavailable, so a row dropped on a flake
takes that fallback with it. Now two consecutive complete snapshots, with
pending rows exempt — they carry no session id to match and are governed by the
ten-minute window alone.

TBD_CONTRACT_VERSION was documented as carrying the negotiated major, but
describe is the call that produces the negotiation, so no negotiated value
exists when it runs. describe now carries the caller's own highest supported
major, and a provider must not vary its response based on it: describe answers
statically and reports every major it supports, so a caller ahead of the
provider still negotiates down instead of being told only what it asked about.

land's forks field defined only its true branch. false now has stated caller
behavior — one conversation, no divergence presented, and no second landing,
since two local copies of a non-forking session would be two writers on one
conversation. A provider that cannot guarantee continuity must report true;
over-reporting false silently loses work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab
@tbd-claude-reviewer

This comment has been minimized.

The plan named ProviderRunner as the sole place hardcoding
TBD_CONTRACT_VERSION=1. There are three. ProviderEventsSupervisor spawns the
events stream outside the runner, and RemoteAttachTerminalView is in TBDApp —
it spawns attach directly on the pane's PTY and never passes through
RemoteProviderInvoking at all.

That third site is what makes this more than a parameter change. Negotiated
state held in the daemon's RemoteProviderManager is invisible to the app, so
built as described, attach would announce major 1 forever while every other
verb for the same provider had negotiated 2 — the divergence the comment above
that call site already warns against. The negotiated major now reaches all
three: a parameter on the invocation protocol, the same value threaded to the
events supervisor, and a field on RemoteProviderStatus, which the app already
receives over remote.providers and already has in hand where it builds the
attach environment.

Part 4's only degrade path covered declarations naming an unregistered
provider, which a compiled-in provider is not. A declaration naming a built-in
provider whose flag is off now degrades identically, and its trust prompt is
never shown — approving a declaration for a feature that cannot run would
display a prompt value for nothing and record a decision the user never got to
act on.

Corrects the ten-minute check to the date seam: it compares a persisted
creation timestamp against now, which is data, not a Duration. Drops a
misattributed projectsBase citation in favour of stating the property that
actually holds — a root TBD owns is not one the session scanner searches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab
@tbd-claude-reviewer

This comment has been minimized.

The ledger bullet still said a complete snapshot omitting a row retires it,
from before the rule became two consecutive absences — an implementer working
from the testing section alone would have built exactly the single-absence
retirement the rule exists to prevent.

Auditing for the same drift found more of it, since several rules acquired
prose without acquiring a test:

- version negotiation had no bullet at all, including the cross-process case
  that is the whole reason it is hard: the app-side attach environment must
  emit the same negotiated major as the daemon's runner and events supervisor,
  and it is the one that would silently keep announcing 1.
- the transcript boundary had no bullet, so the choke point that replaced
  per-call-site guards was untested — it now asserts the refusal holds for
  parse, parseTail and lookupDetail alike, which is the property that stops a
  later entry point from starting unguarded.
- land tested only the forks:true path; forks:false forbids a second landing.
- declarations did not test that a reorder-only edit re-prompts, which is the
  attack raw-byte hashing exists to stop, nor that a flag-disabled built-in
  provider degrades without showing an approval prompt.
- 24-hour retention was relied on by the late-adoption test without being
  asserted anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab
@tbd-claude-reviewer

This comment has been minimized.

They are not. This PR gives describe a new pre-negotiation behavior — it
receives the caller's own highest supported major rather than v1's hardcoded 1
— and the guide opened by telling implementers the opposite. Telling a v1
implementer exactly this class of thing is the document's entire purpose.

A second passage in the same section said the negotiated major rides on "every
subsequent invocation" without ever saying what describe itself sees, which
reads as an ellipsis rather than the gap it was.

Both now state the describe-time case explicitly, along with the MUST-NOT-vary
rule that bounds it: a caller supporting a newer major than the provider still
needs the provider's full contract_versions list to negotiate down, so
tailoring the describe response to what the caller announced is how a provider
ends up unusable by the caller it was accommodating. The branching advice at
the end of the section now names describe as the place where reading the
variable is actively wrong rather than merely unnecessary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab
@tbd-claude-reviewer

Copy link
Copy Markdown

🧌 Changes requested

Docs-only PR (contract v2 bump, migration guide, and the Claude cloud-sessions design spec). One MEDIUM finding survived merge; no HIGH findings, no convention violations.

MEDIUM — docs/specs/2026-08-07-claude-cloud-sessions-design.md:189 — the 24-hour ledger-row retention window is justified by appeal to the contract's clause that providers keep exited sessions listable for ≥24h (a SHOULD, not a MUSTdocs/remote-provider-contract.md:217). But the failure mode the window exists to catch, per the design's own text, is a slow-provisioning session that is still starting/running when it finally appears, not an exited one — the borrowed clause doesn't bound that case. If such a session surfaces past the 24h mark, the ledger row will already be deleted, contradicting the design's own goal (line 197) of still adopting late arrivals. Practically 24h is likely generous enough to rarely matter, so this reads as a stated-rationale gap rather than a demonstrated bug — but the justification as written doesn't support the number, and a future similar case reasoning from this precedent could pick a wrong one.

Both specialists independently re-verified the two items flagged in earlier review rounds (the transcript-guard HIGH and the ten-minute pending-create-window MEDIUM) against the current head content and source code; both hold up as addressed. No invalid/persnickety findings were filtered out during merge (0 dropped).

Finding dispositions
  • correctness-1kept. Re-verified independently by the orchestrator against the cited doc lines; reasoning gap confirmed as described.
Review diagnostics

No tool calls failed or were denied, by either specialist or the orchestrator. Both specialists completed and wrote valid findings files without incident.

Posted by the claude-review check — the review of this PR's diff at patch-id 3cd0d766bfb73775d6329f82176162b2257207eb. A newer review comment supersedes this one.

The number was justified by appeal to the contract's clause about keeping
exited sessions listable, but the case the window serves is a session that
finally appears alive, not one that has exited — the borrowed clause does not
bound it. The number is probably fine; the reasoning was not, and a
justification like that is what a later decision copies.

Sized instead to the gap it has to span, which is not provisioning latency —
that runs in minutes, which is why ten minutes suffices for the failure
judgement — but discovery being unavailable, the condition the ledger exists
for. A day covers a full working day of outage, and past that horizon a cloud
session idle that long has had its environment reclaimed, so there is usually
nothing left to adopt.

Also records why a generous round number is adequate here rather than one worth
deriving precisely: a session surfacing after the row is deleted is not lost.
Discovery returns it and TBD adopts it as a session it did not create, which
the contract already requires. Only the provenance link back to the create is
lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xU92u65ynrwqnugtegCab

Copy link
Copy Markdown
Owner Author

Stopping here and handing this over. claude-review has now failed twice on 501e5be with the same infrastructure fault as the earlier episode — neither specialist produced a findings file, so validate.py failed closed with no verdict:

error: no specialist findings files match 'findings-*.json'
error: expected specialist(s) produced no findings file: correctness, conventions
       — that review lens never ran (orchestrator may have merged before all
       specialists completed); failing closed

I said I would not keep re-running past a repeat, so I am not queueing another.

Where the review actually stands. The last run that produced a verdict reviewed 1866472 and returned exactly one MEDIUM — the 24-hour retention window justified by a clause about exited sessions when the case it serves is a session that appears alive. That is fixed in 501e5be, which is the head that has not been successfully reviewed. Findings have narrowed monotonically across the cycle: HIGH design faults, then internal inconsistencies, then single stale sentences, then one unsupported rationale. The conventions lens has returned zero findings on four consecutive heads.

Rate. Across this branch: two failures at 2bce6e6, then five clean runs, now two at 501e5be. Roughly one run in three loses specialist output, in bursts rather than uniformly.

The most useful evidence for whoever fixes this is in the run that worked, at 1866472. Its own diagnostics reported that ListAgents showed the correctness specialist still running for several minutes after its findings file was already written to disk, and that the orchestrator therefore waited on completion notifications rather than file presence. That is the mechanism working correctly and saying out loud that file-presence is not a safe merge signal. A run that merges on the other path would produce exactly what is seen here.

This can only be fixed on main: the job does rm -rf .github/workflows/claude-review-v2 and restores it from the base SHA before validating, so nothing on this branch can change its own review.

Separately, test also went red once on this head, and it is unrelated. The branch changes three files, all under docs/, and no test reads anything from docs/ — one issue in 2678 tests after the suite's own retry pass. I re-ran it once; that is a normal flake rather than a gate defect.


Generated by Claude Code

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.

1 participant