From 8ae03c53c784ec5ff7207c539653109553a538f1 Mon Sep 17 00:00:00 2001 From: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:05:21 +0800 Subject: [PATCH] docs(catalog): add IP-038 a generic fallback is not a typed diagnosis A caller that cannot recover a published reason has to say it could not recover it. Three merged changes this week showed the same shape from three different seams: a torn diagnostic stream let an exit status stand in for a configuration rejection, a projection writer whose readback guard was under discussion, and host facts that must default to not_observed rather than be inferred from configured intent. IP-038 registers the reusable interaction shape in the State And Boundary family: ask the typed channel before the fallback, frame the reason channel by the separator the publisher writes, reject by code before any mutation, and report an unrecovered reason as missing evidence instead of as the cause. Each rule is anchored to the shipped code and to the tests that already hold it, so the entry is checkable rather than aspirational. Distinguished from IP-036 (transport side), IP-037 (a reason that is present after a capability change), IP-005 (the inverse projection gap) and IP-033 (a recorded rejection as authority). Signed-off-by: NIU-123370 <191000457+NIU-123370@users.noreply.github.com> --- docs/concepts/interaction-pattern-catalog.md | 103 ++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) 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