Contract surfaces

A contract is a promise to a consumer outside your control. Break it, and the consumer breaks. opensip-cli has six contract surfaces. Knowing what they are tells you what you can change freely (everything else) and what's expensive to change (these).

What you'll understand after this:

- The six surfaces opensip-cli commits to.

- Who consumes each one.

- The stability tier each surface sits at.

- The shape and rationale of each.


The six surfaces

| # | Surface | Consumers | Stability tier | Shape lives in |

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

| 1 | CLI argv (commands and flags) | humans, CI, shells | stable (semver-major) | packages//src/tool.ts |

| 2 | Exit codes | CI, scripts | stable (semver-major) | packages/contracts/src/exit-codes.ts |

| 3 | JSON output (CommandOutcome, with SignalEnvelope under .envelope for tool runs) | CI, dashboards, the gate, OpenSIP Cloud | stable (semver-major) | packages/contracts/src/command-outcome.ts, packages/contracts/src/signal-envelope.ts |

| 4 | SARIF output | GitHub Code Scanning, IDEs | stable (versioned by SARIF spec) | packages/output/src/format/signal-sarif.ts |

| 5 | Tool plugin contract (Tool) | third-party tools | stable (semver-major) | packages/core/src/tools/types.ts |

| 6 | Plugin discovery (opensipTools.kind markers; exact check-package pins; sim scenarios-* scope scan) | third-party tools, check packs, sim packs | stable (semver-major) | packages/core/src/plugins/tool-package-discovery.ts, packages/core/src/plugins/capability-discovery.ts, packages/config/src/capability-preferences.ts |

Anything else — internal types, framework helpers, the recipe registry shape, the language-adapter content-filter API — is internal. It can move between minors. Don't depend on it from outside the workspace; if you do, you're on your own when it shifts.


1. CLI argv

The command tree and flag surface. What opensip --help shows.

opensip
├── fit                    (run fitness checks)
│   ├── --recipe <name>
│   ├── --check <slug>
│   ├── --tags <list>
│   ├── --json
│   ├── --verbose
│   ├── --gate-save        (writes baseline row into .runtime/datastore.sqlite)
│   ├── --gate-compare
│   ├── list               (catalog checks)
│   ├── recipes            (catalog recipes)
│   ├── export --format baseline   (SARIF gate baseline)
│   └── plugin             (manage fit extension packs)
│       ├── list
│       ├── add <pkg>
│       ├── remove <pkg>
│       └── sync
├── sim                    (run simulation scenarios)
│   ├── recipes            (catalog sim recipes)
│   └── plugin             (manage sim scenario packs)
│       ├── list
│       ├── add <pkg>
│       ├── remove <pkg>
│       └── sync
├── graph [paths...]       (static call-graph + dead-end analysis)
│   ├── --json
│   ├── --no-cache
│   ├── --gate-save
│   ├── --gate-compare
│   ├── --workspace             (fan-out across detected workspace units)
│   ├── --concurrency <n>       (cap for --workspace)
│   ├── --language <name>       (force a specific adapter)
│   ├── list               (catalog graph rules)
│   ├── recipes            (catalog graph recipes)
│   ├── lookup <name>      (look up function occurrences)
│   ├── index             (emit symbolindex.json)
│   └── export --format <sarif|catalog|baseline>
├── init                   (scaffold the project)
├── report                 (open the HTML report)
├── sessions
│   ├── list
│   ├── show <ref>
│   └── purge
├── tools
│   ├── list
│   ├── create <tool-id>
│   ├── validate <spec>
│   ├── install <spec>
│   ├── uninstall <name-or-id>
│   └── data-purge <tool-id>
├── configure              (cloud API key)
├── completion             (shell completion script)
└── uninstall              (remove ~/.opensip-cli/)

Each command's flag list is owned by the Tool that registers it. fit flags live in packages/fitness/engine/src/tool.ts; sim flags in packages/simulation/engine/src/tool.ts; graph flags in packages/graph/engine/src/tool.ts; top-level commands like init and configure live in packages/cli/src/commands/. The pack-management plugin group is mounted under each pack-supporting tool (opensip fit plugin …, opensip sim plugin …) — there is no top-level opensip plugin.

Stability rule. Removing a flag, removing a command, or changing a default value is a major-version change. Adding a flag with a safe default is a minor. Adding a command alias is also a minor when it is additive and documented; renaming without an alias is a major.


2. Exit codes

The integer the binary returns when it ends. Defined exactly once in packages/contracts/src/exit-codes.ts:

| Code | Constant | Meaning |

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

| 0 | SUCCESS | Run completed; no failing checks. |

| 1 | RUNTIME_ERROR | Run completed; checks failed (violations found). |

| 2 | CONFIGURATION_ERROR | Run could not start (config invalid, plugin failed to load, baseline missing). |

| 3 | CHECK_NOT_FOUND | Typed NotFoundError surfaced through the shared mapper. Current fit --check <missing> exits 2 because explicit check selection is treated as invalid configuration. |

| 4 | REPORT_FAILED | --report-to upload failed (network error or non-2xx). |

| 5 | PLUGIN_INCOMPATIBLE | A Tool plugin was rejected by the compatibility/trust gate before import. |

CI integrations are the primary consumer. opensip fit && deploy is an idiom; so is opensip fit --gate-compare || (echo "regression" && exit 1).

Stability rule. Adding new codes is a minor change provided the additions stay above 2 (consumers that switch on 0/1/2 continue to work; codes 3, 4, and 5 are reserved for the specialized failure modes documented above). Re-purposing or removing an existing code is a major change. The convention is "0 = green, 1 = red but expected, 2 = red and unexpected" — anything that breaks that mental model breaks consumers.


3. JSON output (CommandOutcome)

The structured stdout when --json is set is a host-stamped outer

CommandOutcome. For signal-producing tool runs (fit, sim, graph, yagni)

the unchanged SignalEnvelope sits under .envelope; command-result data sits

under .data; setup and command failures use .errors. This is the

one-machine-output shape from ADR-0065, layered on top of the signal currency from

ADR-0011.

Shapes live at packages/contracts/src/command-outcome.ts and

packages/contracts/src/signal-envelope.ts:

flowchart LR
  Fit["fit signals"]
  Graph["graph signals"]
  Sim["sim signals"]
  Envelope["SignalEnvelope<br/>@opensip-cli/contracts"]
  Root["CLI composition root"]
  Json["JSON stdout"]
  Sarif["SARIF formatter"]
  Table["human table / Ink"]
  Session["SessionRepo<br/>run history"]
  Gate["fit / graph gate"]
  Cloud["optional cloud sink"]
  Dashboard["report data"]

  Fit --> Envelope
  Graph --> Envelope
  Sim --> Envelope
  Envelope --> Root
  Root --> Outcome["CommandOutcome<br/>.envelope"]
  Outcome --> Json
  Root --> Sarif
  Root --> Table
  Root --> Session
  Root --> Gate
  Root -.-> Cloud
  Session --> Dashboard
  Graph --> Dashboard
interface CommandOutcome<TData = unknown> {
  readonly status: 'ok' | 'error';
  readonly command: string;
  readonly tool?: string;
  readonly envelope?: SignalEnvelope;
  readonly data?: TData;
  readonly errors?: readonly CommandOutcomeError[];
}

interface SignalEnvelope {
  readonly schemaVersion: 2;
  readonly tool: string;                 // ToolShortId; first-party examples: fit/sim/graph/yagni
  readonly recipe?: string;
  readonly runId: string;
  readonly createdAt: string;            // ISO 8601
  readonly verdict: {
    readonly score: number;
    readonly passed: boolean;            // no critical/high signals
    readonly summary: { total: number; passed: number; failed: number; errors: number; warnings: number };
  };
  readonly units: readonly UnitResult[]; // per-unit ran/errored/timing facts
  readonly signals: readonly Signal[];   // the flat findings list (4-level severity)
  readonly resolutionMode?: 'exact' | 'fast'; // graph-only
}

Signal remains the single finding currency (packages/core/src/types/signal.ts):

4-level severity (critical|high|medium|low), category, provider,

fingerprint, fix hint. The schemaVersion: 2 discriminator is part of the

contract. A future minor can add fields; a major can change schemaVersion and

break old consumers.

The full per-field reference (when each field is present, what each value can

be) is in 70-reference/04-json-output-schema.md.

Stability rule. Adding optional fields to CommandOutcome or the nested

SignalEnvelope is a minor change. Adding required fields, removing fields, or

changing types is a major change. Reordering keys within objects is not part of

the contract — consumers must parse, not pattern-match — but in practice the

renderer emits keys in declared order.


4. SARIF output

SARIF 2.1.0 is produced by the single shared formatSignalSarif formatter (packages/output/src/format/signal-sarif.ts) — reached via the cli.writeSarif seam by --report-to, by fit export --format baseline, and by graph --sarif <path> (the gate baselines themselves are stored as signals in SQLite, not SARIF; see surface 3 and the gate doc). Note graph export --format baseline is not a SARIF producer — it exports the graph gate fingerprint baseline as JSON for git-trackable enforcement; graph's SARIF comes from graph --sarif. The shape is the SARIF spec's, not ours — opensip-cli commits to producing valid SARIF 2.1.0, not to a custom shape. Consumers (GitHub Code Scanning, VS Code SARIF Viewer, custom CI tooling) can read these files with any SARIF parser.

Stability rule. The fields opensip-cli fills in are: runs[0].tool.driver.name = 'opensip-cli-<tool>', runs[0].results[] carrying ruleId, message.text, level (critical/higherror; mediumwarning; lownote), and locations[].physicalLocation.{artifactLocation, region}. Future versions may fill in more SARIF fields; we won't stop filling in those.

The gate's identity hash for diff matching is not SARIF-spec — it's an opensip-cli internal: sha256(filePath + '\n' + ruleId + '\n' + message), deliberately excluding line numbers so unrelated line shifts don't register as added/resolved. See packages/fitness/engine/src/baseline-strategy.ts and 10-concepts/05-architecture-gate.md.

Baseline identity (ADR-0075). Every SignalEnvelope carries baselineIdentity: { fingerprintStrategyId, fingerprintStrategyVersion } — the fingerprint strategy that stamped each signal's fingerprint. On --gate-save, the host persists this metadata in tool_baseline_meta alongside the captured rows. On --gate-compare and export --format baseline, the host checks that stored metadata matches the current strategy before diffing fingerprints. A mismatch (missing metadata, strategy id change, or version bump) is a configuration error with recapture guidance: re-run --gate-save. This is separate from ADR-0050 session/tool_state payload __version — baseline identity governs the ratchet plane only.

Concurrent CLI invocations that share one project datastore coordinate writes with datastore-file and per-artifact locks (ADR-0075). Optional env overrides are documented in 70-reference/10-environment-variables.md.


5. The Tool plugin contract

Discussed at length in 02-tool-plugin-model.md. The interface lives at packages/core/src/tools/types.ts:

interface Tool {
  readonly metadata: ToolMetadata;
  readonly commands: readonly ToolCommandDescriptor[];
  readonly commandSpecs?: readonly CommandSpec<unknown, ToolCliContext>[];
  readonly initialize?: () => Promise<void>;
  // optional contribution slots: contributeScope, collectReportData,
  // config, capabilityRegistrars, sessionReplay
}

Plus the ToolCliContext injected into each command handler the host mounts

from commandSpecs. A tool declares commandSpecs and the host mounts them;

artifact exports go through cli.writeArtifact(path, bytes) or a narrower

host-owned seam such as cli.writeSarif. See

ADR-0027.

Stability rule. Adding optional fields to Tool (like initialize?) is a minor change. Adding required fields is a major. Adding methods to ToolCliContext is a minor (existing tools won't call them); removing or renaming methods is a major.

Why this surface is so narrow: every byte of it is a constraint on every Tool author for the lifetime of the contract. The five-field shape is the smallest viable Tool API. If you find yourself wanting a sixth, ask whether it's really a Tool concern or a CLI-side helper.


6. Plugin discovery

opensip-cli discovers third-party packages two different ways depending on what you're shipping:

Tools — explicit marker in package.json

{
  "name": "@yourorg/your-tool",
  "main": "dist/index.js",
  "opensipTools": {
    "kind": "tool",
    "id": "your-tool",
    "identity": { "name": "your-tool" },
    "apiVersion": 1,
    "commands": [{ "name": "your-tool", "description": "Run your tool" }]
  }
}

The kernel's discoverToolPackages walks node_modules looking for the opensipTools.kind === 'tool' marker. Tool packages also carry the static identity manifest shown above, and the package's main entry must export a tool: Tool symbol with the same identity.

Check packs — opensipTools.kind marker

{
  "name": "@opensip-cli/checks-mything",
  "opensipTools": {
    "kind": "fit-pack",
    "targetDomain": "fit-pack",
    "targetDomainApiVersion": 1
  },
  "main": "dist/index.js"
}

Graph adapters use targetDomain: "graph-adapter" with the same epoch fields.

The canonical contract is the opensipTools.kind: "fit-pack" marker plus target-domain epoch (ADR-0007, ADR-0074). The fitness engine discovers marker-declared packs from project node_modules; plugins.checkPackages: can additionally name exact packages that do not declare the marker yet. All first-party @opensip-cli/checks-* packs carry the marker. A pack's main entry must export checks: Check[] (each carrying its own config.icon/config.displayName) and optionally recipes: FitnessRecipe[]. There is no separate checkDisplay export — display travels on the check (§5.3).

For packs published under your own scope, declare the marker or pin the package explicitly in the project config:

plugins:
  checkPackages:
    - '@my-org/fitness-checks'      # exact-name supplement for non-marker packages

Marker-based discovery always runs; plugins.checkPackages: is an exact-name supplement for non-marker packages.

Sim scenario packs

Sim packs are discovered by name-pattern, not by a marker (ADR-0029): any installed <scope>/scenarios-* package under @opensip-cli plus configured plugins.packageScopes is discovered automatically (the simulation tool's manifest declares discovery.mode: "name-pattern" with prefix: "scenarios-"). There is no opensipTools.kind: "sim-pack" marker — sim marker discovery was retired in ADR-0029. Exact-name pins (plugins.scenarioPackages:) and the reproducible project-pinned shape (plugins.sim:, managed by opensip sim plugin add/remove/sync) layer on top.

Stability rules

A tool's project-local plugin layout is described by a PluginLayout descriptor ({ domain, userSubdirs }packages/core/src/plugins/types.ts) that the tool declares on its Tool.pluginLayout and the kernel consumes. The domain is a plain string segment used for path resolution (<project>/opensip-cli/<domain>/<kind>/ and .runtime/plugins/<domain>/), not a package.json kind value. The kernel deliberately does not enumerate the first-party domains — there is no 'fit' | 'sim' | 'lang' union in core (ADR-0009 corollary 1).


What's not a contract

It's worth being explicit about what isn't promised:


What this means for you

If you write a tool, a check pack, or a CI integration:


What's next

You've now seen the four mental-model docs:

Time to go deeper. Section 20-fit/ expands stages 4–8 of the loop with full code paths. Section 30-sim/ does the same for sim. Sections 40+ cover runtime mechanics, subsystems, surfaces, reference, and conventions.