I read this as an advisory architecture task - I’ll trace Task26 authority, deployment, rollback, and pin flows end to end, compare two minimal transaction designs, and return one line-grounded design without edits or live actions.


I read this as an advisory architecture task - I traced the authority and deployment paths and recommend a late, compensating Task26 promotion inside the existing stopped-service transaction.

## Decision

Use a **WAL-protected paired-chain append plus compensating predecessor qualification**.

Do not retrofit the existing V14 package: it explicitly authorizes zero external events and omits authority promotion from its stage list (`scripts/prepare_nutricoach_v150_v14_preseal.py:354-368`). Build this into the next versioned preseal closure.

The transaction order is:

```text
preflight/reserve
stop
snapshot/probe
install/offline smoke/migrate/weekly smoke
append QUALIFY(successor)
regenerate successor candidate + pin credentials
switch unit/drop-in
daemon-reload
start
post-fence including Task26 authority
commit
```

On any failure after promotion:

```text
stop successor
repair any interrupted paired append
append QUALIFY(predecessor)
regenerate predecessor candidate + pin credentials
verify predecessor authorization
restore snapshots/remove successor
daemon-reload
start predecessor
verify predecessor service and authority
mark ROLLED_BACK
```

## Why the current code is insufficient

1. The runtime accepts only the latest non-revoked candidate (`gateway/platforms/task26_runtime_authority.py:146-148`), while a qualification event makes its digest current (`gateway/platforms/task26_candidate_authority.py:140-146,211-221`). Merely restoring unit files after promoting a successor therefore does not restore predecessor authority.

2. Pins bind exact source, genesis, event count, and both chain heads; a stale pin is rejected (`gateway/platforms/task26_runtime_authority.py:76-83,104-126`). The current switch rewrites the candidate credential but copies every other credential, including the old pin, unchanged (`scripts/nutricoach_v150_host_operations.py:127-142`).

3. The paired append has crash windows: registry and ledger are replaced separately (`gateway/platforms/task26_candidate_authority.py:165-166`), then each is rewritten again to add source/genesis metadata (`gateway/platforms/task26_runtime_authority.py:414-424`).

4. Current rollback restores files and restarts the predecessor without restoring external authority (`scripts/nutricoach_v150_sealed_controller.py:94-129`). Authority credentials are not among the snapshot paths; only config, registry, unit, and drop-in are mutable snapshots (`scripts/nutricoach_v150_sealed_target.py:133-150`).

5. An active gateway is not sufficient proof of nutrition authority. Telegram catches authority startup failure and returns disconnected rather than necessarily failing the systemd process (`gateway/platforms/telegram.py:2925-2961`), while the current post-fence checks only service/runtime and file postimages (`scripts/nutricoach_v150_host_operations.py:164-188`).

## Viable designs

### A. WAL-protected existing chains — recommended

Keep the existing registry/ledger schemas and readers. Add a durable transition intent containing exact preimages and postimages, then update both files under the existing exclusive lock. Recovery completes a partially written pair before compensating.

- **Coupling:** confined to Task26 authority and the NutriCoach controller.
- **Migration:** no authority schema migration; existing pins and snapshots remain readable.
- **Testability:** every registry/ledger/pin write can be fault-injected deterministically.
- **Failure mode:** a crashed writer leaves a recoverable intent; readers fail closed until recovery.
- **Cost:** a small WAL/reconciliation state machine.

### B. Immutable event-pair files

Store each registry event and ledger row together in one immutable `events/<sequence>-<hash>.json` file. Derive current authority by enumerating the event directory.

- **Coupling:** changes candidate verification, runtime reads, the directory watcher, final-state sealing, fixtures, and delivered bundles.
- **Migration:** existing paired documents need a one-time import.
- **Testability:** simpler crash model because one rename commits one event.
- **Failure mode:** mixed old/new readers during migration.
- **Cost:** materially larger. Final-state code currently seals the two document hashes and heads (`gateway/platforms/task26_final_state.py:1734-1743,2186-2205`).

Design B is cleaner long-term, but it does not pay for itself for one successor transaction. Choose A.

## Exact changes

### `gateway/platforms/task26_candidate_authority.py`

1. Extract the pure logic in `append_candidate_authority()` into:

```python
prepare_candidate_authority_append(...) -> CandidateAuthorityPostimage
```

The result contains exact previous and next registry/ledger documents and returned heads.

2. Preserve existing `source_id` and `genesis_sha256` in the prepared postimages rather than dropping and re-adding them later.

3. Make `_write()` perform complete writes and fsync the containing directory after replacement. It currently performs one unchecked `os.write` and no directory fsync (`gateway/platforms/task26_candidate_authority.py:45-56`).

4. Keep qualification semantics unchanged. In particular, a non-revoked predecessor may be qualified again, while a revoked digest may not (`gateway/platforms/task26_candidate_authority.py:109-117`).

### `gateway/platforms/task26_runtime_authority.py`

Add:

```python
build_authority_pin(root, verified_state) -> dict[str, object]
reserve_external_transition(binding) -> None
promote_external_candidate(binding, successor_pin, successor_candidate) -> dict[str, object]
restore_external_predecessor(binding, predecessor_pin, predecessor_candidate) -> dict[str, object]
release_external_transition(binding) -> None
```

Modify `append_external_authority()` to use the same implementation.

Protocol:

- Hold `runtime-authority.lock` exclusively, retaining the current reader/writer linearization (`gateway/platforms/task26_runtime_authority.py:394-427`).
- Require source ID and genesis to match the sealed baseline.
- Persist `candidate-authority/.pending-transition.json` containing canonical pre/post documents and a document digest.
- Each live authority document must equal either the recorded preimage or postimage. Mixed pre/post is completed forward; anything else is denied.
- Verify both chains, write credentials, then remove and directory-fsync the WAL.
- `build_authority_pin()` derives `event_count` from the verified registry; no caller-supplied count. Existing fixture code demonstrates the required pin fields but hardcodes the count (`scripts/dualcoach_v111_disposable_fixture.py:265-279`).
- Keep a durable external transition reservation keyed by the sealed `authority_id`; unrelated appends are denied until commit or rollback releases it.

The pin and candidate credential names remain exactly those loaded in production: `task26-authority-pin.json` and `task26-candidate-digest` (`gateway/platforms/task26_runtime_authority.py:357-390`).

### `scripts/nutricoach_v150_sealed_target.py`

Add a frozen `Task26PromotionBinding` to `HostPaths` containing:

```text
authority_root
source_id
genesis_sha256
baseline_registry_head_sha256
baseline_ledger_head_sha256
baseline_event_count
predecessor_candidate_digest
predecessor_historical_pass_digest
successor_candidate_digest
successor_historical_pass_digest
predecessor_pin_source
predecessor_candidate_source
authority_id
```

The external root must be absolute, private, and outside profile/package/candidate/wheel/receipt roots, matching current runtime isolation checks (`gateway/platforms/task26_runtime_authority.py:84-94,372-387`).

### `scripts/nutricoach_v150_concrete_host.py`

Extend `live_target()` to parse the binding; it currently binds only deployment paths and wheels (`scripts/nutricoach_v150_concrete_host.py:162-210`).

Add:

```python
preflight_task26_authority()
promote_task26_authority()
restore_task26_predecessor()
verify_task26_successor_post_fence()
verify_task26_predecessor_restored()
```

Also include the sealed baseline and credential paths in `recovery-manifest.json`, whose purpose is fresh-process recovery (`scripts/nutricoach_v150_concrete_host.py:244-314`).

`restore_task26_predecessor()` must be idempotent against three exact chain states:

| Head | Action |
|---|---|
| Baseline `N`, predecessor current | Promotion never committed; regenerate/verify baseline predecessor credentials only. |
| Expected `N+1`, successor current | Append the one expected predecessor qualification. |
| Expected `N+2`, predecessor current | Compensation already committed; regenerate credentials, append nothing. |
| Anything else | Leave service stopped and mark `RECOVERY_REQUIRED`. |

### `scripts/nutricoach_v150_host_operations.py`

Change:

```python
switch_postimages(paths, candidate_digest, authority_pin_payload)
```

Requirements:

- Require exactly one canonical candidate credential and one canonical authority pin credential.
- Write successor candidate first and the fresh pin last, both `0400`.
- Do not copy the predecessor pin.
- Include both in `_postimages`.
- Extend `post_fence()` to instantiate `FileCandidateAuthoritySource` from the successor pin and successfully authorize the successor without a predecessor snapshot.

The existing candidate-only switch test at `tests/test_nutricoach_v150_v14_safety.py:239-259` must become a candidate-and-pin test.

### `scripts/nutricoach_v150_sealed_controller.py`

Insert after weekly startup smoke and before systemd switch:

```python
journal.advance("AUTHORITY_PROMOTION_INTENT")
host.promote_task26_authority()
journal.advance("AUTHORITY_PROMOTED")
host.switch_systemd()
```

This is the latest safe point: installation and offline checks have completed, but no successor bytes are selectable yet. The present ordering is smoke → switch → reload → start (`scripts/nutricoach_v150_sealed_controller.py:188-199`).

Change `RollbackGuard.rollback()`:

1. Stop any active service.
2. Call `restore_task26_predecessor()`.
3. Restore the ordinary snapshot and remove successor-created paths.
4. Reload/start only when both authority restoration and snapshot restoration succeeded.
5. Verify predecessor authority after restart.
6. Otherwise leave the service stopped and journal `RECOVERY_REQUIRED`.

Keep the existing `COMMITTING + SUCCEEDED` linearization, but recovery must also verify the expected `N+1` successor head and successor pin before finalizing (`scripts/nutricoach_v150_sealed_controller.py:202-205,209-230`).

### Next versioned preseal/verifier

Create the next closure as:

```text
scripts/prepare_nutricoach_v150_v15_preseal.py
scripts/verify_nutricoach_v150_preseal_v15.py
tests/test_nutricoach_v150_v15_safety.py
scripts/rehearse_nutricoach_v150_v15.py
```

The preseal must:

- Read and seal the exact external baseline without appending.
- Require the predecessor to be current and non-revoked.
- Bind the predecessor’s existing qualification PASS digest.
- Write `candidate-verification.json` before target construction and use its SHA-256 as `successor_historical_pass_digest`.
- Declare exactly one success event and, conditionally, one rollback compensation event; replace the current `external_events_authorized: 0` contract (`scripts/prepare_nutricoach_v150_v14_preseal.py:205-229,354-368`).
- Include both authority modules and the promotion tests in the detached controller closure. The bootstrap verifies every closure file before import (`scripts/nutricoach_v150_detached_bootstrap.py:52-77,152-177`).

Changing the authority modules changes the canonical candidate because both are qualification-tool inputs (`gateway/platforms/task26_candidate_derivation.py:81-99,201-214`). Therefore the successor must be rebuilt and requalified; an existing candidate digest cannot be reused.

## Append-only rollback semantics

For baseline count `N`:

```text
N+1: qualify(successor, successor_historical_pass_digest)
N+2: qualify(predecessor, predecessor_historical_pass_digest)  # rollback only
```

Never delete or rewrite historical rows, never restore old authority files, and never restore an old pin.

Do **not** revoke the predecessor during this transaction: revoked digests cannot be requalified (`gateway/platforms/task26_candidate_authority.py:114-117,211-218`). Do not automatically revoke the successor on an operational deployment failure either; after `N+2` it is non-current and therefore unauthorized. A later operator decision may revoke it, followed by another predecessor pin regeneration.

## Why predecessor restoration is authorized

After `N+2`, qualification semantics set the predecessor as current (`gateway/platforms/task26_candidate_authority.py:140-146,211-215`). The regenerated predecessor pin binds the `N+2` heads and count, so startup passes the exact-pin check (`gateway/platforms/task26_runtime_authority.py:123-126`). The predecessor candidate credential selects that same digest, and authorization accepts it as current and non-revoked (`gateway/platforms/task26_runtime_authority.py:146-157`).

Conversely, the successor retains its `N+1` pin. After compensation it is both stale and non-current, so accidental successor restart fails closed.

## Required invariants

- Service is confirmed stopped before the first external authority append.
- No switch or start occurs without a verified successor `N+1` pin.
- Predecessor is not invalidated at baseline.
- Source ID and genesis never change.
- Only the exact `N`, `N+1`, and `N+2` states above are accepted.
- Registry and ledger sequence, predecessor hashes, cross-links, cardinality, and heads remain valid (`gateway/platforms/task26_candidate_authority.py:183-234`).
- Pin count and heads are always derived from the verified chain.
- Authority recovery precedes predecessor restart.
- An authority restoration failure leaves the service stopped.
- Successor credentials are successor-owned and removed by rollback; current cleanup already removes the successor root (`scripts/nutricoach_v150_concrete_host.py:501-509`).
- The transaction ends at the durable `SUCCEEDED` commit marker. Failures after that are a separate rollback transaction, not an ambiguous continuation of this attempt.

No files were edited, tests were run, or live authority/service actions were performed.