I3320 — A lifecycle command run against an absent or stale executable fails loudly and never falls back to a shell writer
Give the lifecycle commands (session open and close, the provider event shims, checkpoint and handover) one resolution of the Rust executable with a declared lifecycle contract version: with no resolvable executable, or one older than the tree's contract, the command writes a typed provider.event.failed naming the cause, leaves the episode open, exits non-zero (the hook shims stay fail-open for the provider), and nothing falls back to a shell writer.
READY wave 0 · p1 · implementation profile · parallel safe
Ready. Every dependency is done, so majordomus plan start I3320 will be accepted.
Objective
Give the lifecycle commands (session open and close, the provider event shims, checkpoint and handover) one resolution of the Rust executable with a declared lifecycle contract version: with no resolvable executable, or one older than the tree's contract, the command writes a typed provider.event.failed naming the cause, leaves the episode open, exits non-zero (the hook shims stay fail-open for the provider), and nothing falls back to a shell writer.
Why
Split out of I3141 on 2026-10-18 (verification: I3141 was several pieces of work). The critique of a-session-record-says-what-it-served found that after the cutover the closer, the opener and the hooks need the executable, that lib/rust_bin.sh documents Rust-backed features that degraded in silence when it was missing, and that an old binary would close with old semantics. A fallback to shell would be a second writer, so this is part of close-has-one-writer. Sources: chatgpt-majordomus:c109#3.
Current state
lib/rust_bin.sh resolves MAJORDOMUS_BIN, then libexec/, then target/debug, with no contract version; test/lib.sh rust_bin returns 3 and a case skips without an executable; bash test/run.sh --no-skips already fails a declining case.
Desired state
One resolution with a contract check that every lifecycle command calls first; a missing or stale executable is a typed, visible failure that health reports.
Scope
- lib/rust_bin.sh
- lib/common.sh
- apps/majordomus-cli/src/build_identity.rs
- share/events.yaml
Out of scope
- Building the executable from a hook
- Changing the provider's fail-open contract
Dependencies
None. This issue is a root of the graph.
What waits on this
- I3141BLOCKED majordomus session close writes its record through the typed domain's closer, and the shell closer is deleted
- I3142BLOCKED An episode opens through the typed domain and records the task and issue it began on
- I3143BLOCKED Each provider lifecycle event leaves a typed receipt written by the domain before any guard can return early
Acceptance criteria
- The lifecycle contract version is declared once and the executable reports the version it implements
- With MAJORDOMUS_BIN pointing at nothing, and with an executable reporting an older contract, a lifecycle command writes provider.event.failed naming the cause, the episode stays open, and no shell writer runs; a case covers both
- health reports the failed event
- Each new case named here takes its number from `scripts/case-numbers next` and is announced on the board before the file is written (ADR 0053); a case number written in this record before that is provisional
Validation
- bash test/run.sh --no-skips 54_session_lifecycle_hooks 220_exactly_once_close
- cargo test --manifest-path apps/majordomus-cli/Cargo.toml --test session_domain
Evidence required
- case_a_lifecycle_command_refuses_a_stale_executable
Evidence
None recorded. Every token above needs a command or an artifact behind it before this issue can be completed; narrative is refused.
Timeline
- started
- —
- verified
- —
- completed
- —
Those three fields, the evidence above and the state of the dependencies are all the status is made of. There is no status field to disagree with them.
Canonical record: .ai/repo/project/issues/I3320.yaml. Read it back with majordomus plan show I3320.