API stability
github.com/stacklok/mecatl/engine is the importable core of Mecatl (ADR 0036). It ships as its own Go module with a small dependency closure (doublestar, robfig/cron/v3, github.com/goccy/go-yaml, x/net, x/sync, and mvdan.cc/sh/v3; test-only goleak) so external consumers do not pull Mecatl's full require cone — no LLM SDKs, no gRPC, no TUI stack. This page describes what the public surface covers, what is explicitly excluded, how changes are versioned, and how the three enforcement gates catch accidental breaks before they reach a consumer.
The stable surface
The contract covers the exported identifiers of eight core packages:
| Package | Role |
|---|---|
engine/session | the Session aggregate, value objects, the event taxonomy |
engine/governance | permission Effect/Scope/Rule + Evaluator, hook event types |
engine/learning | evidence-backed reflection and learned-skill lifecycle contracts |
engine/tool | Tool/Catalog, FileSystem/Workspace, source port interfaces |
engine/prompt | two-layer prompt assembly + discovery ports |
engine/port | the port interfaces the agent loop consumes |
engine/team | the agent-team domain |
engine/agent | the loop, dispatch, delegation tools, the team Supervisor |
Every exported identifier — const, var, func, type, exported method, exported struct field — in those packages is part of the contract. The authoritative list is arch.CorePackages in engine/arch/surface.go. The API gate derives its guarded set from that constant and asserts equality, so a new core package cannot escape the gate and a removed one does not silently linger.
What the snapshot captures
The committed baselines under engine/api/*.txt are rendered from go/types object strings with two refinements:
- Const VALUES are captured, not just the type. A wire-protocol enum change — an
EventTypestring likeEvApproval = "approval", or aStopReasonlikeStopBudget = "budget"— is a real break for any consumer reading the value off the wire. The baseline records the exact value so that change cannot pass silently. - Only exported struct fields are captured. Unexported fields (mutexes, maps, private sub-structs) are stripped before the snapshot is rendered. Internal layout churn — adding a mutex, reorganizing private state — does not force a baseline update or a CHANGELOG note.
Exported methods and interface methods are enumerated in full.
What is explicitly excluded
| Excluded | Reason |
|---|---|
engine/adapter/* | Reference adapters: test doubles (mockllm, memfs, memstore, permpolicy) and sane defaults (wallclock, nofs, sessnap, memlease) plus the *conformance suites. These ship as offline test infrastructure and default wiring, not as a stable API. Note: the conformance suites' real contract is the port interfaces in engine/port and engine/tool, which ARE guarded — the suites merely exercise them. |
engine/arch | Test-support only: the layering proofs and the arch.CorePackages list. |
Root module (internal/, cmd/, contracts/, perf/) | Outside the engine module boundary (ADR 0036). No external compatibility promise applies. |
The engine/adapter/* exclusion matters in practice: if you embed Mecatl, you may use engine/adapter/mockllm and engine/adapter/memfs in your own tests, but you should treat them as a convenience, not a stable dependency. Their signatures can change in any minor release. The port interfaces those adapters implement — in engine/port and engine/tool — are what the contract actually guarantees.
Versioning discipline
The engine module tags independently from the host repository using the Go submodule convention engine/vX.Y.Z. The host repo's own vX.Y.Z container-image tags are separate.
While v0.x (current)
| Release type | Tag grammar | When used |
|---|---|---|
| Minor | engine/v0.Y+1.0 | Additive changes (new exported identifiers) and breaking changes. Pre-v1 SemVer permits breaking changes in a minor bump; every break must be CHANGELOG-noted and classified. |
| Patch | engine/v0.Y.Z+1 | Bug fixes with no surface change. |
v1.0.0 and beyond
engine/v1.0.0 is cut once the engine/port set settles and the external integration experience is stable. From that point, a breaking change requires a major bump per strict SemVer.
engine/CHANGELOG.md uses Keep a Changelog conventions. Every entry is classified per engine/COMPATIBILITY.md: Added = minor bump; Changed, Deprecated, or Removed = breaking (which is still a minor bump while v0.x).
The three enforcement gates
All three run under task test and in CI on every PR.
1. api-compat (task api:check)
Loads the eight core packages with go/packages, renders each one's exported surface to the stable text format described above, and diffs against the committed baselines in engine/api/*.txt. Any drift — a new field, a renamed method, a changed const value, a removed type — fails with a human-readable diff. The check runs as a named api-compat CI job for a clear signal, and also as part of the normal task test sweep.
The dumper lives in the root module (internal/apicheck) so the engine go.mod stays free of go/tools — importing go/packages would bloat the engine's dependency closure for every consumer.
2. Engine-standalone build (task test:engine-standalone)
Runs cd engine && GOWORK=off go build ./... and go test ./... with the workspace disabled. With GOWORK=off, the engine resolves against its own engine/go.mod and engine/go.sum alone — exactly the view an external go get github.com/stacklok/mecatl/engine consumer would get.
This gate catches two classes of problem: a stray engine → host-repo import (which the depguard allowlist and the DAG test also catch, but the module boundary provides a third enforcement layer), and a missing or inconsistent entry in engine/go.sum.
3. layering_test (engine/arch/layering_test.go)
Enforces the inward-only dependency rule across the whole import graph: transitive direction + cycle detection. This is the check that neither the per-file depguard allowlist (which does not understand transitivity) nor the module boundary alone can fully express. It also owns arch.CorePackages — the single source of truth for the guarded package set — and asserts that the engine/api/*.txt baseline set matches it exactly.
Consumer workflow for an intentional break
When you change a core package's exported API on purpose:
- Run
task api:checklocally (or let CI tell you). The gate fails with a readable surface diff identifying exactly what changed. - Run
task api:updateto regenerate theengine/api/*.txtbaselines. - Commit the changed
engine/api/*.txtfiles alongside your code change. - Add an entry to
engine/CHANGELOG.mdunder## [Unreleased], classified perengine/COMPATIBILITY.md(Added = minor; Changed/Deprecated/Removed = breaking). - In the PR, the reviewer sees the readable
.txtdiff and the CHANGELOG classification together. The break is deliberate, reviewed, and recorded — never silent.
See engine/CHANGELOG.md for the current state of the unreleased surface and the history of versioned changes. Recent examples: v0.4.0 added session.Usage.ReasoningTokens; v0.3.0 added the guardrail approve-once seam (governance.HookOutcome.AskApproval, session.PendingAsk.HookOriginated, port.HookApprovalLearner); v0.1.0 removed agent.WithWritableChildForker and agent.WithSubagentAutoMerge (a breaking change, CHANGELOG-classified accordingly, once the writable Subagent moved to direct-write per ADR 0077).
The go/types object strings the gate renders are stable across Go patch versions. A Go minor version bump may reformat them. When that happens, a one-time task api:update reseeds the baselines — that regeneration is NOT an API change and does not require a CHANGELOG entry.
Event-sourced Load contract
Mecatl persists a session as a snapshot (engine/adapter/sessnap). A host whose system of record is an append-only event log may instead implement port.SessionStore.Load by folding its event stream into a *session.Session. The reference implementation is engine/adapter/eventsource.Fold; ADR 0038 records the design decision.
What a fold MUST populate vs. what is safe to lose
| Field | Round-trip obligation | Event source |
|---|---|---|
Conversation (user prompts, assistant text, tool calls, tool results — tool-pairing-valid) | MUST | EvUserPrompt (user-role turns — genuine prompt + harness continuations), EvMessageDelta (assistant text), EvToolCall, EvToolResult; pre-compaction head from EvCompactionArchive |
State (idle / running / awaiting / completed / failed / cancelled) | MUST | Derived from the terminal EvResult.Stop; a trailing unanswered EvPermissionAsk → awaiting; no terminal → idle |
| Recorded stop reason | MUST | EvResult.Stop |
PendingAsk (when awaiting) | MUST | The trailing EvPermissionAsk with no following EvApproval or EvResult |
Cumulative Usage | MUST | The sum of every per-run EvResult.Usage (each is per-run; the token-budget brake reads the cumulative aggregate) |
| Title, title provenance, title generation, and title revision | MUST | Authoritative values from eventsource.SessionMeta; an empty legacy title falls back to the first genuine EvUserPrompt |
Creation metadata: id, mode, limits, exact EnvironmentRef, placement metadata, profile, provider/model selector, reasoning effort, session kind and relationship, owner, authority, external binding, createdAt | MUST (supplied out-of-band) | Not in any event — provided by the caller via eventsource.SessionMeta |
Counters (turns / tool calls / consecutive failures) | Run-scoped — reflect the latest run segment (reset on Reopen) | EvTurnStart (turns), EvToolResult (tool calls, consecutive failures) |
| Run plumbing: diagnostics binding, askID serials, context | Safe to lose — rebuilt fresh | n/a |
Creation metadata is not in events. No event carries the session id, limits, exact environment identity, placement metadata, profile, provider/model selector, reasoning effort, title metadata, identity labels, or creation timestamp. There is deliberately no EvSessionCreated event (ADR 0038 notes it as a possible future extension). The caller who created the session supplies this data alongside the stream via eventsource.SessionMeta.
User-role turns — both the genuine client prompt and harness-authored synthetic continuations (no-progress nudge, background-pending nudge, background-completion notice) — are event-carried via the log-only EvUserPrompt event. A fold therefore reconstructs the complete conversation in stream order.
Replay-fidelity limitation
The conversation a fold rebuilds is complete except for provider-private opaque replay fields. Three fields reach the conversation only via session.Session.RecordAssistant in the agent loop and are never emitted on the event stream:
Message.Reasoning— the provider reasoning replay blob (OpenAI encrypted reasoning content; Anthropic(thinking, signature))Message.ProviderPhase— the OpenAI Responses phase markerToolCall.ItemID— the provider-assigned item id
The EvReasoningDelta event carries a human-readable reasoning summary; the loop deliberately never places that on Message.Reasoning, and a fold must not either.
A session reconstructed by folding Mecatl's own event stream is therefore byte-identical-replay faithful only for providers that do not use those fields. It replays cleanly for plain-chat providers (e.g. the mock provider) but not for a reasoning provider whose Reasoning/ProviderPhase/ItemID would be empty where the snapshot carries them. This is why Mecatl's own resume uses the snapshot, which carries those fields. A fold is the right implementation for event-log-SoR hosts that accept this boundary or carry those fields in their own richer event schema. It is a documented contract limitation, not a bug.
What's next
- Embed the engine —
go get github.com/stacklok/mecatl/engine, its small dependency closure, and what's importable. - The agent loop — how the engine runs turns, dispatches tools, and emits the event stream.
- Extension points — implement a port interface (
port.LLMProvider,port.SessionStore,port.PermissionPolicy, and others) to replace any capability. - Deployment decision — choosing between
mecatedand the embedded engine library. engine/CHANGELOG.md— the full history of versioned API changes.