ADR-0018: Core's host contract: needs, commit and record¶
Status: Accepted Date: 2026-09-28 Deciders: Alex Nodeland
Context¶
ADR-0001 made core sans-IO and ADR-0002 made every change a command. Implementing core (RFC-0001 phase 1) required deciding exactly how a host and core cooperate, which the earlier ADRs left open:
- How does a host know what state to load before core can decide a command, when some commands (accepting a proposal) only reveal what they touch after their first entity is loaded?
- How can core stay deterministic when commands create things that need ids, and when a write policy silently turns an edit into a proposal?
- Where do facts about agent runs (a run started, a tool was called) go, and who may assert them?
- What do identifiers look like to callers?
Decision¶
- A three-step contract.
needs(item, actor=, state=)returns the ids still missing from aState. The host loads them (recording absent entities asNone) and callsneedsagain until nothing is missing.commit(command, state, actor=)then returns aCommitResult(outcome, entities to save, revisions to append, events to log) or raises aRejection. Callingcommitwithout loading whatneedsasked for raisesNotLoaded, a host bug rather than a rejection. - Commands carry every id they create, including the proposal id to use if a write policy turns the change into a proposal. Identical commands against identical state yield identical results, which is what makes the conformance fixtures possible. Adapters fill in ids that clients omit.
- A proposal wraps the command it would execute (
CreateArtifact,EditArtifactorArchiveArtifact). Accepting it runs that command, rebased onto the artifact's current version, with the person's optionalchangespatch layered on top. The resulting change is attributed to the person who accepted it and linked to the proposal. - The recorded patch always reproduces the stored data. When validation normalizes data, or changes are layered on, the event carries a patch computed from the stored before and after states rather than the patch that was submitted.
- Run facts go through
record, a sibling ofcommitforRunStarted,ToolCalled,ToolReturned,RunPaused,RunEndedand applicationAppEvents. Only the thread's own agent, or the system, may record facts about that thread's runs. - Identifiers are plain strings.
ArtifactId,ThreadIdand the others are transparent type aliases that document intent. Generated ids carry a type prefix (thr_…), but any string works.
Options considered¶
State loading¶
| Option | Complexity | Supports dependent loads |
|---|---|---|
An iterative needs loop (chosen) |
Low | Yes |
| Core calls a loader callback | Low | Yes, but makes core do I/O through the back door |
| Hosts load "everything relevant" by convention | Lowest | Only by guessing, and every host guesses differently |
Identifiers¶
| Option | Type safety | Caller friction |
|---|---|---|
| Plain strings with aliases (chosen) | Documentation only | None |
NewType per kind of id |
Checked by pyright | Every literal id must be wrapped, e.g. ThreadId("t1"), in application code and tests |
Trade-off analysis¶
The needs loop keeps every rule about what a command touches inside core, where the conformance fixtures can check it, without core performing I/O. NewType ids caught nothing in practice, since ids flow in from JSON and databases as strings, but they added a wrapper to every call site. pydantic-ai also types its ids as plain strings.
Consequences¶
- Easier: a host is a small loop (
needs, load,commit, save), identical for every storage backend. - Easier: tests and fixtures are deterministic.
- Harder: a host that skips
needsfails loudly withNotLoadedinstead of silently deciding on partial state. That is intended. - The
describe_changehook's base annotation isAnyrather thanSelf, because aSelf-typed parameter cannot be overridden without breaking substitutability; overrides annotatebefore: Self.
Action items¶
- Implement
needs,commit,recordand the conformance suite (RFC-0001 phase 1). - Implement the host loop in
Workspace.commitandWorkspace.record(phase 2).