# Diagnostic Isolation — Final Safety Closure Plan

## Status
Planning only. This plan incorporates Stage 28 by reference and changes only the three final blockers below.

## RALPLAN-DR

### Principles
1. Read-only audit must be physically non-mutating, not merely logically read-only.
2. Closing diagnostics must linearize before any later reservation/provider admission.
3. Security policy resources must survive packaging and installation exactly.
4. Ordinary production and all Stage-28 boundaries remain unchanged.

### Drivers
- prevent audit lock files/modes from changing source state;
- guarantee provider zero after close/expiry/stop fence;
- prevent installed deployments from silently losing the promotion policy.

### Options
- **Shared-inode read APIs + detaching generation fence + package-data proof (chosen):** preserves writer synchronization and fail-closed semantics.
- Copy ledgers without locks: rejected because snapshots can tear.
- Keep policy only in source tree: rejected because wheels may omit it.
- Drain without detaching state: rejected because new sends can enter while draining.

## 1. Physically read-only shared locks

### APIs and files
- `WizardStorage.read_locked()` in `wizard_storage.py`
- `AdaptiveEventStore.read_locked()` in `adaptive_nutrition.py`
- `schedule_delivery_read_lock(profile_root, customer_key)` plus a read-only schedule snapshot API in `customer_schedule.py`
- corresponding tests in `test_diagnostic_isolation.py`, `test_customer_schedule.py`, and existing adaptive/wizard tests.

Each reader derives the **same existing lock path/inode** used by its writer. It opens with `os.open(path, O_RDONLY|O_NOFOLLOW|O_CLOEXEC)`, verifies regular file, owner, mode 0600, link count 1 and unchanged `(st_dev, st_ino)` after open, then takes `flock(LOCK_SH)`. It never mkdirs, touches, chmods, creates, truncates, recovers or writes. Missing/unsafe/replaced locks fail closed as `snapshot_unstable` or `corrupt_state` and produce no artifact.

Writers keep `LOCK_EX` on the same inode but are hardened to open safely (`O_RDWR|O_NOFOLLOW|O_CLOEXEC`) after their existing controlled initialization. Initialization remains write-path only. Snapshot export may call only read APIs and validated non-recovering readers while holding the shared lock.

Tests compare recursive names, bytes, modes, mtimes and sizes before/after (atime excluded), cover missing lock and symlink/inode replacement, and run thread/subprocess writer-vs-reader barriers proving either a coherent before/after snapshot or bounded `snapshot_unstable`, never torn evidence.

## 2. Installed promotion-policy resource

### Files
- new `checkin_cli/policies/__init__.py`
- `checkin_cli/policies/diagnostic-promotion-policy.json`
- `pyproject.toml` package-data configuration
- `diagnostic_evidence.py` embedded expected SHA-256 and `importlib.resources.files("checkin_cli.policies")` loader
- profile packaging tests.

`pyproject.toml` explicitly includes `checkin_cli.policies/*.json` in wheel/sdist package data. Runtime loads only through `importlib.resources`, canonicalizes exact bytes, and compares to the code-embedded digest before deriving any promotion trust root. Missing/mismatched resource disables manifest generation.

A hermetic test builds a wheel into a temporary directory with the project’s configured build backend, inspects wheel members, installs/extracts into an isolated temp target without network/dependency resolution, imports the package resource, and verifies bytes and digest. Source-tree-only success is insufficient.

## 3. Detachment/provider linearization fence

### State contract
Add nonterminal `detaching` with monotonically increasing `session_generation`. `DiagnosticHost` owns one async admission lock shared by `activate/revalidate`, `begin_delivery_admission`, `detach/close/expiry/stop`, and route generation snapshots.

- Detach acquires admission lock, validates generation, atomically transitions active→detaching in the durable session journal, increments generation, marks in-memory host detaching, and removes routes before releasing the lock.
- Final delivery admission acquires the same lock and, in the same critical section as durable reservation creation, verifies state=active, exact generation/session/boot/full pins and route full triple. Reservation receives that generation.
- If reservation linearizes first, it is an admitted in-flight attempt governed by existing at-most-once and post-reservation revalidation; detach waits for the bounded in-flight admission counter to reach zero before terminal cleanup. If detach linearizes first, later reservation/provider count is zero.
- Immediately before provider call, attempt rechecks durable state/generation under the same admission protocol. A detaching mismatch terminalizes the existing reservation with provider zero.
- Stop/expiry use the same detach method; cancellation cannot skip durable detaching/cleanup. Crash recovery treats detaching as routes absent, provider forbidden, and completes disable/terminal cleanup.

`DiagnosticRoleRoute` stores the exact full triple. `_lookup_diagnostic_role_route` compares sender `user_id`, `chat_id`, and `topic_id`, then carries the session generation to final admission. Any mismatch yields product/provider row zero.

Tests cover route lookup paused before admission while close/expiry/stop wins; admission paused before reservation; reservation-first then detach; provider-boundary generation change; thread/event-loop/subprocess equivalents where applicable. Assertions distinguish diagnostic session/transition rows from nutrition/delivery rows and prove no provider call after the detaching linearization point.

## Pre-mortem
1. Reader creates a missing lock: blocked by O_RDONLY and missing-lock rejection.
2. Wheel omits policy: build/install resource test blocks release.
3. Handler retains stale route through close: generation carried to final admission rejects.
4. Reservation races detach: shared admission lock defines one winner and provider semantics.

## Verification
Profile cwd `/home/cube/.hermes/profiles/physique-coach/workspace/checkin_cli`:
- `.venv/bin/python -m pytest -q`
- `.venv/bin/python -m compileall -q checkin_cli`
- wheel/resource test is part of full pytest and must run offline.

Gateway cwd `/home/cube/projects/richard/hermes-agent`:
- `.venv/bin/python -m pytest -q tests/gateway/test_adaptive_nutrition.py tests/gateway/test_nutrition_coaching.py tests/gateway/test_telegram_physique_checkin.py tests/gateway/test_telegram_group_gating.py`
- `.venv/bin/python -m py_compile gateway/platforms/diagnostic_isolation.py gateway/platforms/nutrition_coaching.py gateway/platforms/telegram.py gateway/platforms/nutrition_coaching_config.py`
- full `tests/gateway` run and truthful classification.

No network/live Telegram/manual P2–P6/deployment. Final cleaner, Architect, executor QA/red-team and terminal Critic are mandatory.

## Acceptance
1. Snapshot success and rejection leave source tree unchanged except atime.
2. Reader/writer synchronize on the same verified inode; torn snapshot impossible.
3. Built/installed package exposes exact policy bytes through importlib.resources.
4. After durable detaching fence, new nutrition/delivery reservation and provider calls are zero.
5. A pre-fence admitted reservation remains at-most-once and is revalidated before provider.
6. Route authorization compares full triple and generation.
7. All Stage-28 contracts and human-only boundaries remain unchanged.

## Intent reconciliation
The user approved automatic Ultragoal execution after these three safety details reach consensus. No additional product scope or Telegram UI is introduced.

## ADR
Decision: shared-inode non-mutating read APIs, digest-verified packaged policy resource, and durable detaching generation fence. Alternatives rejected because they permit torn evidence, missing policy, or post-close sends. Consequence: a small amount of lock/packaging code and focused race tests; production behavior remains fail-closed.

# Revision 30 Binding Addendum
This addendum is normative and changes only the lock-domain, provider linearization, and package digest details of Stage 29.

## Exact evidence lock domains
No reader acquires two subsystem locks simultaneously. The exporter reads one source under its matching shared lock, releases it, then moves to the next; the complete before/after revision-token pass detects cross-source changes.

| Evidence source | Read API | Existing writer inode | Data covered |
|---|---|---|---|
| wizard draft | `WizardStorage.read_locked()` | `WizardStorage._lock` = `.wizard.lock` | one validated selected draft JSON |
| finalized wizard/canonical profile event | `EventStore.read_locked()` in `store.py` | EventStore `.events.lock` | validated event lookup/event JSONL owned by EventStore |
| adaptive lifecycle/delivery/source/authority/config/overlay | `AdaptiveEventStore.read_adaptive_locked()` | adaptive root `.adaptive.lock` | `path`, `source_day_path`, `source_intent_path`, `authority_path`, `config_epoch_path`, `overlay_path` |
| canonical adaptive event/sequence | `CanonicalSequenceJournal.read_locked()` | canonical root `.events.lock` | `canonical_events_path`, `canonical_sequence_path` |
| schedule | `schedule_delivery_read_lock(...)` | `.scheduled-deliveries.lock` | validated schedule ledger/fence/tombstone inventory |

Each new reader uses O_RDONLY|O_NOFOLLOW|O_CLOEXEC, regular-file/0600/owner/link-count/inode verification, LOCK_SH, non-recovering validators, and no filesystem mutation. Corresponding writers open the same initialized inode with O_RDWR|O_NOFOLLOW|O_CLOEXEC and LOCK_EX. Missing/unsafe locks fail closed. Concurrent tests exist for all five domains. The incident map calls only the table API associated with each row; canonical adaptive evidence never uses `.adaptive.lock`, and EventStore evidence never uses WizardStorage’s lock.

## Provider-call linearization
Choose the conservative diagnostic-only design: **hold `DiagnosticHost._admission_lock` from final generation/state/pin validation through reservation, provider invocation, durable receipt/unknown terminalization, and completion of the bounded provider call**. The diagnostic provider call has a hard configured timeout no greater than the remaining session TTL and an absolute test-safe maximum. This intentionally serializes diagnostic sends; operational throughput is irrelevant in the isolated single-owner environment.

Linearization:
- If delivery acquires the lock first, its provider attempt is pre-detach. Close/expiry/stop cannot durably append `detaching` until the attempt returns/throws/times out and its receipt or terminal unknown is durable.
- If detach acquires first, it durably appends `detaching`, increments generation and removes routes while holding the same lock; every later admission rejects with reservation/provider zero.
- There is no in-flight counter or lease outside the lock and therefore no check-to-call gap.

All provider invocation and terminal persistence is inside `async with _admission_lock` with `try/except/finally`. Cancellation is caught, the attempt is terminalized unknown when no durable receipt exists (or audit-pending when receipt exists), then cancellation is re-raised only after state persistence; the lock is released by `finally`. A process crash is recovered from the reservation/receipt ledger using existing no-resend rules before any restart revalidation. Detach waiting on the lock has a bounded host shutdown timeout; timeout does **not** append detaching out of order or force a second send—it leaves the host unavailable, cancels the provider task through the same terminalization path, then retries lock acquisition and cleanup.

Deterministic tests pause: (a) immediately before provider invocation while admission lock is held and assert close has not yet appended detaching; then allow provider terminalization and close; (b) let close acquire first and assert reservation/provider zero; (c) cancel/timeout during provider and assert one terminal row, no retry, then close; (d) restart from reservation/receipt crash states and assert no resend.

`DiagnosticRoleRoute` full-triple `user_id/chat_id/topic_id` comparison and generation propagation remain mandatory.

## Package digest/install scope
The embedded promotion-policy digest is SHA-256 over the **raw bytes returned by `importlib.resources`**, compared before JSON parsing. Only after a byte match is the resource decoded and validated against its closed JSON schema. The release artifact claim is explicitly limited to the wheel used for installation; no sdist-survival claim is made. The hermetic wheel build/inspection/temp-target import test is mandatory and network-disabled.

## Acceptance additions
- Every evidence read synchronizes with the actual writer inode listed above and never holds two subsystem locks.
- Durable `detaching` can never precede the start or completion of a provider attempt that passed admission; detach and the full bounded attempt are mutually exclusive on one lock.
- The installed wheel contains exact raw policy bytes matching the embedded SHA before parsing.


# Revision 31 Binding Addendum
This addendum resolves the remaining Stage-30 findings.

## Overlay and canonical lock corrections
Remove `overlay_path` from `AdaptiveEventStore.read_adaptive_locked()`. Add `OverlayJournal.read_locked()` using the existing `root/.authority-transition.lock` writer inode with O_RDONLY|O_NOFOLLOW|O_CLOEXEC, regular-file/0600/owner/link/inode checks, LOCK_SH, non-recovering validation and no filesystem mutation. Harden the matching OverlayJournal writer open on that same initialized inode with O_RDWR|O_NOFOLLOW|O_CLOEXEC and LOCK_EX. Add a dedicated concurrent overlay writer-vs-reader coherence test and missing/symlink/inode-replacement cases.

Canonical evidence is split into two sequential domains:
- canonical event bytes at `customer_root/wizard/events.jsonl` are read only through `EventStore.read_locked()` on `wizard/.events.lock`;
- canonical sequence bytes are read only through `CanonicalSequenceJournal.read_locked()` on `adaptive_root/.events.lock`.
The exporter never treats one lock as protecting both files, never holds both, and uses before/after tokens plus retry to validate their relation.

The final source-lock table therefore has seven explicit domains: WizardStorage draft, EventStore canonical event, AdaptiveEventStore main/source/authority/config, CanonicalSequenceJournal sequence, OverlayJournal overlay, customer schedule, and profile authority/spec documents. Each uses its actual writer inode.

## Exact diagnostic provider timeout/cancellation
Extend `DiagnosticIsolationSpecV1` with `max_provider_timeout_seconds`, integer 1..30, included in `spec_digest` and all diagnostic capabilities. Preflight requires it valid. Effective timeout at admission is `min(max_provider_timeout_seconds, floor(expires_at_kst-now))`; a non-positive result rejects before reservation.

Add `DiagnosticHost.deliver_with_admission(...)`. It acquires `_admission_lock`, validates session/generation/full pins, then enters `asyncio.timeout(effective_timeout)` and calls the existing coordinator delivery path with internal keyword `propagate_cancel=True`. The lock remains held through reservation, transport/provider call and durable terminal result.

The coordinator’s existing cancellation handler keeps ordinary behavior by default. With `propagate_cancel=True`, it first persists exactly one linked `delivery_unknown` when no durable receipt exists or preserves/appends `audit_pending` when a receipt exists, then re-raises `CancelledError`. No resend is scheduled. `asyncio.TimeoutError` is converted after the same terminal persistence. `DiagnosticHost` releases `_admission_lock` only after that persistence via `finally`; close/expiry/stop then acquires it and appends detaching/cleanup. The parameter is internal and accepted only with a validated diagnostic capability/session; production callers cannot select it.

Tests pin 1-second deterministic fake-clock/fake-provider timeouts, cancellation immediately before provider, during provider, and after receipt, asserting one provider maximum, one terminal chain, no retry, cancellation/timeout propagation to host, then successful detaching cleanup. Spec/capability timeout tampering rejects before rows/provider.
