Skip to content

fix: AlgoPhase fails closed on ambiguous session resolution#1468

Closed
jbmml wants to merge 1 commit into
danielmiessler:mainfrom
jbmml:fix/algophase-fail-closed-resolution
Closed

fix: AlgoPhase fails closed on ambiguous session resolution#1468
jbmml wants to merge 1 commit into
danielmiessler:mainfrom
jbmml:fix/algophase-fail-closed-resolution

Conversation

@jbmml

@jbmml jbmml commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Part 2 of 2 for #1466 (builds on the same analysis as #1467, which fixes the sibling bug in Inference.ts --auto-state). This half is a deliberate policy change — warn-and-guess becomes refuse-and-explain — so the behavior deltas are listed explicitly below.

What

resolveSlug()'s no-flag fallback picked the most-recent live algorithm-mode row in work.json, printed a WARN, and wrote the phase to it anyway. With two Algorithm sessions alive, a bare AlgoPhase <phase> from one session could flip the OTHER session's phase — cross-session registry corruption that propagates to the Pulse dashboard and every other work.json reader. The hazard was already flagged in the code comment at the fallback site; this PR makes the code act on it.

How

Deterministic priority chain, fail closed on ambiguity:

  1. --slug — trusted verbatim, including phase: complete rows (resume-after-complete is a sanctioned flow). Lookup uses Object.hasOwn instead of the in operator so prototype keys like --slug constructor can't false-match and trigger a registry write; the __pulse_strip metadata row is unaddressable from every path. When --uuid is also given, the pair is cross-checked and a mismatch errors.
  2. --uuid — explicit flags outrank ambient env (an explicit flag must never be silently overridden by environment state); no live match is an error, never a fall-through.
  3. CLAUDE_SESSION_ID env — set-but-unmatched is a hard error rather than a silent fall-through to a guess.
  4. Exactly ONE live algorithm-mode row updated within the last 60 minutes → convenience path preserved (bare call keeps working for the single-session user, with a stderr NOTE). Zero or multiple candidates → ERR: listing them, exit 1, no write.

The 60-minute freshness horizon exists because "not complete" is a weak liveness signal: crashed sessions leave non-complete rows behind and the registry stale sweep only prunes them after ~7 days — without a horizon, one week-old ghost row would hard-fail every bare call. A session actively running the Algorithm touches its row on every phase emit and tool-activity hook, so an hour of silence means the row isn't driving anything. Stale rows remain addressable explicitly via --slug/--uuid.

Output contract otherwise unchanged: OK: <slug> <prev>→<phase> on stdout, ERR: <reason> on stderr, exit 0/1.

Behavior changes (please review deliberately)

  • Bare calls under concurrency now exit 1. Any caller relying on bare AlgoPhase <phase> with multiple live sessions gets an error listing the candidates instead of a write to the most-recent row. The ALGORITHM v6.24.0 phase-tracking doc is updated in this PR to instruct AlgoPhase.ts <phase> --slug <slug> at every transition, which sidesteps the ambiguity entirely.
  • Bare calls against a stale-only registry now exit 1 (rows idle >60 min are not guessable) — previously a days-old crashed row could be silently resurrected.
  • --uuid with no live match now exits 1 (previously fell through to the guess path).
  • Re-emitting a phase for an already-complete session without --slug now exits 1 — complete rows are only addressable explicitly (--slug), matching the resume-after-complete flow.
  • CLAUDE_SESSION_ID honesty: Claude Code does not export this variable to tool subprocesses today, so the env path is forward-looking defense-in-depth (and local-trust only — not an auth boundary). The operative contract today is: explicit --slug/--uuid, or exactly one fresh live row.
  • Advisor interplay: the sibling PR changes Inference.ts --auto-state so an undeterminable caller yields an explicit no-state marker (with a 5-minute completion window preserving the advisor-after-complete doctrine) instead of a possibly-wrong ISA — see fix: deterministic session resolution for Inference --auto-state #1467 for that disclosure.

Verification

Self-contained bun suite added alongside the tool (AlgoPhase.resolution.test.ts, subprocess-driven against a temp LIFEOS_DIR registry — hooks/lib/paths.ts honors the override, the real registry is never touched): ambiguous bare call fails closed with candidates listed and a byte-identical registry; single fresh live row still works bare; stale rows excluded from guessing but stale-only registries error; env match/env-unmatched; --uuid precedence over env and no-match error; --slug on complete rows; --slug/--uuid mismatch; constructor prototype guard; __pulse_strip unaddressable. 12/12 pass; tsc --noEmit --strict clean.

resolveSlug()'s no-flag fallback picked the most-recent live
algorithm-mode row in work.json, printed a WARN, and wrote the phase to
it anyway. With two Algorithm sessions alive, a bare AlgoPhase <phase>
from one session could flip the OTHER session's phase — cross-session
registry corruption that propagates to the Pulse dashboard.

Resolution is now deterministic and fail-closed:
1. --slug: trusted verbatim, including phase:complete rows
   (resume-after-complete is a sanctioned flow). Object.hasOwn instead
   of the in operator so prototype keys like "constructor" can't
   false-match; __pulse_strip is unaddressable from every path. When
   --uuid is also given, the pair is cross-checked.
2. --uuid: explicit flags outrank ambient env; no live match is an
   error, never a fall-through.
3. CLAUDE_SESSION_ID env: set-but-unmatched is a hard error. Claude
   Code does not export this variable to tool subprocesses today; the
   path is forward-looking defense-in-depth, local-trust only.
4. Exactly ONE live algorithm-mode row updated within the last 60
   minutes: convenience path preserved (stderr NOTE). Older
   non-complete rows are crash leftovers (the registry stale sweep only
   prunes after ~7 days) and are never guessable. Zero or multiple
   candidates: ERR listing them, exit 1, no write.

Also updates the ALGORITHM v6.24.0 phase-tracking doc to pass --slug at
every transition, since bare calls now fail closed under concurrency.

Adds a self-contained bun test suite (subprocess-driven against a
temp LIFEOS_DIR registry) covering all resolution branches.
@danielmiessler

Copy link
Copy Markdown
Owner

Thank you @jbmml — a legit fail-open bug. Heads up though: as of v8.2.0, AlgoPhase is no longer a required station (the per-transition phase ceremony was removed; phase tracking is now ISA frontmatter + ISASync, and nothing invokes AlgoPhase.ts in the loop). Since the tool it hardens is being sunset, we're not pulling this into v7.0.0. Genuinely appreciate the fix and the test.

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.

2 participants