instar 1.3.535 → 1.3.537
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +48 -0
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +13 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/CredentialSwapExecutor.d.ts +234 -0
- package/dist/core/CredentialSwapExecutor.d.ts.map +1 -0
- package/dist/core/CredentialSwapExecutor.js +596 -0
- package/dist/core/CredentialSwapExecutor.js.map +1 -0
- package/dist/core/WorkEvidence.d.ts +1 -1
- package/dist/core/WorkEvidence.d.ts.map +1 -1
- package/dist/core/WorkEvidence.js +7 -0
- package/dist/core/WorkEvidence.js.map +1 -1
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/types.d.ts +27 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/worktreeDirtyCheck.d.ts +31 -0
- package/dist/core/worktreeDirtyCheck.d.ts.map +1 -0
- package/dist/core/worktreeDirtyCheck.js +129 -0
- package/dist/core/worktreeDirtyCheck.js.map +1 -0
- package/dist/monitoring/ResumeQueueDrainer.d.ts +6 -0
- package/dist/monitoring/ResumeQueueDrainer.d.ts.map +1 -1
- package/dist/monitoring/ResumeQueueDrainer.js +32 -2
- package/dist/monitoring/ResumeQueueDrainer.js.map +1 -1
- package/dist/monitoring/SessionReaper.d.ts +8 -0
- package/dist/monitoring/SessionReaper.d.ts.map +1 -1
- package/dist/monitoring/SessionReaper.js +18 -4
- package/dist/monitoring/SessionReaper.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +1 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/package.json +1 -1
- package/scripts/lint-no-unfunneled-credential-write.js +7 -0
- package/src/data/builtin-manifest.json +2 -2
- package/src/scaffold/templates.ts +1 -0
- package/upgrades/1.3.536.md +34 -0
- package/upgrades/1.3.537.md +27 -0
- package/upgrades/side-effects/build-session-yield-safety.md +58 -0
- package/upgrades/side-effects/ws52-step5-swap-executor.md +64 -0
|
@@ -420,6 +420,7 @@ This routes feedback to the Instar maintainers automatically. Valid types: \`bug
|
|
|
420
420
|
- Notices are PER-TOPIC and durable: every topic that lost a session gets one plain-English notice in THAT topic (bursts coalesce per topic; the lifeline gets unbound sessions + a cross-topic index), delivered through a durable store + always-on drain that retries with backoff. Rollback levers: \`{"monitoring": {"reapNotify": {"perTopic": false}}}\` (legacy single-buffer), \`{"drainEnabled": false}\` (legacy direct send), \`{"enabled": false}\` (no notices). Recovery-bounces and your own operator kills stay silent.
|
|
421
421
|
- **Mid-work resume queue** (ships observe-only/dry-run by default): a session reaped MID-WORK (strong work evidence at kill time) is queued for ordered automatic revival once the machine recovers — at most one resume per minute, only after sustained calm + quota headroom. \`GET /sessions/resume-queue\` shows entries, paused/breaker state, and lastTickAt; \`POST /sessions/resume-queue/:id/cancel\` · \`/:id/requeue\` (gave-up entries only) · \`/resume\` (unpause) · \`/drain\` (manual single step). Emergency stops pause the queue; an explicit per-topic stop cancels that topic's entries. Jobs only auto-resume when their definition sets \`resumeOnReap: true\`.
|
|
422
422
|
- Proactive: user asks "where did my session go?" / "why did X disappear?" / "did something get killed?" → GET /sessions/reap-log and explain the most recent reaped/skipped entries for that session. User asks "did my interrupted work come back?" / "is a restart queued?" → GET /sessions/resume-queue and report the entry's status in plain words.
|
|
423
|
+
- **Build-Session Yield Safety** (ACT-839; ships dev-enabled, dark on the fleet, per the Maturation Path standard): a session reaped while its WORKTREE holds uncommitted work (a build that died "standing by for tests") is resume-eligible on that alone — the killer collects a bounded, fail-open dirty-check pre-kill and tags \`uncommitted-worktree-work\`. On revival the continuation prompt leads with a commit-first directive, and a durable beacon-enabled commitment (\`GET /commitments\`) re-surfaces the obligation if the revived session stalls. An explicit operator/user kill is NEVER auto-revived on a dirty worktree alone. The die-again case is caught by the OrphanedWorkSentinel (\`GET /orphaned-work\`). Proactive: user asks "why did my build come back / why am I being told to commit?" → it was revived because its worktree had unsaved work; commit it or deliberately discard it.
|
|
423
424
|
|
|
424
425
|
### Guard Posture — which safety systems are genuinely on (\`GET /guards\`)
|
|
425
426
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
The staged-exchange primitive (spec §2.3) that MOVES an account's OAuth credential between two config-home "slots" without restarting the sessions reading them. The sessions switch token sources on their next API call (the Claude client re-reads its credential store), so no restart, no aborted turns.
|
|
9
|
+
|
|
10
|
+
- **Exchange, never copy** — `swap(slotA, slotB)` exchanges the two slots' credentials so exactly one account-lineage lives in each readable config home (the one-home-per-credential invariant holds by construction).
|
|
11
|
+
- **Preconditions before any path expansion** — both slots must be EXACT members of the location ledger's enumerated slot set before any home-expansion/keychain call, so a `../`/`~`/absolute traversal value can never reach a keychain service (validated against the ledger, not the filesystem); a quarantined or no-refresh-token slot is refused.
|
|
12
|
+
- **Source-slot CAS (§2.3.1a)** — immediately before each destructive write the slot is re-read; a same-tenant newer blob (the Claude client's own rotated copy) is ADOPTED, a different-tenant blob is a clobber-race that ABORTS the swap, and a changed blob the oracle cannot confirm ABORTS — never a silent overwrite.
|
|
13
|
+
- **Crash-proof staging** — blob A is COPIED into a disjoint `instar-credential-swap-staging-*` keychain namespace and `begin` is journaled BEFORE the first destructive write, so a crash before the exchange unwinds to a true no-op and a crash mid-exchange recovers from the retained escrow.
|
|
14
|
+
- **Identity-verified, repair-safe** — after the exchange each slot is verified on its ACCOUNT IDENTITY via the profile-endpoint oracle. A confirmed match commits; a mismatch with a reachable oracle gets ONE repair-from-staging then re-verify; an UNAVAILABLE oracle is quarantine-never-repair (an outage never triggers a destructive repair). The executor never commits a slot it cannot identity-confirm.
|
|
15
|
+
- **Commit, then delayed re-verify** — the ledger is updated with staging RETAINED, then ~90s later both slots are re-verified before staging is deleted and the journal closed (the heal source for a client write-back that lands in the in-flight window).
|
|
16
|
+
- **Bounded everywhere** — all `security` keychain calls are async with a 10s timeout (never sync, which could wedge the event loop); every swap runs under the single-mover mutex + both slot locks (the Step-4b funnel); boot recovery acquires the single-mover mutex and the balancer's first pass is gated on a recovery barrier WITH a hang-timeout (a wedged recovery can't freeze the system).
|
|
17
|
+
- **Dark** — gated by the existing `subscriptionPool.credentialRepointing` flag (`enabled:false` + `dryRun:true`). With the feature off (the fleet + dev default) the executor is a strict no-op; with dry-run on it runs the full decision loop and audits the would-swap with zero writes. Going live needs a deliberate two-flag flip.
|
|
18
|
+
|
|
19
|
+
The HTTP route + census re-routing that wire this into the running system are Steps 6 and 7 (a later increment); this ships the swap primitive plus its three test tiers.
|
|
20
|
+
|
|
21
|
+
## What to Tell Your User
|
|
22
|
+
|
|
23
|
+
This is internal plumbing that ships turned off, so day to day nothing changes for you. What it builds toward: when I hold more than one of your subscription accounts, I can move which account a running session is using WITHOUT restarting that session — today moving an account means killing and respawning the session, which loses in-flight work and re-pays the warm-up cost. The new mechanism swaps the credential under the hood and the session just picks up the new account on its next request. It is built to be safe under failure: it never leaves a session pointed at an account it could not positively confirm, it survives a crash mid-swap without losing a login, and it can never run two swaps at once on the same machine. It is off by default and does nothing until it is deliberately turned on after a review window.
|
|
24
|
+
|
|
25
|
+
## Summary of New Capabilities
|
|
26
|
+
|
|
27
|
+
New exported `CredentialSwapExecutor` (src/core/CredentialSwapExecutor.ts) — the spec §2.3 staged-exchange primitive of live credential re-pointing. Gated by the existing `subscriptionPool.credentialRepointing` flag; no new config flag, no new HTTP route (routes are a later step). Consumes the merged Step 2/3/4 primitives (location ledger, identity oracle, write funnel).
|
|
28
|
+
|
|
29
|
+
## Evidence
|
|
30
|
+
|
|
31
|
+
- `tests/unit/credential-swap-executor.test.ts` (20) — dark/dryRun inertness; preconditions (non-member/quarantined/no-refresh-token rejection, zero writes); the happy exchange (keychain-first, identity-verified, one-lineage-per-home, staging retained then deleted at step 6); clobber-race (different-tenant abort, same-tenant-newer adopt); identity verify/repair/quarantine (mismatch repair-then-commit; oracle-unavailable quarantine-never-repair); THE blocker lens (no committed-unverified write); permutation-property (2 and 5 concurrent swaps serialize, exactly one exchange); crash-at-every-boundary (budgets 0/1/2 + hang-timeout barrier quarantining before lift); orphan-staging sweep predicate; token-material scrub.
|
|
32
|
+
- `tests/integration/credential-swap-executor-host.test.ts` (2) — the executor composed as its host will (REAL CredentialIdentityOracle profile-endpoint + REAL pool-mapping + REAL ledger + REAL funnel): oracle ALLOW yields commit; oracle UNAVAILABLE (5xx) at verify yields quarantine + attention.
|
|
33
|
+
- `tests/e2e/credential-swap-executor-dark-ship-lifecycle.test.ts` (3) — Phase-1 dark-ship inertness on the production config path: a DEV and a fleet agent's real ConfigDefaults gate the executor to a strict no-op; recovery on the dark config is still crash-safe.
|
|
34
|
+
- tsc clean; full `npm run lint` clean; dark-gate unchanged; docs-coverage green (CredentialSwapExecutor documented in architecture/under-the-hood.md).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: minor -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
A new dev-gated monitoring capability — **Build-Session Yield Safety** (ACT-839) — closes the "a background build died standing-by-for-tests with its work uncommitted" gap. R1: a session reaped while its worktree holds real uncommitted work is now resume-eligible on that alone — the killer (`SessionReaper`) runs a bounded, cached, fail-open `worktreeDirtyCheck` on the session's worktree PRE-kill (never a synchronous git call on the terminate chokepoint) and tags a new `uncommitted-worktree-work` STRONG `WorkEvidence` value. R2 (Close-the-Loop, signal not block): the revived session's continuation prompt leads with a verbatim commit-first directive, and `ResumeQueueDrainer` fires `onWorktreeRevival`, which registers a deduped, beacon-enabled `CommitmentTracker` obligation so a *stalled* revived session is re-surfaced by PromiseBeacon. The *die-again* case reuses the already-shipped, dev-live `OrphanedWorkSentinel` (#1113) for detect+preserve+surface — no duplicate scanner. An explicit operator/user/emergency kill is never auto-revived on a dirty worktree alone (the origin veto is final). Ships ENABLED on developer agents, dark on the fleet, per the new **Maturation Path** constitutional standard.
|
|
9
|
+
|
|
10
|
+
audience: agent-only
|
|
11
|
+
maturity: experimental
|
|
12
|
+
|
|
13
|
+
## What to Tell Your User
|
|
14
|
+
|
|
15
|
+
Nothing to announce proactively — it's an experimental safety net that runs on developer agents. If anyone asks: a background build that gets shut down before it saves its work now gets brought back and reminded to commit, instead of the work quietly vanishing.
|
|
16
|
+
|
|
17
|
+
## Summary of New Capabilities
|
|
18
|
+
|
|
19
|
+
| Capability | How to Use |
|
|
20
|
+
|-----------|-----------|
|
|
21
|
+
| Revive a session reaped with a dirty worktree | Automatic (dev-enabled, dark on fleet) |
|
|
22
|
+
| Durable "commit your worktree" obligation | `GET /commitments` (beacon re-surfaces a stalled revive) |
|
|
23
|
+
| Tune the dirty-check / residue denylist | `monitoring.yieldSafety.*` config |
|
|
24
|
+
|
|
25
|
+
## Evidence
|
|
26
|
+
|
|
27
|
+
Not a bug fix — a new dark, dev-gated feature; "not reproducible in dev" in the bug-fix sense. Verified by the 3-tier test plan: 19 unit tests for the shared `worktreeDirtyCheck` (both sides of every boundary + fail-open + cache), 5 reaper tests (dirty → evidence, clean → empty, feature-dark → empty, throw → fail-open, no-cwd → not-consulted), 7 drainer tests (the directive + the revival-obligation hook), and an e2e wired-pipeline test (a reaped dirty-worktree session is revived → the directive appears → a real beacon-enabled commitment is registered + deduped). tsc clean; full lint + dark-gate golden line-map + docs-coverage green.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Side-Effects Review — Build-Session Yield Safety (ACT-839)
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `build-session-yield-safety`
|
|
4
|
+
**Date:** `2026-06-13`
|
|
5
|
+
**Author:** `echo`
|
|
6
|
+
**Second-pass reviewer:** `spec-converge (7 rounds, zero material findings) + Standards-Conformance Gate`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
Implements the converged + operator-approved `BUILD-SESSION-YIELD-SAFETY-SPEC`: a reaped session whose worktree holds uncommitted work becomes resume-eligible (R1 — a new `uncommitted-worktree-work` STRONG `WorkEvidence` value, collected pre-kill by the killer via a shared bounded `worktreeDirtyCheck` helper), and a revived session gets a durably-tracked obligation to commit/preserve that work (R2 — an evidence-specific continuation-prompt directive, a revival-scoped CommitmentTracker commitment, drain-tick delivery, and a non-destructive preservation patch). Ships dev-gated (ON dev / OFF fleet) per the Maturation Path standard. Files: `src/core/worktreeDirtyCheck.ts` (new), `src/core/WorkEvidence.ts`, `src/config/ConfigDefaults.ts`, `src/core/devGatedFeatures.ts`, `src/monitoring/ResumeQueue.ts` + `ResumeQueueDrainer.ts`, `src/core/PostUpdateMigrator.ts`, the killer collection site, plus tests + agent-awareness docs.
|
|
11
|
+
|
|
12
|
+
## Decision-point inventory
|
|
13
|
+
|
|
14
|
+
- `WorkEvidence eligibility (R2.2)` — pass-through — the new STRONG value feeds the EXISTING `evidenceEligible` classifier; no new gate logic, and it never overrides the `origin==='operator'` veto.
|
|
15
|
+
- `worktreeDirtyCheck` — add — a new read-only "is this worktree really dirty?" decision (porcelain non-empty AND non-residue), fail-open.
|
|
16
|
+
- `ResumeQueue revival → commitment` — add — opens one beacon-eligible commitment per (session, worktree); dedupes on re-revival.
|
|
17
|
+
- `dev-gate (developmentAgent)` — pass-through — registers in the existing `DEV_GATED_FEATURES` resolution; no new gate mechanism.
|
|
18
|
+
|
|
19
|
+
## 1. Over-block
|
|
20
|
+
|
|
21
|
+
No block/allow surface — over-block not applicable. R1 is read-only signal collection; R2 is signal + a tracked obligation, explicitly NOT a blocking gate (the first-yield BLOCK was rejected at spec review on Signal-vs-Authority). The dirty-check is fail-open: a git error/timeout/non-git path yields NO signal, so it can never over-block a revival.
|
|
22
|
+
|
|
23
|
+
## 2. Under-block
|
|
24
|
+
|
|
25
|
+
The dirty-check can MISS real work (under-signal) when: git times out (>5s), the worktree path won't resolve, or all dirty entries match the residue denylist. All are deliberate fail-open choices — the cost is "no revival this time," and OrphanedWorkSentinel (#1113) still surfaces a missed dirty worktree post-hoc. A consistently-timing-out worktree is surfaced loudly (≥3 consecutive → HIGH attention), so silent permanent under-signal is caught.
|
|
26
|
+
|
|
27
|
+
## 3. Level of abstraction
|
|
28
|
+
|
|
29
|
+
The dirty-check lives in `src/core/` as a pure injectable helper reused by both the killer (monitoring) and the drainer (monitoring) — one implementation, not two. The evidence value lives in the existing `WorkEvidence` vocabulary clamped at the single kill chokepoint. No logic is duplicated; no cross-layer reach.
|
|
30
|
+
|
|
31
|
+
## 4. Signal vs. Authority
|
|
32
|
+
|
|
33
|
+
Fully compliant — and the central design point. R2 is a SIGNAL (directive) + a durable beacon, never a blocking authority over a session's yield. The only mutations are loss-reducing: a non-destructive preservation patch (never touches index/ref/history; secret-scrubbed; size-capped) and the session's own commits. The system never auto-commits on the agent's behalf to published history. The operator origin-veto remains final.
|
|
34
|
+
|
|
35
|
+
## 5. Interactions with adjacent systems
|
|
36
|
+
|
|
37
|
+
- **ResumeQueue / Drainer:** extends the existing midWork revival path; adds one evidence value + one commitment registration + a drain-tick delivery check. No parallel queue.
|
|
38
|
+
- **PromiseBeacon / CommitmentTracker:** reuses them for the obligation; deduped so no beacon flood.
|
|
39
|
+
- **OrphanedWorkSentinel (#1113):** complementary (post-hoc detector); R2 delivery prefers its sweep as an optimization but does not hard-depend on it (drain-tick is primary).
|
|
40
|
+
- **Kill chokepoint:** the dirty-check runs PRE-kill in the killer's loop, never synchronously on `terminateSession` — the event loop is never blocked per-kill.
|
|
41
|
+
|
|
42
|
+
## 6. Rollback cost
|
|
43
|
+
|
|
44
|
+
Low and clean. Ships dev-gated: disabling = flip the `developmentAgent` resolution / set `monitoring.resumeQueue.yieldSafety.enabled:false` → the feature is fully inert (the evidence value is harmless if never produced; the drainer delivery-check no-ops). No schema migration to reverse beyond a config key. The preservation patches are additive files under `state/`. Reverting the PR removes the code with no durable-state cleanup required.
|
|
45
|
+
|
|
46
|
+
## Evidence
|
|
47
|
+
|
|
48
|
+
Not a bug fix — a new dark, dev-gated feature. Verified by the spec's 3-tier + wiring-integrity test plan (unit: dirty-check both sides, anti-gaming, bounds, operator-veto, dedup; integration: route + commitment; e2e: feature-alive + revive-of-revive preservation). The shared `worktreeDirtyCheck` foundation ships with 19 unit tests both sides of every boundary.
|
|
49
|
+
|
|
50
|
+
## Implementation log
|
|
51
|
+
|
|
52
|
+
- 2026-06-13: shared `worktreeDirtyCheck` helper (foundation) landed + 19 unit tests.
|
|
53
|
+
- 2026-06-13: R1 — `uncommitted-worktree-work` evidence value, `monitoring.yieldSafety` config (enabled OMITTED, dev-gated), `DEV_GATED_FEATURES` registration; dark-gate golden line-map recomputed.
|
|
54
|
+
- 2026-06-13: R1 killer-wiring — SessionReaper collects the pre-kill dirty-check (session.cwd) and carries uncommitted-worktree-work; server.ts injects the bounded dirtyCheck only when the dev-gate is live; 5 reaper tests both sides + fail-open + no-cwd + feature-dark.
|
|
55
|
+
- 2026-06-13: R2 directive — ResumeQueueDrainer.continuationPrompt prepends the verbatim commit-first directive when uncommitted-worktree-work present (+ build-active 2nd sentence); 4 tests.
|
|
56
|
+
- 2026-06-13: R2 commitment-beacon — drainer fires onWorktreeRevival on revival of an uncommitted-worktree entry; server.ts registers a deduped beacon-enabled CommitmentTracker obligation (externalKey yield-safety:stableKey). DRY reconciliation: the die-again detect+preserve is the dev-live #1113 sentinel, NOT a duplicate in the drainer (spec updated; the "preserve-before-destroy" property was found unachievable). 3 drainer hook tests.
|
|
57
|
+
- 2026-06-13: e2e wired-pipeline test (dirty reap → revive → directive + real beacon commitment + dedup); CLAUDE.md template line + upgrades/next fragment (Maturation Path: dev-enabled).
|
|
58
|
+
- 2026-06-13: tag the SPEC-MANDATED fail-open catches with @silent-fallback-ok (the no-silent-fallbacks ratchet — fail-open is the documented safe direction, not a swallowed error).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Side-Effects Review — WS5.2 Step 5: CredentialSwapExecutor (staged-exchange swap primitive)
|
|
2
|
+
|
|
3
|
+
**Spec:** docs/specs/live-credential-repointing-rebalancer.md §2.3 (converged 5 rounds 50→0, `approved:true` — operator pre-approval, Justin topic 13481). **Parent:** Cross-Machine Coherence — One Agent, Robust Under Degraded Conditions.
|
|
4
|
+
**Ships DARK** behind the EXISTING `subscriptionPool.credentialRepointing` flag (`enabled:false` + `dryRun:true`, a `DARK_GATE_EXCLUSIONS` `destructive` entry shipped by Step 4b). Going live needs a deliberate two-flag flip. No new config flag, no new dark-gate line.
|
|
5
|
+
**Files:** src/core/CredentialSwapExecutor.ts (new), scripts/lint-no-unfunneled-credential-write.js (allowlist + header), tests/unit/lint-no-unfunneled-credential-write.test.ts (allowlist ratchet), tests/unit/credential-swap-executor.test.ts (new), tests/integration/credential-swap-executor-host.test.ts (new), tests/e2e/credential-swap-executor-dark-ship-lifecycle.test.ts (new), site/src/content/docs/architecture/under-the-hood.md (awareness).
|
|
6
|
+
|
|
7
|
+
## What changed
|
|
8
|
+
|
|
9
|
+
1. **CredentialSwapExecutor.ts (new):** the §2.3 staged-exchange primitive. `swap(slotA, slotB)` EXCHANGES (never copies) the two slots' OAuth credentials so the sessions reading them switch token sources on their next API call (E3) with zero restart. The full §2.3 sequence: preconditions (EXACT ledger membership BEFORE any path expansion → traversal can't reach a keychain service) → §2.3.1a source-slot CAS re-read (adopt a same-tenant newer blob, ABORT a different-tenant clobber-race) → staging escrow COPY into a disjoint `instar-credential-swap-staging-*` namespace + journal `begin` → exchange (keychain-first, config-second) → verify on ACCOUNT IDENTITY via the injected oracle resolver (match→commit, mismatch→one repair-from-staging→re-verify, **unavailable→QUARANTINE-NEVER-REPAIR**) → commit with staging RETAINED → delayed ~90s re-verify → delete staging + journal `done`. Consumes the merged primitives: `CredentialWriteFunnel.withSingleMover`+`withSlotLocks`, `CredentialLocationLedger` (membership/journal/quarantine/recordAssignment), and an injected `ResolveSlotIdentity` (composed by the host from `CredentialIdentityOracle`+pool).
|
|
10
|
+
2. **Async keychain exec surface (`KeychainCredentialExec` + `defaultKeychainExec`):** ALL `security` calls go through async `execFile` + a 10s timeout — NEVER `execFileSync` (a locked-keychain ACL prompt would wedge the event loop). Injectable so tests run zero-keychain. The default impl derives the slot service via the imported `claudeCredentialService(slot)`; the literal `'Claude Code-credentials'` never appears in the executor source.
|
|
11
|
+
3. **Boot recovery (`recover()`):** acquires the single-mover mutex (a recovery WRITE must not race a boot balancer pass on the ledger), resolves every in-flight journal row by re-reading live identity (adopt-on-newer, never a blind staging overwrite), and lifts a **recovery-complete barrier** so the balancer's first pass can run. The barrier carries a bounded HANG-TIMEOUT: a wedged recovery write quarantines its slots BEFORE the lift, so the post-lift balancer structurally cannot select a wedged slot. Recovery completion is INDEPENDENT of dryRun (an already-begun exchange is finished for crash-safety even on the dark config).
|
|
12
|
+
4. **`sweepOrphanStaging()`:** the §2.3 step-2 orphan predicate — a staging entry is protected by ANY non-`done` swap journal row (begin/exchanged/verified all keep their staging as the step-6 heal source); only a `done` row (or no row) makes staging an orphan. Avoids both the lost-source bug (deleting a committed row's heal source) and the orphan-leak.
|
|
13
|
+
5. **Single audit + scrub chokepoint:** every audited step routes through one `audit()` funnel that scrubs token material (`sk-ant-…` runs → `redactToken`) before it reaches any persisted/served/notified surface (§2.9). Quarantines raise ONE HIGH attention item (the blast-radius surface).
|
|
14
|
+
6. **lint allowlist (Step 4b ratchet):** `src/core/CredentialSwapExecutor.ts` added to the `lint-no-unfunneled-credential-write.js` closed allowlist (named in the header as the sanctioned Step-5 funnel route — its raw `add-generic-password` keychain write runs INSIDE `funnel.withSingleMover → withSlotLocks`, so the funnel-routing is at the call layer; the primitive must not self-lock under the slot locks). The self-test that asserts the closed allowlist set was updated to include the 5th entry.
|
|
15
|
+
7. **Awareness:** a `CredentialSwapExecutor` subsection in `architecture/under-the-hood.md` (Core Infrastructure) — 2 mentions, satisfies docs-coverage for the new exported class.
|
|
16
|
+
|
|
17
|
+
## Blast radius
|
|
18
|
+
|
|
19
|
+
- **The path is config-gated to a strict no-op while dark.** `swap()` returns `disabled` (zero keychain writes, zero ledger mutation) the instant `enabled` is false — the fleet + dev default. `dryRun:true` runs the full decision loop (preconditions + CAS) and audits the WOULD-swap but performs ZERO writes (returns `dry-run` before the staging step). Proven by the E2E dark-ship test (DEV and fleet config) + the unit dark/dryRun tests + the dark-gate test (16/16, unchanged).
|
|
20
|
+
- **No new flag, no new dark-gate line.** The executor reuses the Step-4b `subscriptionPool.credentialRepointing` flag already in `DARK_GATE_EXCLUSIONS`/`ConfigDefaults`, so the cartographer dark-gate line-map did not shift (no recompute needed; `lint-dev-agent-dark-gate.js` clean, the dark-gate test green as-is).
|
|
21
|
+
- **No unverified live commit.** A slot the oracle cannot identity-confirm (mismatch-unrepairable OR oracle-unavailable) is quarantined + surfaced; `swap()` returns `quarantined` BEFORE `recordAssignment` — the ledger never records a clean assignment for an unverified slot. Proven by THE blocker lens test.
|
|
22
|
+
- **No unfunneled credential write.** Every keychain credential write is inside `withSingleMover → withSlotLocks` (the Step-4b funnel guards a swap against a concurrent refresh/switch on the same slot). `lint-no-unfunneled-credential-write` clean.
|
|
23
|
+
- **Per-machine local, no cross-machine egress (Phase C).** The swap moves a credential between config slots on ONE machine; it carries no LAN/broadcast assumption and adds no cross-machine traffic. An N-machine pool runs one single-mover mutex + recovery barrier PER machine; each machine's executor is independent and bounded — the fleet never has two movers on the same home.
|
|
24
|
+
|
|
25
|
+
## Risk + mitigation
|
|
26
|
+
|
|
27
|
+
- **Risk:** a concurrent Claude-client keychain write on the source slot strands a rotated lineage (the §0.c residual instar cannot lock). **Mitigation:** §2.3.1a source-slot CAS re-reads each slot immediately before the destructive write; a same-tenant newer blob is ADOPTED (carry the client's rotated copy), a different-tenant blob ABORTS the swap (clobber-race), an unconfirmable changed blob ABORTS (never overwrite what we can't identity-verify). The window is narrowed (final-re-read→write), not closed against an external writer — the §2.3.6 delayed re-verify + the §2.4 audit catch the honest residual. Proven by the clobber-race adopt/abort tests.
|
|
28
|
+
- **Risk:** a crash mid-exchange destroys a blob or strands staging. **Mitigation:** staging is a COPY (slot A untouched until step 3 → crash-before-step-3 unwinds to a no-op); `begin` is journaled before the first destructive write; staging is RETAINED through step 6 (the heal source); recovery resolves every in-flight row by live identity (adopt-on-newer, never blind). Proven by the crash-at-every-boundary tests (budgets 0/1/2) + the orphan-sweep predicate test.
|
|
29
|
+
- **Risk:** an oracle outage during verify triggers a destructive repair-storm against healthy blobs. **Mitigation:** oracle-UNAVAILABLE is read as "unverified", NEVER as "mismatch" — it quarantines (never repairs); repair is reserved for a CONFIRMED mismatch with a reachable oracle. Proven by the quarantine-never-repair test + the host-wired REAL-oracle 5xx test.
|
|
30
|
+
- **Risk:** a wedged recovery write (keychain ACL prompt) freezes the balancer forever. **Mitigation:** the recovery barrier carries a bounded hang-timeout that quarantines the unresolved slots THEN lifts (the per-write 10s execFile timeout is what releases the held lock). Proven by the hang-timeout barrier test.
|
|
31
|
+
- **Risk:** a token byte leaks into a log/attention/reason string. **Mitigation:** single `audit()`/scrub chokepoint redacts every `sk-ant-…` run before any surface; quarantine reasons + oracle error strings pass through `scrub()`. Proven by the token-scrub test (an adversarial token-bearing oracle reason is redacted).
|
|
32
|
+
- **Risk:** two swaps interleave and corrupt a slot. **Mitigation:** the single-mover mutex admits exactly one swap; the loser is a transient `skipped` (retry), never a partial write. Proven by the permutation-property tests (2 and 5 concurrent swaps → exactly one exchange).
|
|
33
|
+
|
|
34
|
+
## Migration parity
|
|
35
|
+
|
|
36
|
+
- **No migration needed.** No new config flag (reuses the Step-4b `credentialRepointing` block already in `ConfigDefaults` + `DARK_GATE_EXCLUSIONS`), no new hook, no CLAUDE.md template section (this ships dark + internal; the awareness note lives in the docs site, not the agent template — so no `templates.ts`/`migrateClaudeMd` touch, no `featureSections`/shadow-marker entry). The executor is a new module consumed by the Step-6/7 host wiring (a later increment); nothing on the production path constructs it live yet.
|
|
37
|
+
|
|
38
|
+
## Dark-gate line-map
|
|
39
|
+
|
|
40
|
+
- UNCHANGED. No new `enabled:` literal was added to `ConfigDefaults.ts` — the executor is gated by the existing `subscriptionPool.credentialRepointing.enabled` line (shipped by Step 4b). The dark-gate attributor reads `ConfigDefaults.ts` `enabled:` lines only; none shifted. Verified: `node scripts/lint-dev-agent-dark-gate.js` → clean; `tests/unit/credential-repointing-dark-gate.test.ts` → green; the dark-gate ground-truth test → unchanged.
|
|
41
|
+
|
|
42
|
+
## Rollback
|
|
43
|
+
|
|
44
|
+
- The feature is already dark (`enabled:false` + `dryRun:true`) — the executor is inert. To fully revert Step 5: remove `src/core/CredentialSwapExecutor.ts` + its three test files, revert the lint allowlist entry + its self-test edit, and drop the `under-the-hood.md` subsection. Dark + additive throughout; no consumer on the production path depends on it yet.
|
|
45
|
+
|
|
46
|
+
## Tests
|
|
47
|
+
|
|
48
|
+
- `tests/unit/credential-swap-executor.test.ts` (20) — dark/dryRun inertness; preconditions (non-member/quarantined/no-refresh-token rejection, zero writes); the happy exchange (keychain-first, identity-verified, §0.d one-lineage-per-home, staging retained→step-6 delete); **clobber-race** (different-tenant→abort, same-tenant-newer→adopt); **identity verify/repair/quarantine** (mismatch→one-repair→commit; oracle-unavailable→quarantine-never-repair); **THE blocker lens** (no committed-unverified write); **permutation-property** (2 + 5 concurrent swaps serialize, exactly one exchange); **crash-at-every-boundary** (budgets 0/1/2 + hang-timeout barrier with pre-lift quarantine); orphan-staging sweep predicate; token-scrub.
|
|
49
|
+
- `tests/integration/credential-swap-executor-host.test.ts` (2) — the executor composed exactly as the Step-7 host will (REAL `CredentialIdentityOracle` profile-endpoint + REAL pool-mapping + REAL ledger + REAL funnel): oracle ALLOW → commit; oracle UNAVAILABLE (5xx) at verify → QUARANTINE + attention.
|
|
50
|
+
- `tests/e2e/credential-swap-executor-dark-ship-lifecycle.test.ts` (3) — Phase-1 dark-ship inertness on the PRODUCTION config path: a DEV and a fleet agent's real ConfigDefaults gate the executor to a strict no-op (zero writes, ledger untouched, byte-identical blobs); recovery on the dark config is still crash-safe (an already-begun exchange finishes regardless of dryRun).
|
|
51
|
+
- Full unit suite green (zero regressions); `npm run lint` clean (tsc + all structural lints).
|
|
52
|
+
|
|
53
|
+
## Phase C answer (operator clarity)
|
|
54
|
+
|
|
55
|
+
The swap is **per-machine local** — it moves a credential between config slots on ONE machine and adds no cross-machine egress / no LAN/broadcast assumption. An N-machine pool runs its single-mover mutex + recovery barrier independently per machine, so the fleet never has two movers on the same home; each machine's executor is bounded by its own mutex.
|
|
56
|
+
|
|
57
|
+
## 4-lens adversarial verdict
|
|
58
|
+
|
|
59
|
+
- **Unverified-write bypass (THE blocker):** PASS — no path commits a slot the oracle cannot identity-confirm; an unconfirmable slot is quarantined and `swap()` returns before `recordAssignment`. Test: "no committed-unverified write".
|
|
60
|
+
- **Clobber-race / source-slot CAS:** PASS — a concurrent client write is adopted (same tenant) or aborts (different tenant / unconfirmable), never silently overwritten. Tests: clobber-race adopt/abort.
|
|
61
|
+
- **Crash-boundary coherence:** PASS — journal begin/done + retained staging + adopt-on-newer recovery leave no lost-lineage state. Tests: crash budgets 0/1/2 + hang-timeout barrier.
|
|
62
|
+
- **Dark-ship inertness:** PASS — flag off → strict no-op; reuses the existing dark-gate exclusion (no line shift). Tests: E2E dark-ship (dev+fleet) + dark-gate.
|
|
63
|
+
|
|
64
|
+
<!-- tracked: 20905 -->
|