Error and resiliency model

OpenSIP failures are definitions first, presentation second. Machine consumers (agents, CI, MCP, workers) key on stable codes and orthogonal axes — not on subclass names or message substrings.

Orthogonal axes

| Axis | Values (summary) | Meaning |

|---|---|---|

| source | application / infrastructure / external | Where the failure arose |

| defaultResponsibility | user / tool-author / operator / environment / unknown | Who can act |

| kind | validation, not-found, network, timeout, cancelled, … | What class of failure |

| retry | never / transient / caller-policy | Default retry posture |

| severity | warning / error / fatal | Execution-failure severity (not finding SignalSeverity) |

| exposure | public / redacted / operator-only | How far fields may travel |

| exitClass | configuration / not-found / runtime / cancelled / … | Host-neutral exit bucket |

ToolRunOutcome (passed \| failed \| degraded \| error) is derived by the host from lifecycle phase + credible analysis evidence — never from definition severity alone (ADR-0060 / ADR-0181).

Two control-flow styles

Keep both; unify semantics, not style:

Do not convert sites between styles for migration fashion. SignalEnvelope is findings output, not an error-handling mechanism.

How to add an error

import { createToolError, defineErrorCatalog } from '@opensip-cli/core';

const catalog = defineErrorCatalog(
  { id: '<tool-stable-uuid>', displayName: 'my-tool', packageName: '@scope/my-tool' },
  {
    'MYTOOL.RESOURCE.MISSING': {
      code: 'MYTOOL.RESOURCE.MISSING',
      source: 'application',
      defaultResponsibility: 'user',
      kind: 'not-found',
      retry: 'never',
      severity: 'error',
      exposure: 'public',
      exitClass: 'not-found',
      operatorAction: 'List available resources and retry with a valid name.',
      stability: 'public',
      lifecycle: 'active',
      publicMetadataKeys: ['resourceId'],
    },
  },
);

throw createToolError(catalog.require('MYTOOL.RESOURCE.MISSING'), 'Resource not found', {
  metadata: { resourceId: 'abc' },
});

Normalization and projections

normalizeFailure(unknown) is total (never throws). It produces a FailureEnvelope then:

Exit codes for any throw use mapFailureToExitCode (@opensip-cli/contracts): typed subclass ladder first (ADR-0066), then structural isToolErrorLike brands (duplicate physical @opensip-cli/core), then normalized definition exitClass.

Host reporting

Tools call cli.reportFailure({ error, message?, suggestion?, … }). The host normalizes once and fans out to log, human/JSON, exit code, and diagnostics. Do not pre-stringify and lose structure.

Escaped process failures use a minimal last-resort net (synchronous coded line) — not the full async fan-out. Both uncaughtException and unhandledRejection force-exit after that write so the process cannot resume with undefined state.

Retry and cancellation

Layer ownership

| Layer | Owns |

|---|---|

| core | definitions, envelope, safe diagnostic data, retry primitives, worker failure wire |

| contracts | numeric exit mapping, command outcome shapes |

| cli | effectful fan-out, last-resort net, interrupt coordinator |

| tools / substrates | package-owned catalogs and throw sites |

Core never imports contracts or cli.

Contributor commands

pnpm --filter=@opensip-cli/core test
pnpm error-inventory:ratchet   # temporary Plan 01 no-new-debt (local campaign baseline)
pnpm docs:error-index          # regenerate error-code reference
pnpm docs:error-index:check

Generated inventory evidence and indexes are never hand-edited.

Public API stability

Published error codes and versioned machine failure projections are public contracts:

See ADR-0181 and ADR-0183.