message: 403: {"code":"ENTITLEMENT_ERROR","message":"Error 403: the user is not subscribed to required model plan"}

stop_reason: error


error_message: 403: {"code":"ENTITLEMENT_ERROR","message":"Error 403: the user is not subscribed to required model plan"}


I read this as a read-only security and migration review - I’ll inspect the required diff and supporting tests/docs, then report severity-ordered findings with precise file:line evidence and an explicit PASS/blocker status.


Review basis: all six requested paths are untracked additions, so `git diff` is empty; I reviewed their current worktree contents. Focused tests: `29 passed`. No production mutation, Telegram, or network access was used.

## Blockers

1. **BLOCKER — documented rollback preflight cannot bind the built rollback executable.**
   `scripts/build_dualcoach_v111_rollback.py:113-118` copies the running executable into a new package path, but `scripts/plan_dualcoach_v111_migration.py:216-228` requires `/proc/<pid>/exe` and systemd `ExecStart` to resolve to that package path. The running service still points to the original executable, so the documented command in `docs/dualcoach-v1.1.1-migration.md:33-40` will reject the package. The package also contains only one executable, not a complete venv, and does not validate that the copied unit references it (`migrate_dualcoach_v111_profile.py:173-200`). The test fixture even accepts `ExecStart=prior` (`tests/...:140`, `544`).

2. **BLOCKER — rollback builder can delete a production profile on path overlap.**
   `build_dualcoach_v111_rollback.py:80-87, 97-104` removes any existing output directory without checking that it is independent from `profile_root`. Passing the profile itself, or an ancestor containing it, can invoke `shutil.rmtree()` before sealing. This violates the claimed read-only/non-mutation boundary.

3. **BLOCKER — a no-op target candidate is not rejected before downtime.**
   `plan_dualcoach_v111_migration.py:229-235` validates the target binding but never requires it to differ from the current candidate. The emitted plan stops the service first (`:200`) and only later runs migration (`:204`). The migrator writes the target binding before `compare_candidate_transition()` rejects the same candidate (`migrate_dualcoach_v111_profile.py:270-272`), leaving partial rollback artifacts and modified staging state.

4. **BLOCKER — generated rollback does not perform its stated safety gates.**
   `plan_dualcoach_v111_migration.py:208` does `mv`, `cp -a`, relink, daemon reload, and start, but does not ensure the destination is absent, restore atomically, validate PID/executable/environment, or run the prior smoke gate. `success_gate` is only descriptive. This contradicts the required procedure in `docs/dualcoach-v1.1.1-migration.md:187-195`.

5. **HIGH — seal and migration-marker integrity is not fail-closed.**
   `compare_dualcoach_v111_seals.py:310-312` compares semantic artifacts but never recomputes `semantic_root_sha256`. `_resume_existing()` also ignores the manifest contents and does not verify the marker’s `rollback_manifest_sha256` (`migrate_dualcoach_v111_profile.py:231-242`). I verified in a temporary fixture that both stored seal roots can be replaced with zero digests and re-entry still succeeds.

## Residual risks and test gaps

- Direct/collecting state is not cross-correlated: bootstrap generation/session, publication generation/message, activation customer, and onboarding customer are only shape-validated (`compare_dualcoach_v111_seals.py:120-159`). The synthetic fixture itself uses bootstrap generation 4 and publication generation 2 and still seals successfully (`tests/...:63-66`).
- The maintenance-window snapshot command only copies files and records no inventory, despite claiming one (`plan_dualcoach_v111_migration.py:201`); rollback later uses the separate preflight snapshot (`:208`).
- No test covers output/profile overlap, same-candidate planning, tampered seal roots/manifests, unit-to-venv binding, or execution of the rollback command. The tests are synthetic filesystem tests plus a fake `systemctl` query, not a full lifecycle/E2E rollback.
- No direct Telegram-token leak was found: the planner only checks token presence (`plan_dualcoach_v111_migration.py:145-159`) and does not serialize it; the focused test confirms this (`tests/...:515-529`). Planner stdout still contains private paths/digests and relies on operator redirection.

Legacy live-profile compatibility is covered only for planner validation with an external binding (`tests/...:568-584`); the full legacy copy/build/migrate path remains untested.

**Status: BLOCKED — not PASS.**


I read this as a follow-up release-gate review - I’ll inspect the updated six-file scope, trace each previously reported blocker to its new guard or proof, run the focused migration tests, and return PASS only if every release blocker is closed.


Status: **BLOCKED — not PASS**.

Focused migration tests pass: **36 passed**. No production mutation, Telegram, or network access was performed.

## Findings

1. **P1 — standalone seal comparison remains fail-open.**
   Migration re-entry now validates seal roots in `scripts/migrate_dualcoach_v111_profile.py:244-246`, and manifest integrity is checked at `:281-305`. However, the documented comparator still only checks schema and artifact equality (`scripts/compare_dualcoach_v111_seals.py:309-313`); `_load_seal()` validates only digest shape (`:329-340`). It never recomputes `sha256(semantic_artifacts)`.
   I verified that two seals with both roots replaced by zeroes still return `SEALS_MATCH`. This affects the documented command in `docs/dualcoach-v1.1.1-migration.md:189-195`.

2. **P1 — staged rollback is not fail-closed if post-start verification fails.**
   `scripts/plan_dualcoach_v111_migration.py:226` starts the restored service and then runs planner verification. If verification exits nonzero, the shell chain stops while the service remains running; there is no cleanup stop or forced paused state. The same exposure exists for candidate resume at `:225`. This contradicts the runbook’s “remain paused” requirement (`docs/dualcoach-v1.1.1-migration.md:208-211`).
   The test at `tests/gateway/test_dualcoach_v111_migration.py:505-530` only checks command text and `bash -n`; it does not execute a failing verification path.

3. **P2 — manifest validation does not bind `live_root`.**
   Re-entry validates `staging_root`, candidates, semantic root, and manifest hash (`migrate_dualcoach_v111_profile.py:290-303`), but never compares `manifest["live_root"]` with the current live profile. The documentation claims the manifest binds both live and staging roots (`docs/...:183-187`). This is a residual integrity gap.

4. **P2 — stopped-state snapshot is procedural, not enforced by the builder.**
   The generated sequence now correctly stops first and invokes the fresh inventory builder (`plan_dualcoach_v111_migration.py:218-219`), and the builder now creates a unit/runtime-bound inventory (`build_dualcoach_v111_rollback.py:106-169`). But `build_rollback_package()` has no stopped-state input or check, so direct or out-of-order invocation can snapshot a live profile. The new tests verify ordering text, not refusal while active.

## Prior blocker verification

- Rollback executable/unit/snapshot binding and the docs command: **fixed**; the builder and validator now bind the unit runtime and snapshot bytes (`build...:106-159`, `migrate...:202-235`).
- Output/profile overlap: **fixed** at `build...:96-103`, with coverage at `tests/...:697-723`.
- Same-candidate pre-downtime refusal: **fixed** at `migrate...:328` and `plan...:268-272`, with coverage at `tests/...:746-779`.
- Post-migration verification mode: **fixed** at `plan...:256-270`, with coverage at `tests/...:472-503`.
- Secret exposure: no Telegram token is emitted by the planner; focused tests still cover this.

The remaining standalone seal-integrity and rollback-failure-state issues are release blockers.


I read this as the final release-gate review - I’ll inspect the latest implementations and tests for each named guard, execute the focused migration suite plus failure-path checks, and return PASS only if all release blockers are closed.


Status: **BLOCKED — not PASS**.

Focused migration tests: **40 passed**. I also executed the generated rollback failure path with temporary fake `systemctl`/`uv`; verification failure returned nonzero and issued a final stop. No production mutation, network, or Telegram access was used.

## Verified fixed

- `compare_seals()` now recomputes both semantic roots (`scripts/compare_dualcoach_v111_seals.py:309-313`); regression coverage at `tests/...:254-265`.
- Manifest `live_root` is now checked against the current live path (`scripts/migrate_dualcoach_v111_profile.py:294-305`), with tamper coverage in `tests/...:395-438`.
- Resume and rollback verification failures force a stop (`scripts/plan_dualcoach_v111_migration.py:249-250`). Resume is executed by `tests/...:558-598`; rollback failure behavior was independently executed in a temporary fixture.
- Unit staging uses a pending file followed by `mv` and daemon reload (`plan_dualcoach_v111_migration.py:247`), providing atomic same-filesystem replacement.
- Fresh execution snapshots invoke the inventory-bound builder after the stop gate (`plan_dualcoach_v111_migration.py:242-243`); the builder records runtime binding, semantic seal, tree digest, and inventory.

## Remaining blocker

1. **P1 — active-profile refusal can be bypassed without the explicit preflight capability.**
   `_profile_has_active_process()` compares `HERMES_HOME` as raw bytes (`scripts/build_dualcoach_v111_rollback.py:92-107`) and silently ignores `PermissionError` while reading `/proc/*/environ` (`:99-102`). A running process using a symlinked or differently spelled path to the same profile is therefore missed. I reproduced this with a temporary `/proc` fixture: a live process with `HERMES_HOME` set to a symlink alias was accepted without `allow_active_read_only_preflight=True`.

   The explicit capability and default refusal exist (`:123-127`, CLI flag `:219-229`, docs `docs/...:29-35`), but the detection must resolve profile paths and fail closed on unreadable process environments. As written, a live profile can still be snapshotted as if stopped.

All other requested blocker checks pass, but this active-process detection gap prevents release approval.


I read this as a single-blocker closure review - I’ll inspect the canonicalized active-process detector and its tests, run the focused migration suite, and return either PASS or one precise remaining blocker without editing.


**PASS**

- Focused migration suite: **41 passed**.
- Symlink-alias `HERMES_HOME` is canonicalized and detected (`build_dualcoach_v111_rollback.py:112-126`); covered by `tests/...:768-817`.
- Unreadable same-owner process environments fail closed when `/proc/<pid>/cwd` resolves to the selected profile (`:98-109`); covered by `tests/...:820-856`.
- Unrelated unreadable processes are skipped; independently verified with a temporary unrelated process fixture.
- Active-profile bypass requires `allow_active_read_only_preflight=True` (`:145-149`); normal execution remains refusal-by-default.
- No files were edited and no production/network operations were performed.