diff --git a/docs/concepts/interaction-pattern-catalog.md b/docs/concepts/interaction-pattern-catalog.md index 6371c172d..6b9ec333f 100644 --- a/docs/concepts/interaction-pattern-catalog.md +++ b/docs/concepts/interaction-pattern-catalog.md @@ -88,7 +88,7 @@ Map P0/P1 catalog rows to canary archetypes before picking commands: | --- | --- | --- | --- | --- | --- | | Work Routing | IP-001, IP-002, IP-003, IP-007, IP-008, IP-021, IP-029 | Hot-path route canary; Planning governance canary when cadence or repair is involved | `quota should-run`, `interaction_contract`, `work_lane_contract`, scheduler hint, handoff todo state | one eligible delivery fixture, one blocked/fallback fixture, one quiet or monitor fixture | agent turn routing is unsafe: it may spend, wait, notify, or choose fallback incorrectly | | Human Decision | IP-004, IP-014, IP-017, IP-027, IP-030, IP-033 | Scoped decision canary; Product/readiness canary when first-screen human copy changes | user todos, decision scope, operator-gate/reward preview, deferred resume candidates | one concrete user ask, one scoped non-blocking gate, one preview-or-append dry run | humans may be asked the wrong question, or an agent may continue without the needed decision | -| State And Boundary | IP-005, IP-006, IP-011, IP-016, IP-019, IP-020, IP-022, IP-023, IP-025, IP-026, IP-028, IP-031, IP-032, IP-035, IP-036, IP-037 | Projection and boundary canary; Hot-path route canary when the projection feeds quota/status | active state, todo metadata, task graph, authority source, claim lease, completed-work archive, install ownership, connector runtime policy, operation receipt, retired setting projection, public/private scan | fixture state plus structured projection check; boundary scan for touched public files | compact state and executable truth diverge, so dashboards and agents may trust stale or unsafe authority | +| State And Boundary | IP-005, IP-006, IP-011, IP-016, IP-019, IP-020, IP-022, IP-023, IP-025, IP-026, IP-028, IP-031, IP-032, IP-035, IP-036, IP-037, IP-038 | Projection and boundary canary; Hot-path route canary when the projection feeds quota/status | active state, todo metadata, task graph, authority source, claim lease, completed-work archive, install ownership, connector runtime policy, operation receipt, retired setting projection, public/private scan | fixture state plus structured projection check; boundary scan for touched public files | compact state and executable truth diverge, so dashboards and agents may trust stale or unsafe authority | | Evidence Lifecycle | IP-012, IP-015 | Evidence lifecycle canary; Product/readiness canary when evidence is rendered | external handle observation, benchmark lifecycle reducer, compact result projection | compact public-safe evidence fixture with raw-material exclusion assertions | progress evidence may be missing, double-counted, or represented with unsafe raw material | | Planning Governance | IP-010, IP-013, IP-018, IP-024, IP-034 | Planning governance canary; Hot-path route canary when cadence changes affect execution | stalled run history, autonomous replan obligation, repair delta, cadence hint, plan-to-todo writeback | two-turn stalled fixture plus repair/writeback delta assertion | the agent may keep planning in prose while the machine-visible frontier stays unchanged | @@ -346,6 +346,7 @@ Projection, authority, write scope, and lease integrity. | P1 | IP-035 | Install Ownership Is Not An Update Permission | Install lifecycle owner plus user | no silent mutation; report the owning installer and its command | classify the install before mutating it; when LoopX does not own it, hand back the owner-owned command instead of switching install channels | | P1 | IP-036 | A Lost Response Is Not An Absent Commit | Effect dispatcher plus caller | no interruption unless recovery needs a user decision; report the receipt read back | name the write with a stable operation id, recover by readback instead of blind retry, and never leave a committed record pointing at material nobody published | | P1 | IP-037 | A Retired Setting Is Not An Absent Setting | Configuration reader plus migration owner | no interruption; keep the retired entry visible and read-only where it was once configurable | reject the retired activation before any write, carry its retired status and replacement in the projection, and treat clearing it as neither enable nor bootstrap of the replacement | +| P1 | IP-038 | A Generic Fallback Is Not A Typed Diagnosis | Diagnostic publisher plus its reader | no interruption; the failure names the value or check that was refused | ask the typed channel before any fallback, frame the reason channel by the separator the publisher writes, and report an unrecovered reason as missing evidence rather than as the cause | ### Evidence Lifecycle @@ -2760,6 +2761,106 @@ flowchart TD shared-authority RFC's open question on keeping one capture boundary. - `examples/interaction-pattern-catalog-smoke.py` protects this entry. +#### IP-038 A Generic Fallback Is Not A Typed Diagnosis + +**Trigger** + +- a component refuses a configuration, an identity, or a write and publishes a + typed reason, while the caller also holds a catch-all that explains the same + failure by its own observation — an exit status, an empty read, a deadline; +- the caller can only reach the typed reason by parsing something the publisher + framed, so a defect in that parse is silent: nothing reports "the envelope was + there and I could not read it"; +- the operator is about to act on the reported cause: restart a host, respend a + Turn, clear a setting, or retry an operation whose real blocker is a single + malformed value. + +**Expected behavior** + +A fallback earns its place only after the typed channel has been exhausted, and +it must never overwrite a reason that exists. Four rules keep "I could not +recover the cause" from being published as "the cause". + +1. **Ask the typed channel first, and fall back only on an empty answer.** + `_start_runtime` reads the captured startup stderr before it reads the exit + status: a recovered envelope raises + `EffectRuntimeStartupError(message, diagnostic_code=code)`, and only a `None` + answer reaches `runtime_exited_before_ready (exit_code=...)` + (`loopx/control_plane/effect_runtime.py:776-791`). The fallback is a name for + missing evidence, not a diagnosis of the failure. +2. **Frame the reason channel by the separator the publisher writes.** One + envelope is one record. `str.splitlines()` honours U+0085, U+2028 and + U+2029 as line breaks while `JSON.stringify` escapes only code points below + U+0020, so a rejected value quoted back inside its own message can tear the + record apart and every fragment that still starts with `{` fails to parse. + The response reader in the same module already frames on the byte newline + (`loopx/control_plane/effect_runtime.py:588`); a diagnostic channel is the + same obligation. +3. **Reject by code before any mutation, and name the guard in the message.** + The projection writer refuses on identity before it appends, with three + distinct rejections rather than one generic one: `must include object marker` + and `is missing identity fields` before the write + (`loopx/control_plane/runtime/runtime_projection_writer.py:27-31`), and + `append did not pass index readback` for a write that went out and could not + be read back (`:88`). Each says which check fired, so no caller can mistake a + rejected identity for an unavailable backend. +4. **Report a fallback as coverage, never as health.** `not_observed`, + `not_supplied` and `unknown` are the honest shapes for a channel nobody read: + `subagent_context.ts` defaults `live_availability` to `not_observed` + (`loopx/control_plane/subagent_context.ts:31`) and only upgrades + `receipt_observation` when a receipt was actually supplied (`:52`), rather + than inferring either from configured intent, and `agent-context` answers + `host_receipts_observed: false` instead of promoting a configured ceiling + into an execution promise (#5051). A fallback that reads as a clean result is + the same defect wearing a different face. + +This is the read-side twin of IP-036: there a lost response must not be read as +an absent commit, and here a lost *reason* must not be reported as an absent +cause. IP-037 keeps a retired setting legible after a capability change, while +this pattern is about a reason that is fully present and still gets replaced. +IP-005 is the inverse gap — humans see work the machine projection does not — +and IP-033 reads a recorded rejection as authority that exists. None of them +cover the moment a consumer chooses its own explanation over one that was +published for it. + +**Visual Model** + +```mermaid +flowchart TD + A["a component rejects something and publishes a typed reason"] --> B{"can the caller parse the reason channel?"} + B -->|"yes"| C["raise with the publisher's own code"] + B -->|"no, framing broke"| D["generic fallback masks the real cause"] + B -->|"none was published"| E["report not_observed or unknown, never health"] + D --> F["operator restarts a host or respends a Turn for the wrong reason"] + C --> G["operator fixes the named value or scope"] + E --> G +``` + +**Bad smell** + +One error string carries two different meanings: "the runtime refused my +configuration" and "I could not tell what the runtime said". Retries, restarts +and escalations then target the exit code, the guard keeps firing, and the +actionable guidance that was already produced is nowhere in what the operator +sees. + +**Validation** + +- `tests/control_plane/test_effect_runtime_integration.py::test_invalid_idle_timeout_configuration_fails_closed` + drives the real managed Python-to-TypeScript startup boundary and asserts the + publisher's own `invalid_idle_timeout` code plus the absence of a runtime-info + file, so a fallback cannot pass as correct behaviour there. +- `tests/control_plane/test_runtime_projection_writer_guards.py` pins rule 3: + `:75`, `:86` and `:93` each reject before any write on a different identity + defect, `:134` injects the post-write readback failure, and `:124` is the same + fixture left unpatched — which is what proves that negative is not a + self-fulfilling injection. +- `tests/control_plane_ts/agent_context.test.ts` keeps the defaults honest: + `:35` asserts `receipt_observation` stays `not_supplied` on the after-result + phase, and `:210` asserts `live_availability` stays `not_observed` rather than + being inferred from configured intent. +- `examples/interaction-pattern-catalog-smoke.py` protects this entry. + ### Evidence Lifecycle #### IP-012 External Evidence Observation