Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion docs/concepts/interaction-pattern-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading