ADR-0015 — Mutating operations require a verified claude contract band; CAM_ASSUME_CLAUDE_CONTRACT overrides
Context and Problem Statement
cam's interop surface is claude's credential write protocol: wS() path
resolution, .credentials.json schema, .oauth_refresh.lock /
.storage-write / <realpath(storage)>.lock coordination, and the scoped
login's env isolation. Bisecting the published linux-x64 artifacts (npm
cli.js bundles, then native binaries from 2.1.120 on) showed that
protocol is version-banded, not universal:
.oauth_refresh.lockappears by2.1.70,.storage-writeat2.1.136,CLAUDE_SECURESTORAGE_CONFIG_DIR(wS()) at2.1.144. The full contract exists together only from2.1.144; every sub-range below it lacks a different piece, so partial support cannot be stated.- Bands drift internally: the refresh lock's staleness moved
stale:1e4→stale:60000,update:5000between2.1.150and2.1.218, and.design_oauth_refresh.lockappeared at2.1.218. Patch releases change lock semantics; a newer version is not a compatible version. - The launcher strip list of 6 vars was written against an early bundle;
2.1.288reads credential-supply, endpoint-redirect, federation-store, and identity-injection vars that could silently skew or hijack a scoped login.
Before this decision only cam add enforced any floor (1.0.0, itself
wrong), and switch/usage/auto wrote credentials and consumed
one-time-use refresh tokens with no version check at all.
Decision Drivers
- Never lose or clobber a credential lineage: a write under an unverified contract can persist something a live claude mangles, and a spent refresh token cannot be un-spent.
- Fail closed on what we have not measured; fail open on what we have.
- Reads race nothing: the credential/config file schema is version-stable across every band observed, so read-only commands must work with no claude installed at all.
Considered Options
- Per-band behavior (different lock sets, probes per sub-range)
- Gate every command
- Gate at
camstartup - Gate at the narrowest unsafe op
- Floor
>=2.1.0 - Override via CLI flag or persisted
cam configkey
Decision Outcome
Chosen option: "Gate at the narrowest unsafe op", because probing only when about to mutate catches mid-session claude updates without charging every read a subprocess.
shared/claude_contract.py owns the band: claude --version → semver →
[2.1.144, 2.2.0), else UnsupportedClaudeVersionError. The gate sits at
the narrowest unsafe operation, per use case: SwitchAccount._transact
top (before .ops.lock), FetchAccountUsage/FreshenTarget immediately
before refresher.refresh(), ClaudeLoginLauncher before spawn. Probe
failures (absent binary, unparseable banner, nonzero exit, timeout)
refuse, failing closed. CAM_ASSUME_CLAUDE_CONTRACT non-empty marks the
contract assumed and bypasses the band; the override is explicit per
run and never persisted. Read-only paths (list, status, config, remove,
--dry-run, cache-served or unexpired-credential usage, the active
account's usage) never probe.
The launcher environment doctrine (T6): the login child gets ambient env
minus every var that could redirect endpoints, supply credentials,
re-point the federation store, inject identity, or re-import env files:
the observed 2.1.288 literal set, grouped by hazard class in
claude_login_launcher.py. CLAUDE_CONFIG_DIR is still set explicitly to
the account dir (ADR-0004).
Re-verification procedure (run before bumping SUPPORTED_MAX_EXCLUSIVE
or the floor): fetch the candidate's linux-x64 artifact
(npm pack @anthropic-ai/claude-code-linux-x64@<v>, a tarball holding the
native binary; older bands ship package/cli.js under
@anthropic-ai/claude-code instead), then grep the artifact for the
contract literals and compare semantics against the floor:
grep -ac SECURESTORAGE_CONFIG_DIR <artifact> # wS() present? (≥1)
grep -ac storage-write <artifact> # per-mutation lock (≥1)
grep -ac oauth_refresh <artifact> # refresh lock family
grep -aoE 'stale:[0-9]+' <artifact> # staleness drift check
<artifact> --version # X.Y.Z (Claude Code) shape
grep -ac 'claudeAiOauth\|expiresAt\|oauthAccount' <artifact> # schema
grep -aoE '(ANTHROPIC|CLAUDE)_[A-Z_]+' <artifact> | sort -u # env sweep
A bump is justified only when every literal is present, wS() semantics
and lock options match, and the env sweep adds nothing to the launcher's
hazard classes.
Consequences
Mutating operations now cost one claude --version subprocess per call,
bounded (15s timeout), and never on read paths. A user on claude >=2.2
gets a refusal naming the exact override rather than silent divergence;
the env var is deliberately per-run so the override cannot fossilize. The
band edge is a constant, not config; widening it is a re-verification
commit, not a user decision. What a future reader may want to "fix": the
upper bound looks conservative while 2.2.x does not exist yet. That
conservatism is the point: mid-band drift already happened once, and the cost of a false
positive is a clear error message while the cost of a false negative is a
stranded token lineage.
Confirmation
The stub-version matrix in test/unit/shared/test_claude_contract.py exercises versions inside and outside the band plus the CAM_ASSUME_CLAUDE_CONTRACT override.
Pros and Cons of the Options
| Option | Pro | Con |
|---|---|---|
| Per-band behavior (different lock sets, probes per sub-range) | Supports old claude | Each sub-range lacks a different contract subset; re-deriving per-band contracts is the unverified-band problem twice over, for zero users on ancient claude |
| Gate every command | Simplest rule | Breaks list/status/config/remove on machines with no claude; probes cost a subprocess per read |
Gate at cam startup |
One probe per invocation | A mid-session claude update slips through; TUI/long auto loops go stale |
| Gate at the narrowest unsafe op | Probe only when about to mutate; per-call probing catches updates at the next mutation, not next invocation | Probe is distributed across four call sites; needs a shared contract module to stay honest |
Floor >=2.1.0 |
More permissive | .storage-write/CSSCD absent below 2.1.144; interop claims would be false |
Override via CLI flag or persisted cam config key |
Discoverable | Flag needs plumbing per command; a persisted key silently outlives the mismatch it was set for |