DECISIONS.md

The project's decision log. Two kinds of entry, in one file, newest-in-each-section-last:

  • Architecture Decisions (ADR-NNN) — Nygard format (Status / Context / Decision / Consequences) plus a required Spec impact field. These originate from design sessions with the human and are the authoritative record of why SPEC.md says what it says. Supersede, never delete: a later ADR that changes course adds a new entry and marks the old one Superseded, it does not edit or remove it.
  • Implementation Decisions — small, spec-invisible choices made while building, recorded per SPEC.md §12 ("Decide-and-record when: something small is underspecified"). Dated entries, newest last. These do not get ADR numbers; they document internals, not contracts.

All new entries — of either kind — follow this convention. See PROCESS.md for how an entry gets here (chat proposes, repo decides).


Architecture Decisions

Seeded from the 2026-08-14 design session (DECISIONS-SEED.md, a session artifact — see PROCESS.md's mechanics section — not committed). Numbering preserved from the seed.

ADR-001: Go runner, single static binary

Status: Accepted Context: Distribution wedge is a 60-second install; contribution surface is adapters (any language, process-isolated), not the runner. Decision: Go 1.22+, single binary. Deps limited to modernc.org/sqlite (pure-Go, no cgo), santhosh-tekuri/jsonschema/v5, gopkg.in/yaml.v3. Consequences: Adapter contributors never touch the runner; runner contributors are few by design. Spec impact: Already in spec §2. No change.

ADR-002: The ledger is the bus

Status: Accepted Context: n8n-style "every step forwards all fields" breaks at scale; steps need only projections. Decision: Steps read projections from and write facts to SQLite; any stream carries only identity keys + control. Two-layer schema: identity layer (identities, field_values append-only, relations) is durable/cross-run cache; run layer (runs, run_records, step_events, costs, deliveries with UNIQUE target+idempotency) is per-execution. Consequences: Cache-aware waterfalls, SQL segmentation over history+treatment, resume, receipts — all fall out of the schema. Spec impact: Already in spec §3. No change.

ADR-003: Projection ships as a SQL VIEW

Status: Accepted (audit fix; supersedes projection-in-Go-only) Context: Query examples referenced a current_fields relation that didn't exist; projection logic lived only in internal/ledger/project.go, so query-land and runner-land could drift. Decision: Define the current-value projection (highest-confidence within freshness window, newest wins ties) as a SQL view created with the schema. Runner and gtme query both use it. One definition. Spec impact: AMEND — ledger section gains the view DDL; query examples corrected to use it.

ADR-004: uses: — dynamic needs for AI steps

Status: Accepted (audit fix). Partially superseded by ADR-019, which generalizes this from an AI-only mechanism to a general needs: dynamic concept with a second instance (deliver's variables:) — the uses: mechanics here are unchanged, only the framing broadens. Context: AI step prompts reference arbitrary fields; static manifest needs can't know them, so plan-time validation silently skipped the riskiest steps. Decision: AI adapters accept uses: [field, ...] in step config. Runner treats uses as the step's needs for projection and plan validation. A prompt referencing a field not in uses (or a uses field no upstream step provides) is a plan error. Spec impact: AMEND — manifest/config section + plan semantics.

ADR-005: Pipe syntax dropped from v0 entirely

Status: Accepted. Supersedes (in order): multi-process pipe chaining as M3 → inline gtme x '<a>|<b>' expression form → both. Context: The v0 hypotheses to falsify are (a) ledger/runner semantics, (b) adapters writable against the protocol, (c) Claude can build and operate the system. Pipe syntax tests none of them — it is a presentation of the pipeline object — and it was the recurring source of spec inconsistencies. Decision: v0 CLI surface is exactly: init, secret, plan, run (--resume), query, show, runs, freeze, help --agent. No gtme x, no multi-process pipes, no standalone source/filter/enrich/compose/deliver subcommands (they existed only for pipe mode — cull them). YAML is the only pipeline authoring surface. One constraint on implementation: nothing may couple steps to shared in-process memory in a way that precludes a future stdio transport; do not build the transport abstraction, just don't destroy the seam. Consequences: M3 (pipe mode) is deleted; milestones renumber. Validation campaign debugs one execution path. Pipes return post-v0, if at all, as a transport over the same step executor (see ROADMAP.md). Spec impact: AMEND — CLI surface section, §8 pipe-mode mechanics deleted, milestones restructured, non-goals gains: "pipe syntax deferred; all pipeline surfaces compile to the pipeline object."

ADR-006: gtme show — standalone read-only projection inspector

Status: Accepted (mid-stream tap form dies with ADR-005; standalone form survives) Decision: gtme show <identity-key> and gtme show --run last print full ledger projection with --fields/--provenance/--limit. Strictly read-only; never appears in freeze output. Spec impact: AMEND — add verb to CLI surface (lands in what was M4).

ADR-007: gtme help --agent — self-describing surface

Status: Accepted Context: The LLM's real interface is whatever document about the CLI is in its context; that document must be generated, never hand-maintained. Decision: gtme help --agent emits the full surface — verbs, flags, every installed adapter manifest (needs/provides), 3 canonical examples — as one compact (~1–2k token) machine-readable doc regenerated from the registry. Spec impact: AMEND — add to CLI surface + acceptance criterion (doc must round-trip: an agent given only this doc can author a valid pipeline).

ADR-008: expand role — named and deferred

Status: Accepted (deferred post-v0) Context: Mid-pipe apollo/people-at-company violates "sources receive no RECORDs"; it's really record-in → N records (possibly different entity_type) out, writing relations. Decision: Name the role expand, define nothing further in v0, park in ROADMAP.md with the open question: run-membership semantics when entity type switches mid-run. Spec impact: ROADMAP entry only; spec non-goals mentions it by name.

ADR-009: Webhooks/batch via spool + cron; no daemon

Status: Accepted Decision: Event-driven operation = commodity receiver (Worker/Zapier/Action) appends payloads to a spool; scheduled gtme run drains it via a webhook/source adapter (near-clone of csv/source). Deliveries-table idempotency absorbs at-least-once redelivery structurally. Per-event low latency is explicitly out of scope for v0. Spec impact: AMEND — add webhook/source to adapter list, document the pattern as a recipe, keep "no daemon" in non-goals with this as the stated answer.

ADR-010: Spec-as-canon methodology

Status: Accepted Decision: SPEC.md is the single source of truth for observable behavior: CLI surface, exit codes, JSON output schemas, error structure, ledger DDL, identity-key derivation, wire protocol, manifest schema, projection/freshness semantics, idempotency guarantees, behavioral invariants, acceptance criteria. Implementation (packages, concurrency, naming, internals) belongs to Claude Code, recorded in DECISIONS.md when non-obvious. Litmus: "would a second clean-room implementation need this to interoperate?" Spec-visible changes require a proposed spec diff + human approval BEFORE code; spec-invisible decisions are autonomous. Machine-checkable artifacts (JSON Schemas, ledger.sql incl. the ADR-003 view, golden wire transcripts, acceptance scripts) live in spec/ and are loaded directly by the test suite. Code that diverges from spec is a bug even if it works. Spec impact: Governs everything; encoded in CLAUDE.md.

ADR-011: Documentation formats

Status: Accepted Decision: ADRs in a single DECISIONS.md (numbered entries, Nygard format + a "Spec impact" field; supersede, never delete). RFC 2119 keywords in SPEC.md normative sections, declared once. Acceptance criteria in Given/When/Then sentence form (no Cucumber tooling). Keep-a-Changelog section at the bottom of SPEC.md. Principles stay freeform prose. Spec impact: SPEC.md gains RFC 2119 declaration + Changelog section.

ADR-012: Principles and stories placement

Status: Accepted Decision: Principles = SPEC.md §0 preamble, explicitly non-normative (guides proposals, never overrides a MUST; ~1 page max; only principles that settled a real argument). The eight operator stories (launch, top-up, interrogate, iterate, segment, guard, recover, report) appear twice with different jobs: invariant + Given/When/Then acceptance criteria in SPEC.md (normative); enactment scripts in VALIDATION.md (non-normative, living). Spec impact: AMEND — add §0 and story acceptance sections.

ADR-014: Chat ↔ Claude Code contract

Status: Accepted Decision: "Nothing is decided until it's in the repo." Chat = design organ (proposes; each session ends in a session packet: ADR entries + spec diffs + roadmap items). Repo = canon. Claude Code = implementer + conformance checker (builds from spec, proposes amendments, never silently diverges). Human = sole approver of spec-visible change. Encoded in PROCESS.md. Spec impact: None (process).

ADR-015: Instruction vs knowledge

Status: Accepted Decision: Instruction = how to act (CLAUDE.md, skill files) — thin, procedural, under a page. Knowledge = what's true (SPEC.md, DECISIONS.md, principles, stories) — consulted, can be long. The spec is knowledge with normative force; the instruction pointing at it is one line. Spec impact: Shapes CLAUDE.md authoring.

ADR-016: Next milestone after reconciliation = the validation campaign

Status: Accepted. Superseded in part by ADR-019, which renames the first campaign "campaign zero" and gives it a smaller shape (see ADR-019); this entry's ~50-record Apollo funnel survives as a second, later campaign once campaign zero has run clean. Context: Code exists but nothing has run a real campaign; the untested-ness is the main risk, not documentation. Decision: After spec reconciliation and divergence audit, the priority is VALIDATION.md's first campaign: ~50 real records, real Apollo pull, AI compose, deliver to a controlled Instantly campaign; enact the eight stories in miniature (kill/resume mid-run, Monday top-up proving dedupe + cache hits, interrogate one record, read the cost receipt). Spec impact: VALIDATION.md created; spec untouched by outcomes until amendments are proposed.


Session packet, 2026-08-15 (canonical vocabulary & edge contracts)

Second design-session packet (DECISIONS-SEED-2.md, a session artifact, not committed — same as the first). Numbering continues from ADR-016.

Scope note (2026-08-15): on receiving this packet, the human scoped its application: ADR-017/018/019 are recorded below as decided, and VALIDATION.md was rewritten per ADR-019 (see "campaign zero" there). Their Spec impact was deliberately not applied in that docs-only pass, and none of the mechanics they describe (a field registry, columns:/variables: edge mappings, needs: dynamic, the on_missing policy, the dry/armed gate) existed in code — a separate, larger reconciliation-plus-build pass, scoped explicitly as future work. Update (2026-08-15, later the same day): that pass ran on the human's instruction. The Spec impact of all three ADRs was applied to SPEC.md (changelog v0.4, milestone M7), together with ADR-020 below (Accepted after review) resolving the identity-normalization gap flagged under ADR-017; the M7 mechanics were then built — make check green, including offline e2e acceptance of the campaign-zero shape — and VALIDATION.md's campaign zero is now marked runnable, still human-gated as ever.

ADR-017: Canonical field registry (the shared vocabulary)

Status: Accepted (spec impact deferred — see scope note above) Context: needs/provides matching is string equality; it is only meaningful if adapters agree on field names. Singer standardized the protocol but never the vocabulary — every tap emitted its own shapes, so composability never materialized. gtme must not inherit that failure. The registry is also what makes AI-generated adapters trustworthy: codegen targets a closed vocabulary with conformance tests instead of inventing names. Decision: A canonical field registry per entity type lives in spec/fields/<entity_type>.json. Each entry: name, type, format, normalization rule, value domain (enum where applicable), example. Scope rule — a field is canonical when it crosses an adapter boundary; three tiers:

  1. Identity fields (mandatory): person = email, linkedin_slug, first_name, last_name; company = company_domain, company_name. These back identity-key derivation; their normalization rules (email lowercased, company_domain reduced to eTLD+1) are part of the registry and were previously implicit in the key-derivation spec — make them explicit and shared.
  2. Canonical core: any field that (a) ≥2 adapters provide, (b) is waterfall/dedupe-relevant, or (c) is commonly consumed by compose/deliver steps. Seed by one-time curation of the overlap across major B2B providers (Apollo, Clearbit, PDL, ZoomInfo class) — expect ~40–60 fields. Canonical fields declare canonical VALUE domains too (e.g. seniority is a fixed enum; employee_count is an integer, never a range string) — without value normalization, waterfalls compare incomparables.
  3. Vendor namespace: everything else as <vendor>.<field> (e.g. apollo.intent_score). Stored with provenance, queryable, usable in uses:. Namespaced fields in a pipeline's needs make vendor coupling visible; plan notes it. Promotion: namespaced → core when a second adapter provides the same fact ("rule of two"). Additive changes = one ADR line, non-breaking. Renames = breaking, spec amendment + version bump. Enforcement, three layers: (1) manifest validation — needs/provides/uses entries must exist in the registry or be namespaced; (2) runtime — RECORD output validated against provides including normalization/value domains; (3) an adapter conformance kit — golden vendor-payload fixtures in, expected canonical records out — which the adapter-authoring skill targets, making "generate adapter" a generate→test→fix loop with a machine-checkable finish. Consequences: Adapters map vendor dialect → canonical at their own boundary; nobody downstream thinks about mappings. Waterfalls work without configuration. Registry starts small and grows by demand, never by design session. Spec impact: AMEND (not yet applied) — new registry section; spec/fields/ artifacts; manifest validation rules; conformance-kit requirement added to adapter authoring section; identity-key normalization rules cross-referenced to registry.

Known gap to resolve when this is built (flagged 2026-08-15, not yet an ADR): person identity fields need at least one more normalization rule than SPEC §4 currently has, and possibly two more key tiers.

  • linkedin_slug has two incompatible shapes in the wild. A provider's linkedin_url-shaped field can be the public vanity URL (linkedin.com/in/jane-doe) or an internal/member-ID form (opaque, Sales-Navigator-style). §4's normalization (strip protocol/host/trailing slash/query, lowercase) silently produces two different strings for the same real person depending on which shape arrived — a dedup failure, not a caught error. Unlike a weak→strong key upgrade (§4's existing mechanism), this is two values within the same tier that are secretly the same identifier. The registry needs either a normalization rule that detects and resolves the internal form (plausibly requiring an enrichment call — Harvest or similar — before the field is trustworthy as key material), or a rule that refuses to key on an unresolved internal-form URL at all until something resolves it.
  • github_username and twitter_handle are plausible additional person identity tiers — both globally unique, public, low-collision, likely ranking below linkedin_slug (LinkedIn stays primary for B2B) but above the name-hash fallback. No v0 adapter provides either field yet, so this is speculative until one does — not urgent, but worth reserving the tier ordering for now so it doesn't get designed around later.

ADR-018: Mapping exists only at the edges

Status: Accepted (spec impact deferred — see scope note above). Escape-hatch note (2026-08-16): the code-transform escape hatch for computed fields named below is superseded in practice by sql/enrich / sql/filter (ADR-027), which give computed fields a declarative, plan-validatable home. Context: The minimal pipeline (csv/source → instantly/add-to-campaign) exposes two foreign vocabularies: CSV headers (user's world) and campaign merge fields (Instantly template author's world). Neither is gtme's; the interior is. Decision: Exactly two mapping sites, both declarative, both in step config:

  • Ingress: csv/source takes columns: mapping headers → canonical names. Headers already matching canonical names auto-map with zero config; near-misses are SUGGESTED in plan output, never silently guessed. A mapping that yields no identity-key path (person: no email) is a plan error. Normalization per the registry happens at ingress; invalid values are per-record verdicts, not crashes.
  • Egress: instantly/add-to-campaign (and deliver adapters generally) take variables: mapping canonical/namespaced ledger fields → the target's arbitrary merge-field names. No interior step may carry a mapping block. Code transforms are a named escape hatch for COMPUTED fields only (e.g. splitting full_name), not a mapping mechanism — declarative mappings are plan-validatable; code is opaque to plan. Consequences: Pipelines stay portable; mapping burden sits with the two parties who own the foreign vocabularies; plan can prove edge-to-edge coherence before any row is read. Spec impact: AMEND (not yet applied) — csv/source and deliver-adapter config schemas; plan semantics (auto-map + suggestion behavior; identity-path check).

ADR-019: Dynamic needs generalized (supersedes ADR-004's AI-only framing)

Status: Accepted (spec impact deferred — see scope note above). Partially supersedes ADR-004: the mechanics uses: established for AI steps are unchanged; this generalizes the concept to a second instance (deliver's variables:) rather than replacing anything ADR-004 built. Context: ADR-004 gave AI steps uses: because prompts reference arbitrary fields. The deliver case revealed this is the general pattern: any step whose contract is defined by external user-authored content (a prompt, a campaign template) has needs unknowable to a static manifest. Decision: A manifest may declare needs: dynamic, in which case the step's effective needs are derived from config: uses: for AI steps, the values of variables: for deliver steps. The runner projects exactly those fields; plan validates each against upstream provides; a referenced field nothing provides is a plan error. Mechanics of ADR-004 unchanged — this renames the concept from an AI special case to a general one with (currently) two instances. Additionally: per-record completeness at deliver time is a runtime contract with explicit policy on_missing: skip | fail, default skip with verdict — blank merge fields must never send. Skipped records appear in the receipt with reasons. Dry-run receipts for deliver steps render the RESOLVED variables per record (the approval artifact a human reviews before arming). Consequences: One mechanism instead of two; the minimal CSV→send pipeline exercises the full contract spine (identity, registry, both edge mappings, dynamic needs, plan coherence, per-record verdicts, idempotent delivery, armed gate) with two adapters and zero enrichment spend — making it the correct first validation campaign shape. Spec impact: AMEND (not yet applied) — manifest schema (needs: dynamic); plan semantics; deliver runtime policy + receipt format; VALIDATION.md gains the CSV→send pipeline as campaign zero, run dry → review resolved-variables receipt → arm at ~10 records into a controlled campaign → re-run same CSV to prove zero duplicate deliveries. This VALIDATION.md change was applied (2026-08-15) even though the manifest/plan/runtime mechanics it depends on were not — campaign zero is documented as blocked on that follow-up work, not as runnable today.

Registry seeding note (implementation guidance, not an ADR)

Seed spec/fields/person.json and spec/fields/company.json from the fields the v0 adapters actually touch plus the curated cross-provider overlap; do not exceed ~60 entries in the first pass. Every entry must have a normalization rule and (where comparability matters) a value domain. When in doubt, leave a field namespaced — promotion is cheap, demotion is breaking. Executed 2026-08-15 (37 entries: 31 person, 6 company). Per the human's direction during the apply pass, the v0 seed declares no value domains — ADR-017's seniority-enum example notwithstanding — because no field yet shows real cross-provider convergence; the enum mechanism remains in the registry schema for when one does. Exact canonical types (integer-never-range) apply from day one.

ADR-020: Identity-tier amendments — internal-form LinkedIn URLs, reserved handle tiers

Status: Accepted (2026-08-15 — proposed by this reconciliation pass to resolve the "known gap" flagged under ADR-017; human-approved same day after two revisions: the shape split into explicit URL fields, and the one-of needs corollary) Context: ADR-017's known-gap note: a provider's linkedin_url field can carry the public vanity URL or an internal/member-ID form (opaque member-id slug, Sales-Navigator path). §4's strip-and-lowercase normalization silently produces two different keys for the same real person — a dedup failure within one tier, which the weak→strong upgrade mechanism cannot catch. Separately, github_username and twitter_handle are plausible additional person identity tiers worth reserving now so the ordering isn't designed around later. Decision: (1) The observable URL shapes are explicitly distinct registry fields, so they can never collide under one name: linkedin_url admits the public vanity URL only (its normalization rule rejects any other shape as invalid), linkedin_internal_url holds an internal-form profile URL, linkedin_sales_nav_url a Sales Navigator URL — each stored as the URL it is, case preserved, never reinterpreted as an extracted "member ID" (gtme distinguishes shapes; it does not claim to know LinkedIn's identifier semantics). Adapters classify at their own boundary (sales/… path → sales-nav; opaque acwaa/acoaa-prefixed token after in//pub/, or profile/talent paths → internal; otherwise public) and emit the matching field. Neither non-public field is key material in v0: v0 never merges identities, so keying on a non-public shape would permanently fork a person who later arrives under the public form, whereas falling through to a weaker tier converges via §4's existing upgrade path once an enrichment resolves the profile and writes linkedin_url. Resolution-by-enrichment (the other option the gap note named) is thus the recovery path, not a v0 build item. (2) Person identity tiers become: email > public LinkedIn slug > gh: github_username > tw: twitter_handle

name-hash. The handle tiers are implemented in key derivation and listed in the registry as reserved: true — no v0 adapter provides either field; normalization is the registry's handle rule. Consequences: An internal-form-only record keys on a weaker tier (possibly name-hash) until something resolves the public profile — correct but weaker dedupe, visible in the ledger rather than silently forked. Adapters providing github/twitter handles later slot into a fixed ordering. Splitting the shapes surfaced a needs-model gap: harvest/profile needs at least one LinkedIn URL shape, which a flat required list cannot say — hence one-of (anyOf) needs in the planner's contract walk (§7), with harvest re-contracted to accept any shape and provide the resolved public linkedin_url (the recovery path made concrete). Spec impact: AMEND (applied and approved in the v0.4 pass) — §4 tier list, shape-split rule, reserved-tier prose; §7 one-of needs; §10.4 harvest re-contract; registry entries in spec/fields/person.json.


Session packet, 2026-08-16 (groups: the association primitive)

Produced by a design conversation during the incremental validation campaign (campaign zero and its widenings — see VALIDATION.md's campaign log). The trigger: campaign zero exposed that delivery dedupe scopes to the adapter rather than any chosen scope, and that filter verdicts scope to the run while their meaning is campaign-relative — both symptoms of a missing model layer between durable facts and per-run bookkeeping.

ADR-021: Groups — a named association between identities and a context

Status: Accepted (2026-08-16 — proposed and iterated in a design conversation the same day: segments-redundancy, filter-orthogonality, nondeterminism, and after-the-fact-grouping objections each tested and absorbed; human-approved. Spec impact not yet applied — a separate reconciliation-plus-build pass, like ADR-017/018/019's. Update, later the same day: that pass ran as milestone M9 on the human's instruction — spec impact applied as SPEC v0.7 and built, make check green; see the M9 implementation-decision entry.) Naming note: groups is verified safe unquoted as a SQLite table name (the reserved word is GROUP; joins and GROUP BY against a groups table coexist cleanly, tested on 3.43). MySQL-class engines reserve GROUPS; irrelevant to v0's SQLite-only contract (§2) and an internal concern for any future hosted store. The concept name was also weighed against the GROUP BY homonym and kept: Unix/IAM groups — policy-bearing asserted membership, exactly this feature's shape — have coexisted with SQL aggregation in the same users' heads for decades, and the alternatives each import a worse wrong meaning (cohort: time-bucketed analytics; list: static, ESP-collides; audience: re-narrows to delivery; set: harder SQL collision). One docs discipline follows: never use bare "group" as a verb meaning aggregate; the word belongs to the feature. Context: The ledger models durable facts (identities/field_values) and execution receipts (runs/run_records) crisply, but everything in between — campaign membership, suppression lists, qualified pools, touch history — is either smeared across config strings and external tools or accidentally hardcoded: deliveries dedupes per adapter (not per chosen scope), and filter verdicts persist per run while their meaning is campaign-relative (so a top-up re-judges — and, the judge being an LLM, possibly re-rolls — people the same campaign already decided on). Every tool in the category has this association layer in fragments (lists, audiences, campaign membership, suppression lists), each hardcoding one flavor with one policy; the general case is one primitive. Decision: A group is a named association between identities and a context: groups(id, name UNIQUE, note, created_at) plus an append-only group_events(id, group_id, identity_id, event, detail, run_id, created_at) with exactly three event kinds — added/removed (membership, provenance in detail) and touched (a delivery under this group's banner). Current membership is a view over added/removed (the ADR-003 append-then-derive pattern). Members are identities, so groups hold people and companies alike. Groups carry no type field and no executable logic: a group's character (campaign-like, DNC-list-like, pool-like) is derived from its events and the pipelines that reference it — a stored type would be an assertion no behavior backs.

Groups participate in pipelines at both ends, plus two gates — all runner-owned semantics (adapters see only projections, never the ledger), all plan-validated:

  • Terminus: a pipeline may end in group membership instead of (or in addition to) an external deliver — records that complete the run are added. This is the recommended campaign decomposition: a qualify pipeline (source → enrich → filter ⇒ group) runs cheaply and often; the group is a durable, reviewable, hand-editable artifact; a separate send pipeline consumes it deliberately. The judgment-to-money gap gets a human-inspectable checkpoint, per the plan-gate principle.
  • Source: a group can be a pipeline source — members projected from the ledger like any record. (The easy, extensional half of ROADMAP's "segments as sources"; a filter step remains role-agnostic about where its records came from.)
  • require: <group> / exclude: <group> on any step — membership as a plan-checkable gate. Exclusion is also the judgment-memory mechanism: a qualify pipeline that excludes its own output groups (exclude: [q3-qualified, q3-rejected]) sends only never-judged records to the AI filter, so each identity is judged once per scope. This is a determinism device before a cost one — an LLM judge is stochastic run to run, and set membership freezes its first answer as a recorded decision; re-judging becomes a deliberate act (remove from the group, change the prompt), never an accident of re-running. Plain set arithmetic, inspectable with gtme query, replaces any judgment-cache mechanism. (A remember:-style cache consulting judgment events was considered and dropped as redundant sugar over exclude-and-add.)
  • record: <group> on a deliver step — successful deliveries append touched; defaults to the pipeline name, so every pipeline is safely scoped by default and sharing a scope across pipelines is an explicit override.
  • suppress: {group: <g>, within: Nd} on a deliver step — skip records with a touched in that group/window; skips are receipted with reasons (the on_missing pattern).

Filtering stays orthogonal: the filter role (AI-backed or not — any adapter emitting VERDICTs qualifies: deterministic rules, verification services, human review) gates which records continue through this run, groups persist sets, and only the runner connects them. Single-pipeline use with no groups at all remains fully supported.

Everything subtler is gtme query: segments-as-SQL extend over the group tables automatically, and an extensional "frozen list" is simply a group nobody updates. Two affordances follow: gtme groups add <group> --from-segment <name> (or --query "SQL") snapshots an intensional definition into extensional membership, each added event carrying "segment X evaluated at T" provenance; and because the run layer logs every person's every run (run_records, step_events, deliveries, field_values.run_id), any grouping — including a filter's failers — is reconstructable after the fact, so record: stays single-valued and the terminus captures completers only in v0. Consequences: Groups are not redundant with segments: segments derive sets from what the ledger already implies (and re-evaluate, so membership drifts); groups assert sets — hand-picked members, imported lists, frozen commitments — with membership provenance as an event trail, plan-checkable references safe enough for the delivery path (arbitrary segment SQL is not, per the ADR-018 declarative-vs-opaque line), and a scope key that touched events need to exist at all. Segments answer questions; groups record decisions about sets. "Campaign" needs no entity and no spec vocabulary — it is a usage pattern (a group a qualify pipeline fills, a send pipeline consumes, records touches into, and suppresses against). Delivery suppression becomes chosen rather than accidental; the current adapter-wide dedupe survives as the idempotency floor (§8) with group suppression layered above it. run_records is recognizable as a degenerate group (label forced to one execution) — not refactored, just understood. The deliberately excluded half (option C) is parked in ROADMAP.md: group-owned rules, intensional self-evaluating groups, lifecycle state machines, cross-type traversal policies, typed groups. Spec impact: AMEND (not yet applied) — §3 ledger DDL (two tables + a membership view), §9 YAML (group terminus and source, require:, exclude:, record:, suppress:), §8 deliver semantics and receipt lines, §7 plan checks (referenced groups exist; windows well-formed), a gtme groups verb set (list with derived character, add --from-segment/--query), and acceptance criteria. To be applied only after human approval, as a reconciliation-plus-build pass like ADR-017/018/019's.


Session packet, 2026-08-16 (declarative bindings, universal adapters, transform floor)

Third design-session packet (DECISIONS-SEED-3.md, a session artifact, never committed — same handling as the first two). The packet was authored before repo ADR-020/021 existed and instructed "append as ADR-020..025"; the repo had already reached ADR-021, so its six entries land here renumbered ADR-022..027, with the packet's internal cross-references adjusted to the new numbers. Per PROCESS.md, where a packet conflicts with the repo the repo wins; two such conflicts surfaced in transcription and are reconciled inline where they occur — the universal set's query/source claim (see ADR-023) and the relationship between sql/filter and ADR-021's membership gates (see ADR-027). The packet also names its build (the binding engine) "the first post-campaign-zero milestone"; repo ADR-021's groups build is queued too, and the relative order of the two was deliberately not asserted by this transcription. Sequenced by the human later the same day (2026-08-16): binding engine first — SPEC §11 M8 (bindings + simulate), M9 (groups), M10 (bundles).

ADR-022: Declarative binding tier — adapters as data

Status: Accepted Context: Most GTM vendor APIs are CRUD-shaped HTTP. Hand-coding each adapter repeats Singer's maintenance failure; Airbyte's migration of most of its catalog to declarative low-code manifests interpreted by a generic engine is precedent that bindings work at ecosystem scale. Verified against real vendors: Instantly (deliver), Attio (assert/upsert), Apollo (search), HarvestAPI (lead-search, get-profile — clean REST over their managed scraping, publishes OpenAPI + llms.txt). Decision: The runner gains one generic HTTP execution engine. A tier-1 adapter is a YAML binding the engine interprets deterministically — all judgment frozen at authoring time, never per-call. Binding schema lives at spec/binding-schema.json, kept to ~8 primitives:

  1. auth (type, header/param name, env var ref)
  2. request template — method, URL, body AND query-param templating from config + canonical fields
  3. pagination (strategy: page|cursor|offset; termination; max)
  4. extraction — records JSONPath + per-field response→canonical paths, with a transform: hook restricted to REGISTRY normalization rules (e.g. slug_from_url) — never arbitrary logic
  5. error→verdict mapping
  6. idempotency: native | ledger — declares which party guarantees dedupe (Attio assert = native; Instantly = ledger via deliveries table)
  7. cost declaration (per record / per request / unit)
  8. retry/rate policy incl. hourly windows; optional session declaration (UUID-per-run passed through, for vendors like HarvestAPI that offer pagination-consistency sessions) Roles: source (pagination + cursor/STATE), enrich (per-record request), deliver (idempotency + dry-run receipts). Same manifest surface as process adapters (needs/provides/config_schema/freshness). Graduation rule (hard): the moment a binding needs logic — conditionals, expressions, multi-call workflows, OAuth dances, request signing, computation — it graduates to a process (NDJSON) adapter. No expression language may ever grow inside binding YAML. Two-tier taxonomy: bindings cover anything that SELLS an API; process adapters cover anything that must be FOUGHT for. Engine unification: inline http/* steps (ADR-023/024) are the binding engine invoked anonymously; a named binding is the same config published, versioned, and conformance-tested. Recurring inline config across pipelines is the signal to extract and name a binding. Security consequence (record explicitly): bindings cannot execute code; their blast radius is what the engine permits. Community bindings are reviewable, diffable data — this is what makes a future adapter marketplace safe to host. POC & sequencing: the packet names this the first post-campaign-zero milestone (confirmed by the human 2026-08-16, sequenced ahead of ADR-021's groups build — see the packet intro above and SPEC §11 M8). Port all three real Go adapters (apollo/search, harvest/profile, instantly/add-to-campaign) to bindings; acceptance = receipt diff against each Go twin on campaign-zero data (dry runs where delivery is involved). Three matches proves the engine in read, enrich, and write directions; the Go adapters were scaffolding. First net-new integration ships as pure YAML: Attio (assert endpoint, idempotency: native). Spec impact: AMEND — new binding-tier section; spec/binding-schema.json; adapter model becomes two-tier; conformance kit extended to bindings (fixture payloads in → canonical records out); roadmap entry for marketplace security note.

ADR-023: Universal adapter set (the floor)

Status: Accepted Context: Smallest set of adapters with near-total reach, docking onto the three universal transports: files, webhooks, the web. Universality is bought by pushing semantics into user config, so universal adapters are always the WORST version of any given integration — their job is the guarantee ("wireable today"), not excellence. Bindings are the ceiling. Decision: The universal six:

  • In: csv/source (exists) · webhook/source (ADR-009, exists) · group-as-source (ADR-021). Reconciliation note: the packet listed query/source (saved ledger query as source) here as "exists as decided" — it does not exist and was never decided; intensional segments-as-sources remains a ROADMAP.md design pass. What IS decided is repo ADR-021's group-as-source — members of an asserted group projected from the ledger, the extensional half — and that is the universal-floor In slot: any set you can name, import, or snapshot (gtme groups add --from-segment/--query) becomes a source. query/source stays parked in ROADMAP.md.
  • Transform: ai/* steps — kept PURE: fields in via uses:, fields/ verdicts out, NO network access (see ADR-024 for the fetch half)
  • Out: http/deliver — POST mapped variables per record to any URL; idempotency-key template REQUIRED in config (even the trivial case cannot infer semantics — it must be told) · csv/deliver — write a segment/run's records to CSV; universal output to anything with an import button and the natural human-review artifact Floor/ceiling growth loop (record as standing position): receipts showing the same http target recurring across runs = the tool's cue to suggest minting a proper binding (and later, the codegen skill's demand signal). Spec impact: AMEND — add http/deliver and csv/deliver to adapter roadmap (post-binding-engine, both small); universal-set framing in the adapters section.

ADR-024: http/enrich — generic fetch enricher with markdown mode

Status: Accepted Context: Research enrichment ("read the company's website") currently has no home; putting network access inside AI steps would make them nondeterministic, uncacheable black boxes. Decision: http/enrich: per-record HTTP request templated from canonical fields; two modes: (a) JSON extraction (the binding engine's enrich role, inline) and (b) markdown: true — fetch a page, convert to markdown, store as a ledger field (e.g. homepage_markdown). Division of labor: http/enrich does deterministic acquisition; ai/* judges it via uses:. Content fields are facts with provenance and a MANDATORY freshness_days (web content rots) and an engine-enforced size cap; fetch-once economics means N AI steps across M runs reuse one fetch, and receipts show exactly what content was judged. Dynamic provides (mirror of ADR-019): the step declares its output field name in config; plan validates downstream uses: against it; ad-hoc names are namespaced unless mapped to a canonical field. Limit stated honestly: no-JS fetching only. JS-heavy pages route to a reader-provider binding (Jina Reader / Firecrawl class — URL→markdown as an API): the provider-shape absorbs the hard version, same as harvest. Spec impact: AMEND — dynamic provides added to plan semantics beside dynamic needs; http/enrich spec'd with freshness/size requirements; ai/* purity (no network) stated as an invariant.

ADR-025: OpenAPI is codegen input, never runtime input

Status: Accepted Context: ChatGPT Actions proves an LLM can drive any API from its OpenAPI spec — but Actions puts the model in the loop PER CALL (it re-derives operation choice and field mapping every invocation, with human confirmation on consequential calls). gtme runs unattended batches where approval is concentrated at the plan gate; per-call model judgment means per-row cost, nondeterminism where money moves, records in model context (violates ledger-as-bus), and steps plan cannot validate. Decision: Runtime OpenAPI-driven generic adapter: REJECTED (record the syntax-vs-semantics reason: specs describe endpoints; adapters encode operation selection, idempotency keys, verdicts, canonical mapping — judgment no spec contains). Instead, move the Actions maneuver to BIND TIME: the adapter-authoring skill's happy path is paste an OpenAPI URL → model proposes a binding (operation, mapping, idempotency, pagination) → conformance tests pass → adapter exists. Actions ease, batch-grade determinism. Same idea, two binding times: Actions binds per-call; gtme binds once. Consequences: Strengthens the central AI-adapter bet again — generating constrained YAML against a schema is far more reliable than generating Go, and OpenAPI→binding is a spec-to-data transformation. HarvestAPI (OpenAPI + llms.txt published) is the ideal first codegen target. Spec impact: Adapter-authoring skill requirements; roadmap.

ADR-026: Adapter naming — contract owner names the adapter

Status: Accepted Decision: An adapter is named by whoever DEFINES ITS CONTRACT. apollo/search: Apollo's API defines the step's meaning → vendor-named. ai/filter, ai/compose: the contract is the operation (uses: in, verdict/fields out, judged against a prompt); the model provider is an interchangeable engine → operation-named, provider is config. Provider-naming AI steps would vendor-couple pipelines exactly where nothing vendor-specific exists and multiply the closed grammar with synonyms. Provenance carries the engine anyway: field_values.source records e.g. ai/compose @ claude-sonnet-4-6, and COST attributes spend per model — the ID says what KIND of fact, provenance says who produced it. Flip side: when a provider capability leaks into the contract (e.g. a citations format that IS the product), it takes the vendor name. Same logic as fields: canonical when shared, namespaced when proprietary. Spec impact: Naming rule added to adapter authoring section; provenance format includes model identifier for ai/* steps.

ADR-027: sql/enrich and sql/filter — the deterministic transform floor

Status: Accepted Context: The transform floor was fuzzy-only (ai/). Common deterministic work — splitting full_name, domain-from-email, title→seniority bucketing, boolean flags, and set-based derivation over relations ("count of known people at this company") — was homeless or wastefully sent to AI steps. SQL is the ledger's own language; "the ledger is the bus" implies SQL steps as a corollary. Decision: sql/enrich: a SELECT over the projection view (+ relations), scoped to the run's records; result columns become field values appended by the ENGINE like any adapter output (the step never writes storage directly — append-only, provenance sql/enrich @ <query-hash>, freshness all preserved). Contracts are DECLARED, not parsed: config carries uses:/provides:; plan validates both; engine checks result columns match provides. Read-only, timeboxed, no side effects. sql/filter: same mechanism producing verdicts from a predicate — closes membership-by-ledger-facts cases ("has replied ever", "3+ known contacts at company") that where= combinators don't reach. Relation to ADR-021's membership gates (reconciliation note): sql/filter and ADR-021's require:/exclude: are complementary, not competing. sql/filter computes a verdict from what the ledger implies — facts and relations, a predicate re-evaluated every run — while require:/exclude: gate on what a group asserts — membership someone recorded, stable until deliberately edited. The qualify-pipeline pattern uses both: a filter (sql/ or ai/) decides, the group terminus records the decision, and exclude: makes it judgment memory. Consequences: Shrinks ADR-018's code-transform escape hatch to nearly nothing — computed fields get a declarative, testable home in a language that already exists, which is also the anti-creep move (no expression language needs inventing inside YAML). Transform floor is now symmetric: sql/ for the computable, ai/* for the judgeable; both read projections, both write facts, both free to re-run. Spec impact: AMEND — two new built-in steps; plan semantics for declared SQL contracts; ADR-018 escape-hatch note updated to point here.

Standing notes (not ADRs)

  • HarvestAPI lead-search returns NO email → identity ladder (linkedin_slug as rung 2) validated against real payloads. Kept as a design-confirmation note.
  • HarvestAPI also exposes send-connection / send-message → a future LinkedIn outreach deliver BINDING (multichannel with zero new architecture). Parked in ROADMAP.md.
  • Harvest example in the two-tier taxonomy corrected: harvest-via-provider is tier 1 (the provider absorbed the fight); only DIY scraping is tier 2.

Session packet, 2026-08-16 (consequences of adapters-as-data)

Fourth packet (DECISIONS-SEED-4.md), same session date. Both entries are consequences of ADR-022's binding tier; the packet instructed "append as ADR-026..027" and is renumbered ADR-028..029 for the same reason as the previous packet, cross-references adjusted. Neither blocks the binding-engine milestone: ADR-028 lands naturally with it, ADR-029 immediately after.

ADR-028: Simulation gate — gtme run --simulate

Status: Accepted Context: Bindings execute against fixture payloads as easily as against live vendors (ADR-022's conformance kit already requires fixtures). That makes whole-pipeline offline execution nearly free, and it fills a gap in the gate ladder: plan validates contracts but executes nothing; dry-run executes but touches live read APIs (spend) and stops only at delivery. Decision: gtme run --simulate <pipeline>: executes the ENTIRE pipeline with every binding served from its conformance fixtures and every process/AI step either fixture-served or stubbed (AI steps replay recorded fixture responses when present, else emit a marked synthetic verdict). No network, no spend, no sends, deterministic. Output is a full receipt marked SIMULATED, never written to the durable identity layer (simulation runs are ephemeral or flagged so cache/projection ignore them). The gate ladder — extending §8's dry/armed gate built in M7 — becomes: simulate → plan → dry-run → armed, and the agent loop gets its missing rung: an agent that authors a pipeline can now fully validate it offline — structure via plan, BEHAVIOR via simulate — before a human reviews anything. Consequences: Conformance fixtures do double duty (adapter validation

  • pipeline simulation), which raises the incentive to keep them good. VALIDATION scripts can open with a simulated pass. Requires fixture coverage discipline: a binding without fixtures is visible as a simulation gap in the receipt. Spec impact: AMEND — run verb gains --simulate; receipt schema gains simulated flag; ledger semantics note (simulated runs excluded from projection/cache); acceptance criterion: campaign-zero pipeline simulates end-to-end with zero network calls.

ADR-029: Campaign bundle — freeze output as a portable folder

Status: Accepted Context: With bindings (ADR-022), prompts, queries, and pipeline YAML all being data, a campaign is fully expressible as a folder of text files — no code. freeze already snapshots a pipeline; this names its output format and scope. Decision: gtme freeze produces a campaign bundle: a directory (or tarball) containing the pipeline YAML, every referenced binding at its exact version, AI prompt files, saved queries, the relevant registry slice, and a manifest (bundle format version, content hashes, source run id). Properties to guarantee: (a) self-contained — gtme run on a bundle resolves nothing outside it except credentials; (b) diffable — text files, stable ordering; (c) portable — same bundle runs on any machine/ledger (membership and cache naturally differ; contracts don't). Simulation (ADR-028) must work on a bundle using fixtures included in it, making a bundle a fully offline-verifiable artifact. Interaction with groups (ADR-021, reconciliation note): a bundle captures contracts, not ledger state. Group references in a bundled pipeline (require:/exclude:/record:/suppress:, a group terminus or group source) are names resolved against whatever ledger the bundle runs on — membership travels with the ledger, not the bundle, exactly the "membership and cache naturally differ" category above. ADR-021's plan check (referenced groups exist) is what makes a bundle moved to a clean ledger fail loudly at plan rather than silently run ungated; simulation on a bundle evaluates group gates against the target ledger's (possibly empty) membership. Consequences: Campaigns become reviewable, versionable, shareable artifacts — the unit of distribution for playbooks/recipes and the natural thing to keep in a git repo per client. Spec impact: AMEND — freeze section specifies bundle layout + manifest schema (spec/bundle-manifest.json); run accepts a bundle path; acceptance: freeze campaign zero, move bundle to a clean ledger, simulate

  • dry-run it successfully.

Session addendum, 2026-08-16 (payload retention)

Proposed in a working session during the M8 wrap (not a seed packet): M8's port made the cost of discarding raw vendor responses visible, and the design conversation resolved where retention can live without breaking the append-only spine. Human-approved same day.

ADR-030: Payload retention — raw vendor responses are cache, not facts

Status: Accepted (2026-08-16 — proposed and iterated in a design conversation during the M8 wrap; human-approved same day) Context: Adapters extract canonical fields and discard the raw vendor response; the ledger keeps only what the mapping chose at fetch time. ADR-022 changed the economics of that discard: extraction is now declarative data, so a retained payload plus an improved binding equals better canonical fields with zero re-spend — "fetch once, judge many" (ADR-024) generalizes to fetch once, extract many. Retained payloads are also per-record recorded fixtures (the campaign-zero shape-drift episode — live HarvestAPI shapes the fixtures never saw — becomes a systematic minting loop, and --simulate can replay real history), and they are point-in-time truth: the profile that existed when a verdict was rendered is irreplaceable. Two constraints shape the mechanism. First, freshness is a read gate, not deletion — a "TTL" implemented as freshness leaves data in place, so retention safety needs real eviction, and deleting from field_values would breach the append-only spine (ADR-002). Second, a payload stored as a namespaced field would leak into needs-all projections: an AI step with no uses: projects everything, so multi-KB documents would silently enter prompts — records-in-model-context, which ADR-025 rejects. Decision: A hard line: extracted = fact, unextracted = cache. Facts (field_values, canonical or vendor-namespaced) remain append-only forever. Raw payloads live in their own table — payloads(id, identity_id, adapter, run_id, content_type, body, created_at, expires_at) — which is cache material and therefore legitimately purgeable. Payloads are never projected into any step and never appear in gtme show's default output; the only paths out are (a) extraction, which writes facts with normal provenance, and (b) deliberate promotion into a content field (the http/enrich homepage_markdown pattern, with mandatory freshness and size cap) when AI steps should judge the content. Retention is declared, not assumed: adapters and bindings declare keep_payloads with a TTL and an engine-enforced size cap (manifest/binding default, per-step override — the freshness_days shape). Default is on with a 90-day TTL, defensible precisely because eviction exists. Eviction is opportunistic at run start plus an explicit gtme vacuum verb (receipted; no daemon, per ADR-009's stance). The unit stored is the per-record slice for sources, the response body for enrich/deliver. Consequences: Registry promotions become retroactive — when a field earns canonical status by the rule of two, historical payloads back-fill it; likewise a new <vendor>.<field> extraction entry mints values from documents already paid for. The append-only principle survives intact by scoping what counts as knowledge. Storage is bounded by TTL and size cap; the PII posture improves over silent forever-retention because retention is per-adapter declared, bounded, and evictable. Deliberately deferred to ROADMAP.md: a re-extraction verb/engine mode, fixture minting from stored payloads, and simulate-replay-from-history — this ADR creates the substrate, not the verbs. Sequencing: Not M9 (groups). Build with the http/enrich/sql/* milestone, which already brings the size-cap and content-field machinery, and whose sql/enrich is the natural query surface over payload-derived facts. Spec impact: AMEND (build queued per sequencing) — §3 gains the payloads DDL with the cache-not-facts note (explicitly exempt from append-only, never projected); §6 and §10a gain the keep_payloads/TTL/size-cap surface; §8 gains gtme vacuum; ROADMAP.md gains the re-extraction/fixture-minting/simulate-replay entries. Acceptance: a run with retention on stores payloads and a re-run after a binding improvement back-fills a new field with zero vendor calls; gtme vacuum removes expired payloads and nothing else.

ADR-031: Deliver is a role, not a position — deliver steps join steps:

Status: Accepted (2026-08-17 — design conversation; human-approved same day. Update, same day: built as milestone M13 — make check green; see SPEC §11 and changelog v0.14) Context: pipeline.yaml carried a singular top-level deliver: block beside source: and steps: — a shape inherited from the original pipeline sketch and never defended by an ADR. The manifest layer already treats deliver as one of six roles (§6), and every deliver-special mechanism keys off role or target, never position: the dry-run/armed gate withholds deliver steps (§8), deliveries idempotency is keyed (target, idempotency) (§3), and variables:/on_missing:/record:/ suppress: are role-gated config the planner validates the same way it validates uses: on filter/compose steps. The block bought one thing — the send point is obvious at a glance — and cost real expressiveness: one delivery per pipeline, always last. Multi-target sends (campaign + CRM upsert + notification egress), segmented sends gated per deliver step, and mid-pipeline delivery ordering were all inexpressible. Decision: The top-level deliver: block is removed. Deliver adapters are ordinary entries in steps:; a pipeline MAY carry zero, one, or many, at any position, and "steps execute strictly in order" (§9) is the whole sequencing story — a deliver step sends exactly the records that survived everything before it. variables:, on_missing:, idempotency:, record:, and suppress: become keys valid only on steps whose adapter role is deliver, rejected by the planner elsewhere — the uses: pattern, second instance. Per-step semantics are unchanged and now simply apply per deliver step: each keeps its own deliveries idempotency scope (per target), its own on_missing policy, its own record: touch scope (still defaulting to the pipeline name — two deliver steps sharing the default share the scope, which is the correct reading of "this pipeline touched them"; distinct scopes are an explicit per-step record:). --dry-run withholds every deliver step and the receipt renders resolved variables per deliver step; arming arms them all. The terminus (group:) is untouched: it admits records that complete the run's final step, so a record that delivered mid-pipeline and then failed a later step has delivered but does not join — the terminus captures completers, not touchees, and record: already remembers the touch. The at-a-glance property moves to gtme plan, which knows every step's role and MUST call out each deliver step (target and touch scope) in its output — validated truth instead of YAML position. Considered and rejected alongside: a per-run step cardinality ("runs once for the whole run" — the Slack-summary / Google-Sheet case). The test that killed it: a step's contract questions — what does it need per record, what keys its idempotency, what happens on a missing field — must have answers. An aggregate export (sheet, CSV) answers all of them and is just a batch: true deliver (§6); a run-summary notification answers none and is therefore not a step but a run-lifecycle hook, parked in ROADMAP.md. Consequences: Multi-delivery pipelines with zero new machinery — the planner, the gate ladder, idempotency, and groups semantics all already operate per step. The YAML format changes pre-publication with no compatibility shim (the ADR-005/v0.12 stance): a document carrying top-level deliver: fails schema validation and KnownFields decoding. Campaign-zero, the examples, and e2e fixtures move the block into steps: when M13 builds. Spec impact: AMEND (applied, changelog v0.13; build queued as M13) — §7 plan-output wording; §8 deliver idempotency / on_missing / dry-run / groups sections re-worded per deliver step, terminus clarification; §9 example and schema rules; spec/schemas/pipeline.schema.json (top-level deliver removed, role-gated key descriptions); §11 milestone M13; ROADMAP.md gains the run-lifecycle notification hook entry.

ADR-032: The handoff to the next stage is a delivery — group/deliver, group-source limit:

Status: Accepted (2026-08-28 — design session 2026-08-26..28; human-approved 2026-08-28) Context: A campaign that runs over days needs to move records between stages under human and budget control: review a batch before committing it, hold what is not ready, work only N today. The apparent answer is a lifecycle layer inside the runner — per-stage holds with a release verb, leases, attempt counters with backoff, ranked serve order — which is the workflow engine §0's closed grammar refuses and ROADMAP.md's "Groups, option C" parks by name. The pressure was put to the test against a real multi-stage system built independently of gtme whose operational layer is exactly those primitives. Refusing the engine left the need unmet. The terminus (group:, ADR-021) is already ~70% of the answer — it is idempotent, rehearsed under --dry-run, and conditional (filter-failed records do not join) — but a pipeline can route to exactly one group, unconditionally beyond completion, and source: {group: …} takes every member with no cap. Decision: Model the stage handoff as a delivery. Committing a record to the next stage authorises downstream spend, which makes it destructive in exactly the way sending is, so it inherits the apparatus already built for that edge with no new concepts: the --dry-run receipt as the review artifact, arming as approval, deliveries idempotency against double-enqueue, suppress: windows, on_missing completeness, record: touch history, require:/exclude: gates. Two spec changes. (1) group/deliver — a runner-owned deliver step (no adapter, no network, in the manner of the SQL steps) whose target is a group named in with: {group: …}, created on demand; subject to every deliver-step key including variables:, so the receipt renders the fields a reviewer needs (a brief, a verdict) rather than a list of keys. A pipeline may carry several, so now → stage-2 and later → held route in one run. A hold is then a group with no consumer pipeline; release is gtme groups add (by key, --from-segment, or --query); review is gtme groups show. gtme groups remove gains --note so a rejection carries its reason in the event's detail. (2) limit: N on a group source — members served in group_events insertion order, oldest first; the budget for "work thirty today." Ranked serve order is declined: limit: plus insertion order plus an upstream sql/filter covers the real cases, and an order_by reintroduces the scheduler. A handoff is a write, not a trigger: gtme has no daemon; a group is shared state one pipeline writes and another pulls on its own schedule, and re-running the consumer at any time is safe because cache-skip, exclude: judgment memory, terminus idempotency, and delivery idempotency together make a re-run do only the new work. Deriving readiness from state rather than enqueueing is the property that makes multi-day campaigns safe; gtme has it structurally. Consequences: Holds, releases, leases, attempt counters, hand-back, and ranked serve order will not be built — this ADR is the reason, and it is a tested position, not an instinct (see ROADMAP.md, Groups option C). One rule follows from ADR-031's all-or-nothing arming and MUST be documented and surfaced by gtme plan: one commit point per pipeline — a handoff-deliver and a network-side deliver never share a pipeline, or approving the handoff approves the send; plan warns when both appear. when: supports only <step>.passed; routing the failures elsewhere uses a second sql/filter over the verdict already in the ledger (free) until a .failed form is justified. ~250 LOC, no migration, no adapter touched. Spec impact: AMEND (proposed diff queued) — §8 deliver semantics gain group/deliver; §8 groups section gains limit: on the group source and --note on groups remove; §9 grammar; §7 plan output (one-commit-point warning); spec/schemas/pipeline.schema.json.

ADR-033: AI steps declare their output fields

Status: Accepted (2026-08-28 — design session 2026-08-26..28; human-approved 2026-08-28) Context: ai/filter returns {pass, reason} and ai/compose returns {first_line, ps_line}, both hardcoded in the adapter (the prompt shape string, validateItem, and emit each pin the field names). Any judgment that is not a boolean and any writing task that is not two lines is inexpressible — not because the model cannot do it but because the adapter cannot say so. A qualification returning a state from a declared vocabulary plus an orthogonal timing disposition plus reasoning; a multi-step message with its own subject and bodies; a company-level brief — none can be declared. Separately, both AI manifests are pinned entity_type: person, so an AI step inside a company pipeline plans as person and validates uses: against the wrong registry (it works today only when every field is dual-registry or namespaced). And a declared output written to field_values is global to the identity, so two campaigns' judgments about the same company collide: disposition: now for one overwrites later for another. Decision: An AI step MAY declare provides: in config, exactly as sql/enrich (now sql/transform, ADR-037) already does: its effective provides derive from config (ADR-024 dynamic provides, applied to the one role left out), the planner adds the names to the available-field set, and the runtime validates the model's output against the derived schema with the existing one-retry loop. A declared schema MAY carry enum; a value outside the domain is a validation failure, never stored. The prompt's required output shape is generated from the declared schema, not a literal string. ai/filter keeps emitting a VERDICT; a filter declaring provides emits a VERDICT and RECORD fields, so reasoning becomes queryable without a second call — §5 states this. AI manifests become entity-agnostic: the step's entity type is the pipeline's. Declared outputs land namespaced by pipeline — <pipeline>.<field> — unless the config maps a name to a canonical field (§4a: facts stay global, judgments stay per-campaign, no new table). A step declaring nothing keeps today's shape; no existing pipeline changes behavior. Consequences: Two hardcoded shapes become one general rule; the AI-step special case — the only role whose outputs cannot be declared — is deleted, so surface goes down while expressivity goes up. Every mechanism it needs already exists (config-declared provides, the SCHEMA wire message aisteps already emits at OPEN, registry validation, the retry loop). Unlocks structured judgment on every campaign shape, account-based or not, before any cardinality work. ~250 LOC, no migration, no adapter touched beyond aisteps. Spec impact: AMEND (proposed diff queued) — §5 (VERDICT + RECORD from a filter); §6/§7 dynamic provides for AI roles; §9 provides: valid on AI steps; §10 items 3 and 5; §4a namespace-by-pipeline default for AI outputs.

ADR-035: Prompt assembly is specified — compact encoding, wrapping, a stated order

Status: Accepted (2026-08-28 — design session; scoped down before approval; human-approved 2026-08-28) Context: How an AI step arranges its prompt is an unstated implementation detail. userPrompt writes the operator's instruction first and the record batch second; the batch is pretty-printed (json.MarshalIndent), spending tokens on indentation that carries nothing; long values are emitted as single lines, which the claude-code engine's file-reading path truncates silently into a broken head fragment. None of this is a bug against the spec, because the spec is silent — and an unstated choice cannot be reviewed, cannot be A/B'd against its inverse, and drifts whenever the function is touched. Externally-fetched text (http/enrich pages, provider bios and summaries) reaches the prompt as raw prose in the same shape as the operator's criteria; ordinary marketing copy is imperative-mood text full of criteria vocabulary, and a delimiter plus one sentence helps the model treat it as evidence rather than task. Decision: Three mechanical rules and one stated default. (1) Records are encoded compactly, never pretty-printed. (2) Long values are wrapped at structural commas outside strings — never between a backslash and its escaped character, never inside a surrogate pair — so no single line exceeds what the engine's tooling reads intact. (3) Fields whose provenance is an external fetch are wrapped in a delimiter and labelled in-band as data supplied by the subject; the delimiter string is neutralised inside the body before wrapping, and wrapping happens after neutralising — encode → neutralise → wrap, in that order, or the fence is decorative. This is default-on with a per-step opt-out in the AI adapter's config (with: {fence: false}) — adapter config, not pipeline grammar — and the spec states the properties, not the delimiter bytes. The operator has no hook to do any of this themselves: the records are marshaled after the prompt in code they do not control, and bindings cannot execute code at all. (4) Assembly order is a stated default (operator prompt first, then records), and the shared/payload split stays exposed so a cache breakpoint can sit between them and so the default is trivially A/B-able against its inverse on any campaign's own ledger. No order is recommended: the one measurement suggesting criteria-last (a recency effect on a long payload) came from a single campaign, prompt, and model, and belongs in VALIDATION.md as a per-campaign question, not in the spec. Consequences: Token reduction from (1) is immediate. (2) fixes a real truncation path. (3) is judgment hygiene, not a security control — the judge holds no tools, so the worst case was and remains a wrong verdict the human gate sees before anything sends; it should be stated in the spec that AI steps hold no tools rather than left as an accident of the adapter set. ~150 LOC inside prompt assembly, no migration. Spec impact: AMEND (proposed diff queued) — §10 items 3/5 gain the assembly rules and the fence config key; §0 or §10a states that AI steps hold no tools.

ADR-036: A 2xx is not a delivery — accepted, attestation, and a three-way verdict

Status: Accepted (2026-08-28 — design session; human-approved 2026-08-28) Context: A successful adapter RECORD/END writes the deliveries row and the record counts as delivered. That conflates three facts: the provider accepted the request; the provider stored what was sent; the provider acted on it. They come apart in practice — a create can return 200 while the content silently fails to persist, leaving a lead that exists and will be mailed blank; and acceptance is never evidence of sending, since a queued lead and a mailed one are indistinguishable from the create response. Decision: (1) A delivery is stamped accepted, never sent, until something attests otherwise; sent means a provider attested it, and absent attestation sent_at stays empty by design. deliveries gains status and sent_at. (2) Where an adapter can re-read what it just wrote, it verifies and reports a three-way verdict, not pass/fail: confirmed (every non-blank field sent is present in what is stored), contradicted (a readable value says it did not persist — hard fail), inconclusive (the re-read failed or the shape was unrecognised — reported ok, with a warning). The three-way split is load-bearing: the record already exists at the target and will be acted on regardless, so marking it failed is the more dangerous direction to be wrong in — an operator seeing failed re-sends by hand into a duplicate. Attestation is a per-adapter capability declared in the manifest, with inconclusive the honest default when absent. Promotion from accepted to sent requires reading execution evidence back from the provider, which is the listen verb's territory (ROADMAP.md); this ADR deliberately stops short of it — (1) is worth doing alone, because a sent that overclaims is worse than an accepted that underclaims. When promotion arrives it MUST be compare-and-swap on the observed (status, sent_at) pair so a racing writer's fresher value is never overwritten by a stale one. Consequences: The receipt and gtme show distinguish accepted from attested from sent. One migration (two columns). ~250–400 LOC. The Instantly adapter is the first to declare attestation. Spec impact: AMEND (proposed diff queued) — §3 deliveries schema (status, sent_at) and spec/ledger.sql; §6 manifest attests capability; §8 deliver idempotency wording; migration 0007.

ADR-037: SQL is the transform floor — sql/enrich → sql/transform; {query:}/{segment:} config values

Status: Accepted (2026-08-28 — design session; human-approved 2026-08-28; sql/filter explicitly retained) Context: A runnable test built the account-based campaign shape — company fans into people, a subset is selected, the set collapses into one account-level fact, the company is judged — from shipped atoms only, offline, with fixtures. Three of the four cardinality moves ran today, and the SQL steps carried them: the cross-type gate ("people whose company is in group G, via works_at") as a sql/filter over relations and group_members; the fan-in as a sql/enrich aggregate on a company-entity run, with provenance. The fourth — a company fanning into its people — is blocked by nothing deeper than sources taking static config lists. SQL is therefore the expressive power tool: one key behind one contained door, read-only, deterministic, offline under --simulate, provenance-hashed. Three things about how it is presented are wrong, and one thing it cannot do. sql/enrich is misnamed — "enrich" in GTM means "look this record up at a provider," which neither a per-record derivation nor a cross-record aggregate is; ADR-027 itself titles the feature the transform floor. Two facts are true and unstated: a SQL step's query may read any identity (only results are run-scoped), and SQL steps never cache-skip — runStep diverts them before the cache path — which is exactly what makes a cross-record aggregate safe, since it cannot go stale when related records change. User queries currently know table internals (json_extract(value,'$'), the groups↔group_members join), which is coupling to schema rather than to vocabulary; gtme plan validates only that the statement is a SELECT. Decision: (1) Rename sql/enrich → sql/transform. sql/filter stays: it is explicit, "transform" does not suggest a verdict, and a reviewer should see the role in the id (an earlier draft collapsed both into one id with the role derived from provides:; rejected as less legible for no gain). (2) State normatively that a transform's query MAY read any identity and that SQL steps always recompute. gtme plan annotates a SQL step whose query references relations or group_members as cross-record. (3) Invest in the floor, zero new grammar: two more spec'd views in spec/ledger.sql — one that pre-unwraps values, one that joins membership by group name — so queries read as vocabulary; EXPLAIN QUERY PLAN at plan time against the local ledger ($0, no network), failing on unknown tables or columns; the ledger schema and the canonical query shapes in help --agent. (4) Any config value MAY be {query: SQL} or {segment: NAME} (a segment is a saved SELECT from gtme query --save), resolved read-only at plan time and again at run time, with the resolved rows printed in plan output and recorded in runs.config_json. Zero rows is a plan error — an empty list handed to a vendor search is the shape that searches everything. Because segments re-evaluate, the list may drift between plan and arm; when stability matters the operator snapshots into a group first (groups add --from-segment) and queries the group — ADR-021's pattern, and the group is where the human gate lives. Read a segment when the list is a live computed fact that should drift; read a group when it is a decision that should not. Consequences: expand (ADR-008) is retired by composition: fan-out happens at the pipeline boundary via a config query, where run membership is fresh by construction, so its open run-membership question is never raised; fan-in is a cross-record transform. Single-file ergonomics would remove a review gate (ADR-031 arms every deliver at once), so expand is not a safety improvement and stays on ROADMAP.md only as a convenience. The two typed atoms considered instead — a per-adapter from_group: source key and a relation-hop require: — are rejected as special cases of what SQL does generally; mint a typed atom for a relation shape only when receipts show it recurring (the floor→ceiling rule ROADMAP.md states for http/*). (4) delivers the safe half of ROADMAP.md's "SQL segments as pipeline sources": a segment feeding a source's parameters touches neither run membership nor identity minting; segments as the run's records themselves still needs that design pass and stays parked. The rename is breaking and cheap only pre-launch; after launch it is a deprecation. ~40 LOC rename; ~150 for views, EXPLAIN, help; ~150 for config resolution in the planner; the views are a migration. Spec impact: AMEND (proposed diff queued) — §3/spec/ledger.sql two views; §7 config-value resolution and EXPLAIN; §8 help --agent schema section; §9 {query:}/{segment:} config form; §10a rename and the two stated semantics; ROADMAP.md expand, Groups option C, SQL segments.

ADR-038: Asynchronous steps — a step may end a run in flight; --resume collects

Status: Accepted (2026-08-28 — drafted from ROADMAP.md's "Asynchronous steps"; amended in review 2026-08-29 — last-step rule, collect-first run, respend warning; human-approved 2026-08-29) Context: Every step answers within the run that dispatched it. That is the right default and the wrong ceiling: the Anthropic Message Batches API answers the same prompts at half the per-token price, keyed by custom_id in any order — which is already gtme's record shape — but it answers in minutes to hours, not in the request. Every AI judgment gtme makes is a candidate, and a campaign that judges thousands of records is where the price matters. The same shape — dispatch now, collect later under a token — is what provider polling for listen (ROADMAP.md) will need. Two facts make this cheap: unknown wire message types are already ignored (§5), so the protocol extends without breaking an adapter or a runner that predates it; and gtme run --resume already exists as the verb that continues a run without redoing done work (§8, M4). gtme has no daemon (§13) and this ADR does not add one: nothing waits, nothing polls on its own; a human or a cron invokes the collection exactly as it invokes a run. Decision: (1) A step MAY end a session with work in flight. An adapter that has dispatched a batch it cannot answer yet emits PENDING {token, detail?} — one per session, step-level, after any records it could answer — and END. The runner records a pending step event for every dispatched record the session did not answer (detail: the token), leaves their run_records.state where it was (the step is not completed; nothing downstream sees them), and finishes the run with a new status, pending — a run that ended with work in flight is not done. The receipt says so per step and names the token. (2) A deferred step is the pipeline's last step. gtme plan rejects deferred: true anywhere else, naming the fix: the step's output lands through declared provides: fields and the group: terminus, and a consumer pipeline pulls from the group. This is what keeps the arm from preceding the judgment — no deliver step can follow a deferred one, so every send is its own pipeline whose dry-run receipt shows the judgments as collected — and it bounds the shape to one in-flight step per pipeline. (3) gtme run collects before it starts. When the most recent run of the same pipeline is pending, a plain gtme run resumes it instead of sourcing anew, and says so; --resume RUN_ID|last is the explicit form. Collection opens a session whose OPEN carries pending: {token} followed by the same records and END; the adapter, seeing a token, does not dispatch — it fetches results and answers with the ordinary RECORD/VERDICT/ATTEST/COST messages, or emits PENDING again if the batch is still processing (the run stays pending; run again later). A record the collection does not answer fails as it would in a synchronous session; COST lands at collection under the same run; the terminus asserts and the run finishes done. The cron recipe is unchanged, and nothing is ever submitted twice by habit — the tool derives the action from ledger state, which is the project's own rule. (4) Opting in is per step, in config: with: {deferred: true} on an AI step; the api engine then submits the batch to the Message Batches API under custom_id = identity_key and returns the batch id as the token; claude-code has no batch surface (it is one synchronous claude -p subprocess per batch of records, unchanged by this ADR) and ignores deferred with a plan warning; the fixture engine answers synchronously under --simulate (a rehearsal that ended in flight would rehearse nothing) and, in tests only, can be scripted to answer PENDING first. --dry-run on a deferred pipeline is a plan warning: there is no deliver step to hold back. (5) Respend is declared, never accidental. gtme plan warns when a paid step would pay for the same records again on a re-run with nothing to remember the answer — an AI step with no judgment memory (no exclude: naming a group this pipeline writes), or a credentialed enrich/verify with no freshness window — and respend: true on the step silences it. A default judgment cache that retires the warning is queued as its own ADR (ROADMAP.md). (6) Bounded by what exists: no new run-record state grammar (state stays "last completed step id"), no new table, no migration — pending is a step event and a run status; gtme runs counts in-flight records; gtme show --run shows their state unchanged. A token is provider-opaque; the runner never interprets it. Two things are explicitly out of scope: any form of waiting (--wait, polling loops) — cron already covers "run it again later" — and listen-style event sources, which reuse the mechanism but still need their own identity-correlation design. Consequences: AI steps at half price with one config key, and the campaign shape it produces is the one the project already recommends — judge into a group cheaply and often, send from the group deliberately — now enforced for deferred judgments rather than suggested. The receipt gains a fifth per-step outcome (in flight) beside in/out/cached/filtered/ failed, and gtme runs a fifth status. gtme run gains one state-derived behaviour (collect first), which is what makes batches safe under cron. The respend warning makes double spend an explicit choice in the YAML today; the judgment cache makes it unnecessary later. Additive to the wire protocol; an old adapter never sees a token it did not ask for, and an old runner ignores PENDING (and would then fail the unanswered records as "no verdict returned" — the honest degradation). ~350 LOC: PENDING/OPEN in internal/protocol, the pending event and status in internal/ledger, the last-step rule and respend warning in the planner, collect-first in the CLI and collection in the runner's dispatch, a batch submit/collect path in internal/ai's API engine, deferred in the AI manifests and respend: in the pipeline schema, receipt and gtme runs wording. Spec impact: AMEND (proposed diff in this packet's second commit) — §3 runs.status gains pending and step_events.event gains pending/collected (DDL comments, mirrored to spec/ledger.sql; no migration); §5 PENDING message and the OPEN pending field with the rules; §7 the respend warning; §8 gtme run collect-first / --resume / the last-step rule / receipt / gtme runs; §9 respend: true and, with §10 items 3 and 5, deferred: true; §11 milestone M15; spec/schemas/msg-pending.schema.json, msg-open.schema.json.

ADR-039: The judgment cache — no paid call twice by default

Status: Accepted (2026-08-29 — drafted from ROADMAP.md's "Judgment cache", named while amending ADR-038; input-hash exclusion added in review; human-approved 2026-08-29) Context: Enrich and verify steps cache-skip a record whose fields are current within the freshness window (§7); AI steps never do — every run re-judges every record and pays again, and the only guard is the operator remembering exclude: judgment memory (ADR-021). ADR-038 made that visible (the respend warning, respend: true) but not safe: the default is still "pay again". The decision the operator actually wants is the one enrich already has: the same question about the same facts is answered once. Judgments differ from fetched facts in one way that matters — they do not rot with time; they go stale when the question changes (the prompt, the model, the declared shape) or the facts do (the fields the prompt read). So the right key is not a clock but a signature over both. Nothing needs a table: the done step event already records each judgment with its verdict and reason, and field_values.source already carries the engine's model identifier (ADR-026). Decision: (1) Every AI step caches by default. Before dispatching a record, the runner computes the step's judgment signature — a hash over the adapter id, the model identifier, the operator prompt and the generated output shape (the declared or default provides, the uses: list) — and the record's input hash — a hash over the fields the judgment reads: the uses: fields when declared, else the projection minus the step's own provides and minus every field namespaced by this pipeline (a needs-all step would otherwise see its own last answer as a changed input and never cache), as canonical sorted JSON. If a done event for this identity carries the same signature and input hash, the record is skipped (skipped_cache, reason same_judgment): a filter re-applies the stored verdict (pass advances, fail freezes — and the declared provides written then are still the current values), a compose has nothing to write (its fields are current with that provenance). No time window by default — same question, same facts, same answer — and (for the one case that needs a clock, a prompt that reads it: "posted in the last month") cache: Nd bounds reuse to N days when an operator wants a periodic re-read; respend: true (or cache: 0d) turns it off. (2) The signature is recorded where the judgment is: the done event's detail gains signature and input, and field_values.source for ai/* steps becomes ai/<op> @ <model-id>#<signature> (ADR-026 amended), so a provenance row says which question produced it and gtme show --provenance and SQL over current_values can tell two prompts' outputs apart. (3) The respend warning narrows to what remains uncached: a paid enrich/verify with no freshness window. An AI step no longer warns; exclude: judgment memory stays as the routing memory it always was. (4) Sources stay excluded by design — a source's spend is its query, and "search once, consume the group" already covers it. (5) --simulate runs against a copy of the ledger, so it cache-skips exactly as an armed run would — free and deterministic, which is what a rehearsal should be. Deferred steps (ADR-038) cache-skip before they submit, so a re-run after a collection submits only what changed. Consequences: Double spend on judgments becomes an explicit choice instead of the default, with no new grammar (cache: and respend: already exist) and no migration (a JSON detail and a provenance string). The receipt's cached column now counts judgments, and its avoided-cost line prints ? for AI steps until per-record cost is attributable (a batch's COST is per call). A changed prompt, model, or input re-judges without anyone clearing anything, which is the property a cache keyed by time cannot have. The provenance format change is breaking for anything parsing ai/* @ <model> exactly; pre-launch, that is a one-line adjustment in this repo's own tests and nowhere else. ~250 LOC: the signature and input hash in the AI adapter's OPEN handshake (the runner cannot see the assembled prompt, so the adapter reports the signature in SCHEMA — or the runner computes both from what it already holds: the step config and the projection; the build decides which and records it), the lookup in the runner's prepare, the verdict re-application, the provenance suffix, the narrowed warning, receipt wording. Spec impact: AMEND (proposed diff in this packet's second commit) — §7 cache check extended to AI roles with the signature and input rules, and the respend warning narrowed; §10a provenance format ai/<op> @ <model-id>#<signature>; §3 step_events.detail keys (prose, no DDL); §11 milestone M16.

ADR-040: Deliver preflight — the target is checked before anything sends

Status: Accepted (2026-08-29 — drafted from ROADMAP.md's "Deliver preflight"; human-approved 2026-08-29) Context: gtme plan proves gtme's own contracts — needs, provides, credentials, config — with zero network (§7). It knows nothing about the target's state, and the class of failure that produces is the one attestation (ADR-036) cannot see: every request returns 200 and nothing meaningful sends. The incident that names it: 269 leads added to a campaign whose template never referenced the merge variable the copy was in — 0 replies, no error anywhere, days lost before anyone looked at the template. The same class: a campaign that is paused, a sequence with fewer steps than the copy assumes, an A/B variant pulling a template the variables do not fill. Low reply numbers are ambiguous between "the pack is wrong" and "the plumbing is wrong"; without a preflight the two cannot be told apart, so the wrong thing gets tuned. Decision: (1) A deliver adapter MAY declare preflights: true in its manifest (§6, beside attests), meaning it can check the live target against what the step is about to send — read-only, zero spend, one or two calls per run, never per record. (2) The runner asks before it sends. At --dry-run and at the start of an armed run, before any record session, the runner opens a short session per preflighting deliver step — OPEN with preflight: true and END, no records — and the adapter answers PREFLIGHT {status, checks} (§5): ok, blocked (a readable fact says sends would be meaningless or wrong), or inconclusive (the target could not be read — reported ok with a warning, since the sends themselves would surface an unreachable target and a false block is the dangerous direction here too). checks is a list of {name, ok, detail} for the receipt. A blocked armed run fails the step before a single record is dispatched — its records stay at the previous state, the run finishes failed, and --resume after the fix preflights again; a dry run reports the checks either way. (3) The checks derive from the step, not from config. The adapter knows the campaign and the variables: targets; the operator writes nothing. The one knob is adapter config preflight: false to skip. (4) Instantly is the first preflighting adapter, with four checks: the campaign exists and is Active; the sequence has at least the step count the copy assumes (the highest _step_N suffix among the variable targets); every variables: target appears as {{name}} in some step body; no A/B variant lacks one. (5) plan stays zero-network — preflight is a rehearsal-time and arm-time act, which is where the target's state is a fact rather than a forecast. Under --simulate a credentialed process adapter is stubbed (SPEC §8) and so is its preflight — a counted gap, as ever. Consequences: The receipt gains the target's side of the story: "send: preflight ok (4 checks)" or the exact check that blocked, before anything is spent on a broken campaign. Closes the last "succeeded and sent nothing" class the campaign story had open, beside attestation's "persisted nothing". Additive to the wire (unknown message types are ignored); one manifest key; no migration. ~60 LOC runner, ~150 in the Instantly adapter's HTTP file (the sequence representation is the only vendor-shaped part, fixture-tested), a fixture adapter for the acceptance. The maintenance exposure is "Instantly changes its sequence JSON" — one file, one fixture, the exposure the adapter already carries. Spec impact: AMEND (proposed diff in this packet's second commit) — §5 PREFLIGHT message and OPEN preflight; §6 preflights capability; §8 dry-run/arm behaviour and receipt wording; §10 item 6 (Instantly's checks); §11 milestone M17; spec/schemas/msg-preflight.schema.json, msg-open.schema.json, manifest.schema.json.

ADR-041: A second agent surface — gtme help --bindings

Status: Accepted (2026-08-29 — from the agent round-trip finding, VALIDATION.md 2026-08-29 and AUDIT.md (b) item 3; human-approved 2026-08-30) Context: gtme help --agent is the document an agent is meant to work from alone (§8), and it says nothing about bindings — the one route to an API gtme has no adapter for. The first real round-trip proved both halves: the agent assembled two pipelines from the doc without help, and then had to pull spec/binding-schema.json out of the binary with strings to write the adapter it needed. The contract is large (templating, pagination, extraction, error verdicts, fixtures) and needed by one agent in ten; folding it into the pipeline doc would make the common case worse to serve the rare one. Decision: Two surfaces, one pointer. gtme help --agent stays the pipeline/operator document and gains one sentence and a bindings field pointing at the second surface. gtme help --bindings prints, as one JSON document: the binding schema (spec/binding-schema.json, embedded, byte-identical), the discovery path (~/.gtme/adapters/<name>/binding.yaml, $GTME_ADAPTER_PATH, id → directory naming), one reference binding as a worked example (the fullest shipped one, verbatim; amended 2026-08-30 — first written "smallest", which picked the deliver binding: the one role with no extract surface, so the worked example taught least exactly where the round-trip evidence says authors write sources), the conformance expectation (fixtures beside the binding; gtme run --simulate serves them), and — once ADR-042 lands — the adapters add / search / verify verbs. Regenerated from embedded artifacts, never hand-maintained, like help --agent. The unknown-adapter error already points at it. Consequences: An agent that needs an adapter finds the contract in one command instead of in the binary's string table; the pipeline doc stays short. Acceptance mirrors §8's round-trip: the printed schema equals the spec artifact, the printed reference binding validates against it, and an agent given only help --bindings can author a binding that gtme plan resolves. ~80 LOC in internal/cli, no spec beyond §8. Spec impact: AMEND (proposed diff in this packet's second commit) — §8 verb table and the help --agent section; §11 milestone M18; AUDIT.md (b) item 3 applied by it.

ADR-042: Bindings live in a registry, not in the binary

Status: Accepted (2026-08-29 — design conversation 2026-08-29; human-approved 2026-08-30). Decision (4) partially superseded by ADR-059, which retires the reference-twin carve-out: the binary carries the floor and no vendor. Context: The binary carries the floor — csv/*, http/*, sql/*, ai/*, group/* — plus four reference bindings that twin the Go vendor adapters. Every further vendor is a binding: a directory of YAML and fixtures, data the engine interprets, unable to execute code, its blast radius bounded by what the engine permits (ADR-022). Compiling vendors into the binary would grow it without bound and put a release between an operator and an adapter; leaving them only on local disks makes every operator rediscover the same API shapes (the round-trip's fourteen probe runs). §13 parks an "adapter marketplace" as a non-goal — correctly, if marketplace means accounts, payments and hosting. An index and a fetch verb are neither. The first real evidence of the supply side arrived before the registry did: an agent authored a working CRM source binding in minutes from the schema alone. Decision: (1) Bindings are URL-addressed. gtme adapters add <ref> takes github.com/<owner>/<repo>/<path>[@<tag|sha>], fetches the repository over HTTPS as a tarball at that ref (no git dependency; private repositories via a GITHUB_TOKEN stored with gtme secret), copies the binding directory — binding.yaml and its fixtures/ — into ~/.gtme/adapters/<id, slashes → dashes>/, and writes .source.json beside it: the ref as given, the commit it resolved to, the content's sha256, the install time. Pinned by construction; gtme adapters update <id> re-fetches at a newer ref only when asked, never implicitly. (2) Nothing installs unverified. gtme adapters verify <id> — run by add before it completes, and any time after — validates the binding against the schema, runs its conformance fixtures offline, and prints the reviewable surface: the hosts its requests will call, the credentials it will demand, its needs/provides. Fixtures are mandatory; a binding that ships none, or whose fixtures fail, does not install. (3) The registry is an index, not a monorepo. A public repository, gtme-bindings, holds index.json (spec/schemas/registry-index.schema.json: id, description, vendor, role, entity type, needs/provides summary, credentials, source {url, path, ref, sha}, content sha256, tier) and the verified set — bindings maintained there, whose fixtures the registry's CI runs. A community entry points at its author's own repository, listed by pull request, fixtures required. gtme adapters search <text> fetches the index (GTME_REGISTRY overrides the URL) and matches id, vendor, description and role; gtme adapters lists what is installed with its source and pin. (4) The binary carries the floor and the reference twins, nothing else. New vendor bindings — the CRM source the round-trip produced first among them — are registry entries; the reference twins in spec/bindings/ stay because they are the conformance kit for the Go adapters, not a distribution channel. (5) §13's non-goal narrows to what it meant: a hosted marketplace — accounts, payments, a service — stays out of v0. Bundles (ADR-029) already carry bindings with a hash manifest; an installed binding's .source.json is what a bundle records for it. Consequences: Agents and humans search the same index, and an agent that finds nothing writes a binding (ADR-041) and can publish it by pull request — the supply side the round-trip demonstrated becomes a loop. Verification is the registry's product, not authorship. Any web surface over the index (a page per entry, generated from the YAML and its fixtures) is a registry-side concern outside this spec; the spec's contribution is that such a page cannot lie, because an entry whose fixtures stop passing stops being listed. ~350 LOC: tarball fetch and extract in internal/adapters, .source.json, the three verbs in internal/cli (verify reuses the binding conformance runner --simulate already has), the index schema, help --bindings gaining the verbs. The registry repository is seeded with the CRM binding, its fixtures minted from the payloads its run retained (ADR-030's minting verb, built as part of this). Spec impact: AMEND (proposed diff in this packet's second commit) — §6 discovery (URL-addressed bindings, .source.json); §8 verbs adapters add / search / verify / update; §10a registry tier; §13 non-goal narrowed; spec/schemas/registry-index.schema.json; §11 milestone M19. ROADMAP.md "Adapter marketplace" promoted.

Implementation Decisions

Predates the ADR log above; recorded per SPEC.md §12. Newest last.

2026-08-12 — Module path

Q: What Go module path? Choice: github.com/gtme-run/gtme. Why: Matches the repo owner's GitHub account. Nothing outside go.mod, imports, and the make build ldflags depends on it; rename with a single gofmt -r-style sweep if the repo lands elsewhere. Spec impact: None (spec-invisible, internal naming).

2026-08-12 — ULID generation without a new dependency

Q: SPEC §3 wants ULIDs; §2 pins a minimal dependency set that has no ULID library. Choice: A ~90-line internal/ulid (48-bit ms timestamp + 80 random bits, Crockford base32, entropy incremented within a millisecond so IDs minted in a tight loop still sort in creation order). Why: Cheaper than a dependency for the one property we actually need — lexicographically sortable unique ids. No parsing, no interop with other ULID producers is required. Spec impact: None (spec-invisible; satisfies the DECIDED requirement without adding a dependency).

2026-08-12 — Identity key aliases (additive table)

Q: SPEC §4 says a stronger key replaces a weaker one in place. What happens to the vacated key? A record that later arrives carrying only the old weak key would find nothing and create the duplicate the spec forbids — and the cross-run cache (§1 bet 2) would miss on every re-source. Choice: New additive table identity_aliases(entity_type, identity_key, identity_id) (migration 0002). Every key a record carries that is weaker than the winner is aliased at create time, and the vacated key is aliased on upgrade. Lookup prefers a live identities.identity_key and falls back to an alias. INSERT OR IGNORE means an alias never re-points, so v0 still never merges two existing identities — when the strong key is already taken by another identity, the weaker one is left alone. Why: No change to any DECIDED table or to the wire contract; it only makes "do not create a duplicate" actually hold across runs. Identity merging remains out of scope for v0. Spec impact: None (additive table, spec-invisible per the ADR-010 litmus).

2026-08-12 — Name-hash key inputs

Q: §4 specifies sha256(lower(full_name) + "|" + lower(company_domain)). Which incoming field names, and is the domain normalized? Choice: Name comes from full_name, else name, else first_name + last_name; whitespace is collapsed as well as lowercased. The domain half is run through the same registrable-domain normalization used for company keys, so Acme.com, www.acme.com, and https://www.acme.com/careers produce one key. Company keys accept domain, company_domain, or website. Why: Unnormalized inputs would silently fork one person into several identities, which defeats the cache. Accepting the obvious column aliases keeps csv/source (§10.1) flag-free, per the §1 "minimal interactive overhead" rule. Spec impact: None (fills in an underspecified normalization rule; doesn't change the DECIDED formula).

2026-08-12 — Provenance sentinels for run-less writes

Q: field_values.run_id is nullable but step_events.run_id/step_id are NOT NULL, yet §4 requires an identity_upgraded event even for imports that have no run. Choice: run_id is left NULL on field values written outside a run; step events written outside a run use the sentinels run_id='(none)' and step_id='(import)'. Why: Keeps the DECIDED schema untouched while making run-less writes representable and greppable. Spec impact: None (schema untouched; sentinel values are internal).

2026-08-12 — Ledger timestamps and single writer

Q: §3 says RFC3339; SQLite string comparison is used for ordering. Choice: All timestamps stored as 2006-01-02T15:04:05.000Z07:00 in UTC, so lexical order equals chronological order. The *sql.DB pool is capped at one connection. Why: Millisecond precision keeps same-second tie-breaks meaningful; a single writer avoids SQLITE_BUSY between pooled connections at v0 concurrency (the worker pool defaults to 4 and only the runner writes). Spec impact: None (timestamp format already implied by RFC3339 + DECIDED ordering requirement).

2026-08-13 — Built-in adapters run in-process over pipes

Q: §1 bet 5 says built-ins are "invoked through the same protocol boundary as external ones". Does that mean the binary must re-exec itself per step? Choice: No. A built-in runs as a goroutine wired to the runner by two io.Pipes and speaks the identical NDJSON protocol; external adapters get os/exec. adapters.Session is the only thing the runner talks to, so neither side knows which transport it got. Why: The boundary that matters is the message contract, and this keeps it exactly one implementation wide. Re-exec would also break go test, where os.Executable() is the test binary. Consequence worth knowing: pipes are unbuffered, so the runner streams input from a goroutine (Session.SendStream) — writing all input before reading deadlocks against an adapter that starts replying early. That bug was found and fixed in M2. Spec impact: None. Reinforces ADR-005's transport seam: adapters.Session is exactly the abstraction a future stdio transport would sit behind.

2026-08-13 — Dependencies added beyond §2

Q: §2 pins a minimal dependency set; three additions came up. Choice: github.com/anthropics/anthropic-sdk-go for the AI engine, golang.org/x/term for the no-echo gtme secret set prompt, and golang.org/x/net/publicsuffix (already implied by §4's eTLD+1 requirement). Why: The Anthropic SDK is the vendor-supported client — hand-rolling the Messages API would mean owning request shapes, retries and error classification for no gain. x/term is the only sane way to read a secret without echoing it. Everything else in §2 stands; there is no HTTP framework, no CLI framework, and no ORM. Spec impact: None (implementation dependency, not an observable-behavior change; recorded here per CLAUDE.md's "no dependencies beyond §2 without a Decision" rule).

2026-08-13 — AI model default, and how cost is attributed

Q: §2 decides model claude-sonnet-4-6. Choice: Kept as the default, overridable per step (model: in with) or globally (GTME_AI_MODEL). A small static price table in internal/ai/pricing.go turns token usage into the COST messages the receipt sums; an unknown model reports unpriced rather than guessing, and the receipt prints ?. Why: The spec's model is real and appropriate for batch classification. claude-sonnet-5 has since shipped at the same list price and is the better default when you want it — hence the override rather than a hard-coded id. Refusing to invent a price for an unknown model keeps the receipt trustworthy. Spec impact: None (the default stays as DECIDED; the override is additive).

2026-08-13 — Prompt-and-validate instead of structured outputs

Q: Should AI steps use the API's structured-output mode? Choice: No. The adapters send a strict output contract in the system prompt, validate the answer (JSON array, one element per record, keys that match the batch, correct types), and on failure retry once with the validation error appended — exactly the loop §2 specifies. Why: It is what the spec describes, it works on every engine including the claude-code CLI and the fixture engine, and structured outputs are not available on the spec's default model anyway. The validator is also stricter than a schema: it catches invented, dropped and duplicated identity keys. Spec impact: None (implements the DECIDED retry loop as written).

2026-08-13 — A fixture AI engine, selected by environment

Q: M5 says "tests use a fake engine". How does the test choose it without polluting the pipeline format? Choice: GTME_AI_ENGINE=fixture plus GTME_AI_FIXTURE=<script.json>, a JSON array of scripted responses. engine: in a pipeline still accepts only the two engines §2 defines. The sentinel response "$auto" makes the engine synthesize a schema-valid answer for whatever batch is in flight, so a test can exercise batching without hard-coding identity keys. One script is shared by every AI step in a process, so a run consumes it in order. Why: Keeps the public config honest while making the retry path (garbage, then valid) testable offline and deterministically. Spec impact: None (test-only selection mechanism; engine: in the YAML is unchanged).

2026-08-13 — Wildcard needs means "project everything"

Q: The runner builds each projection strictly from the needs properties. An AI step wants whatever is known, which no property list can express. Choice: A needs schema that is open-ended and names no properties (additionalProperties: true, no properties) marks the step as needs-all; the runner projects every field the ledger holds for the record. gtme plan prints "projects: (every field known about the record)". Why: Without it, an AI filter would receive an empty object. Expressing it in the schema keeps the rule in the manifest rather than in an adapter-specific branch in the runner. Spec impact: None. Superseded in spirit by ADR-004 for AI steps specifically — uses: is now the mechanism an AI step declares its dynamic needs with; this wildcard convention remains the general-purpose "needs everything" idiom for non-AI steps that want it.

2026-08-13 — Cache-skip also checks provenance

Q: §7 skips a record when "every field in the step's provides already has a current value". An adapter whose provides schema has optional properties can never satisfy that, so it would be re-called (and re-paid for) forever. Choice: A record is skipped when all provides fields are fresh or when that adapter (matched on id@version) wrote anything for the record inside the freshness window. Events record which rule fired (fresh_in_ledger / already_answered_by_adapter). Why: It answers the question the cache exists to answer — have we already paid this provider for this record — and a version bump still invalidates. Spec impact: None (refines a DECIDED cache rule's edge case without changing the observable skip/don't-skip contract for the common case).

2026-08-13 — Optional credentials

Q: §6 makes a missing declared credential a plan-time error. An AI step that runs on the claude-code engine needs no API key, so declaring ANTHROPIC_API_KEY as required would fail plans that are fine. Choice: New additive manifest field credentials_optional: injected when present, reported by gtme plan as a warning when absent, never a plan error. Required credentials behave exactly as §6 says. Why: Keeps "fail before spending" for the cases where the key is genuinely required, without inventing conditional-credential syntax. Spec impact: None (additive manifest field; §6's required-credentials rule is unchanged).

2026-08-13 — A probed CSV schema is closed

Q: csv/source provides whatever its header says. Should the probed schema stay open-ended? Choice: No. The static manifest schema is open (the planner may have no config to probe with), but a probed header is exact, so that schema is closed (additionalProperties: false). Why: It is what makes gtme plan catch "this pipeline needs linkedin_url and your CSV has no such column" before a single record is processed, instead of discovering it per record at run time. Spec impact: None (an implementation-level tightening of an already-open manifest schema).

2026-08-13 — Delivery idempotency keys are canonicalized

Q: §8 keys a delivery on "the value of the field named by idempotency". Taken literally, Jane.Doe@Acme.com and jane.doe@acme.com are two different keys — and the same person gets mailed twice. Choice: Trim always; when the value parses as an email, lowercase it through the same identity.NormalizeEmail the ledger uses. Non-email values keep their case, because an external id legitimately can be case-sensitive. Why: Double-delivery is the exact failure this table exists to prevent, and email is case-insensitive everywhere it matters. Found by a test that asserted on the stored key. Spec impact: None (closes a real gap in §8's idempotency rule without changing the field it keys on).

2026-08-13 — Pipe mode is stage-buffered, and adapters can be discovered from a path

Q: How much streaming does pipe mode really do, and how do tests reach external adapters that are not installed in ~/.gtme/adapters? Choice: Each pipe stage reads its whole input before dispatching, then emits. GTME_ADAPTER_PATH (colon-separated) is searched before ~/.gtme/adapters. Why: Batching (one adapter invocation per batch_size records) and per-step cache accounting both need the full working set anyway, and buffering keeps the run/pipe semantics identical — the acceptance test relies on gtme freeze producing a pipeline that runs the same way. Per-record streaming is a v1 question. The search path is how the repo's own fixture adapters are found without installing anything. Spec impact: PARTIALLY SUPERSEDED by ADR-005. The stage-buffering half documented pipe mode, which is deleted from v0 entirely (see AUDIT.md for the dead-code removal). The GTME_ADAPTER_PATH discovery mechanism is unaffected — it's used by the e2e test harness independent of pipe mode — and remains live.

2026-08-13 — Provider exit codes survive the runner

Q: §8 defines exit codes 3 auth, 4 rate-limited, 5 network. Adapters are the things that meet providers. Choice: internal/httpx classifies provider failures and the error carries an ExitCode(); the runner wraps errors with %w, and gtme run / the pipe verbs exit with that code. An external adapter that exits 2/3/4/5 has its code preserved through adapters.ExitError. Why: "Rate limited" and "your key is wrong" deserve different retry behaviour from a caller or a cron wrapper, which is the whole point of the code table. Spec impact: None (implements the DECIDED exit-code table faithfully).

2026-08-13 — Saved segments live in the ledger

Q: §8 has gtme query --save NAME but does not say where the SQL goes. Choice: Migration 0003_saved_queries.sql, a saved_queries table. Why: A segment is a statement about the ledger's contents; it belongs beside the ledger, gets backed up with it, and needs no new file format. gtme query is enforced read-only twice over: the statement must be a single SELECT/WITH/EXPLAIN, and it runs on a connection opened mode=ro. Spec impact: None (fills in the storage location --save left unspecified).

2026-08-15 — M7 internals: embedded registry, injected variables, fixture upgrades

Q: Where does the runtime read the field registry from, how does a deliver adapter receive the step-level variables: mapping, and what happens to the fixtures the old hard-coded contracts leaned on? Choice: (1) spec/fields/*.json is embedded via a tiny package spec (spec/embed.go) and parsed once per process (internal/registry, sync.Once); a conformance test asserts embedded == on-disk so the binary can never disagree with the artifacts it was built from. Rule ids resolve to the same internal/identity functions §4 key derivation uses — one implementation per rule, as §4a demands. (2) The runner injects variables into the OPEN config map (now stated in SPEC §9): the adapter owns the egress mapping, the runner owns projection and the on_missing completeness check, so neither reimplements the other. (3) mock/deliver upgraded to the dynamic contract (email floor + variables) so the campaign-zero shape is e2e-tested offline end to end; mock-enrich-py's fields became mock.score/mock.note and apollo_id became apollo.id under §4a's namespacing rule. AI compose output is trimmed at the adapter boundary — canonical values must be fixed points of their rule, and models emit stray whitespace. Why: Everything observable landed in SPEC.md v0.4; these are the internal seams that make it hold. Spec impact: None beyond v0.4 (the OPEN-config sentence was added to §9 as part of that pass).

2026-08-16 — M8 internals: the binding engine, reference bindings, and simulate

Question: How does the binding tier (SPEC §10a) sit inside the existing runner without a second execution path, what did porting the three Go adapters actually reveal, and how does --simulate (§8) guarantee zero network and zero durability? Choice:

  1. The engine is an ordinary built-in adapter. internal/binding.Engine implements adapters.Adapter behind the same Session/NDJSON boundary as every other adapter — the runner cannot tell a binding from a process adapter, which is what "same manifest surface, plan treats both tiers identically" (§10a) demands. Discovery mirrors §6: ~/.gtme/adapters/<name>/binding.yaml resolves via a loader hook wired in internal/adapters/all (no import cycle). Embedded bindings live under spec/bindings/ (embedded in package spec like the field registry).
  2. The three reference ports are shipped data, not registered built-ins. Their ids belong to their Go twins until the twins' removal is decided; the M8 receipt diff installs them under shifted vendor prefixes (apollox/, harvestx/, instantlyx/) and compares ledgers, never ids. attio/assert — the net-new pure-YAML integration — IS registered.
  3. The graduation rule fired twice during the port, as designed. harvest/profile's recent_posts (a second call per record) and role_history (computed line formatting over structured positions) are tier-2 judgment: the binding covers the single-call acquisition surface, the diff asserts the Go twin's extras are exactly {role_history}, and the process adapter keeps the computed fields. Likewise instantly/add-to-campaign's campaign-NAME resolution (second endpoint + matching) stays tier-2; the binding takes the campaign id. This is the two-tier taxonomy holding, not a porting failure — and the deterministic halves of those extras are exactly the shape ADR-027's sql/enrich is for, later.
  4. Schema hardening from real ports (spec/binding-schema.json, still the canonical artifact): extraction gained paths: waterfalls (first non-empty wins — Apollo's domain fallback), absent: sentinel values (Apollo's locked-email placeholder, zero-valued counts), skip_if_input: (emit the resolved public linkedin_url only when the record arrived without one — ADR-020's recovery path), and the engine-owned linkedin classify-and-route transform (§4's rule, executed at the engine boundary); request bodies gained the $variables splice (resolved variables minus any referenced individually — declarative first-class-field routing); pagination declares in: body|query; extract is required only for source/enrich; config defaults come from config_schema defaults. A declared retry windows/rate_per_hour makes the binding refuse to load — the engine does not enforce them yet, and a silently ignored policy is worse than a loud gap.
  5. ai/* provenance is now ai/<op> @ <model-id> (§10a, ADR-026), computed runner-side by ai.ProvenanceModel — an exact mirror of the engine-resolution the adapter itself performs, so the runner (which owns ledger writes) and the adapter (which owns the call) cannot disagree. Under the fixture engine the identifier is fixture, which doubles as the synthetic marker simulate needs.
  6. Simulate is ephemerality plus injected env. The CLI copies the ledger with VACUUM INTO (a consistent snapshot under WAL) and runs against the throwaway copy — the §8 durability exclusion implemented as ephemerality, with no schema flag and nothing for projection/cache to filter. The runner injects GTME_SIMULATE=1 (bindings switch to fixture-served mode) and, for AI steps, GTME_AI_ENGINE=fixture plus a synthesized ["$auto"] script — an operator-recorded GTME_AI_FIXTURE in the process env still wins, which is §8's replay-when-present rule. Credentialed non-AI process adapters (and bindings without fixtures) are stubbed: records pass through untouched, counted and receipted as simulation gaps; a stubbed filter judges nothing, so downstream when: gates hold records back — the gap made visible rather than papered over with a fabricated verdict. Missing credentials downgrade to warnings under --simulate only. Why: M8's acceptance was the receipt diff, and it did its job: full field parity for Apollo (sentinel drop, LinkedIn routing, domain fallback), identity-upgrade parity for Harvest on the shared surface, identical dry-run artifacts for Instantly — and two honest graduation findings that would otherwise have been silently absorbed into engine creep. Spec impact: spec/binding-schema.json hardened as above (changelog v0.6); no other normative text changed — §10a/§8 were written in the v0.5 pass and this build implements them.

2026-08-16 — M9 internals: groups

Question: Where do ADR-021's runner-owned semantics live so that adapters stay ledger-blind, plan stays offline, and dry runs stay rehearsals? Choice: (1) A group source resolves no adapter: the planner marks the step (IsGroupSource, displayed as group:<name>) with open provides — the needs-all wildcard path — and the runner projects members directly; nothing crosses the wire protocol. (2) Plan-time group checks live on the Plan (CheckGroups), called by the CLI with the ledger opened lazily — only when the plan references groups — so planner.Build stays ledger-free and a group-free pipeline still plans without gtme init; the check is enforced under --simulate too (a missing group is a contract error, not a credential). (3) The terminus adds only completers who are not already members — re-asserting membership would put noise in the event trail the whole feature exists to keep readable — and under dry/simulate it reports would-adds without creating the group at all. (4) Suppression is checked after the idempotency floor (idempotency answers "did this exact delivery happen"; suppression answers "does policy allow another"), and it applies to dry runs too: a rehearsal that ignored the contact policy would rehearse the wrong send. (5) Membership gates load each referenced group's set once per step and share the when: Gated counter — a gated record is simply not dispatched, which is the whole judgment-memory mechanism. (6) gtme groups add/remove edits are idempotent (no-op events are skipped); snapshots require an identity_id column and run on the read-only connection; bare keys resolve via FindByKey with --type for the ambiguous case. (7) The v0 answer to grouping a filter's failers is a snapshot over the run layer (--query on run_records.verdicts), per the ADR's after-the-fact-grouping position — exercised in the acceptance tests rather than given a verb. Why: The qualify → judgment-memory → send → suppress loop runs end-to-end offline in the acceptance tests, with zero AI calls on the top-up — the determinism claim made checkable. Spec impact: None beyond v0.7 — this entry records the internal seams.

2026-08-16 — M10 internals: campaign bundles

Question: What exactly travels in a bundle, and how does a bundle run resolve against a machine that has its own adapters? Choice: (1) A bundle packs the pipeline YAML, every referenced binding at its exact version WITH its conformance fixtures (that is what makes simulate-on-bundle fully offline), the registry slice (for review and diffing — the binary still enforces its embedded copy, per §4a's one-artifact rule), and a hash manifest. AI prompts already live inside the pipeline YAML in v0, so there are no separate prompt files yet; saved queries are not referenced by pipelines in v0 and are not packed — recorded here so ADR-029's fuller list is a checklist for when those referents exist. (2) Resolution precedence while a bundle runs: bundle adapters/ first, then built-ins, then the search path — the frozen binding version wins over whatever the binary or machine carries, which is what "resolves nothing outside it except credentials" means operationally. (3) External process adapters do not travel — executables are not data (the ADR-022 security line, applied in reverse) — and freeze warns per step instead of silently narrowing the bundle. Built-in process adapters ship inside the gtme binary itself. (4) Content hashes are verified on every bundle run; a mismatch is a validation error naming the file — diffable means the manifest is the truth. (5) Relative input files (a source CSV) and credentials stay operator-provided, the same category as membership and cache. (6) gtme freeze now preserves the pipeline's own name (--name wins; frozen-<id> only for ad hoc runs) — a bundle carries the campaign's identity, and the old always-rename behavior predated anything caring about the name. Why: The acceptance ran the full loop offline: freeze from one ledger, simulate on a clean one with zero keys and zero network (source served from bundled fixtures), dry-run live against a local fixture server, tamper detection on a one-byte edit. Spec impact: Changelog v0.8; no new normative text — §8's bundle section was written in the v0.5 pass and this build implements it.

2026-08-16 — M11 internals: the transform floor and the payload path

Question: Where does payload capture live given adapters own HTTP and the runner owns the ledger, and how do the transform-floor steps execute without becoming adapters? Choice: (1) Payloads ride as an optional attachment on outbound RECORDs — the minimal §5 counterpart ADR-030's mechanism implied (flagged in changelog v0.9); the runner is the retention authority (adapters only offer), computing TTL from the manifest declaration. The binding engine re-encodes its decoded JSON canonically (httpx consumed the raw bytes; point-in-time truth here is semantic, not byte-exact — recorded, not hidden); http/enrich keeps the raw response bytes. In this build the engine and http/enrich attach; the Go vendor adapters do not yet (queued adoption, stated in §6). (2) http/enrich lives in the binding package — it IS the engine's acquisition surface, sharing the template and path language; markdown conversion uses golang.org/x/net/html, already a required module (publicsuffix), so no new dependency entry. Oversized responses fail the record — never truncated silently. Under --simulate the runner stubs it as a counted gap; payload replay is the ROADMAP verb. (3) §7 dynamic provides reuses the existing ProbeSchema seam (csv/source's config-known-schema mechanism) instead of inventing a parallel one; derived needs come from a {{record.<field>}} scan of the step config; config freshness_days doubles as the step's cache window via a generic planner rule. (4) SQL steps are runner-owned like the group source: one set-based query per step on the read-only connection, timeboxed 30s; :run_id is bound only when the query references it, and scope is guaranteed at APPLICATION — result rows for identities outside the run are dropped and counted — so a badly-scoped query cannot leak. The pass-column and membership-style verdict forms both apply verdicts through the same path as adapter VERDICTs. Query-hash provenance is the first 12 hex of sha256 over the trimmed text. Plan-time field-name validation is entity-type-blind for SQL steps (the pipeline's entity is not knowable at resolve time); the runtime registry check per record still enforces. (5) Eviction: gtme vacuum plus an opportunistic purge at armed-run start; simulated runs skip it (their ledger is a throwaway copy). Why: M11's acceptance runs the whole floor offline: markdown into a declared field with the payload retained and the second run cache-skipped (fetch-once economics observable in the receipt), sql/enrich with hash provenance, both sql/filter styles, the plan gates, and vacuum touching nothing unexpired. Spec impact: None beyond v0.9.

2026-08-16 — M12 internals: the universal Out floor

Question: How thin can http/deliver and csv/deliver be while staying honest about what a generic Out cannot know? Choice: (1) http/deliver IS the engine's deliver role invoked anonymously: OPEN synthesizes a binding from config and every record runs the same deliverRecord path a named binding uses — the §10a unification made literal, and zero new delivery semantics. The default body is the resolved variables object ($variables splice); a body: template overrides. The step-level idempotency: key is plan-REQUIRED, never defaulted — ADR-023's "even the trivial case cannot infer semantics", enforced where a default identity key would have silently guessed. (2) Auth declared in an http/* step's config (auth.env) now resolves through the same machinery as manifest credentials (env, then ~/.gtme/secrets) and is plan-checked — previously a config-referenced env var would have bypassed the secrets file entirely. (3) csv/deliver is a plain process adapter (file I/O, not HTTP): columns are the sorted variables: targets behind a leading identity_key; the header is written under O_CREATE|O_EXCL so concurrent sessions cannot double-write it, and each row is a single O_APPEND write so sessions interleave whole rows. Re-runs append nothing because §8 idempotency holds records back before the adapter is ever invoked — the file inherits delivery semantics instead of reimplementing them. Why: The acceptance runs both halves offline, including on_missing holding the nameless record out of both the webhook calls and the review file — the Out floor inherits every deliver guarantee (dry/armed, completeness, idempotency, suppression) because it sits behind the same runner semantics. Spec impact: None beyond v0.10.

2026-08-16 — The name is gtme, and the rename is total

Question: The project needed its public name before first push: gtm collides with existing tools, and the working name had always been provisional. Choice: gtme — as in GTM engineer, which is also the audience — decided by the human. Because the repo is pre-public with zero users, the rename is total and shim-free: binary gtme, home ~/.gtme, env prefix GTME_, schema $id host gtme.spec, module path github.com/gtme-run/gtme, and every command in every document, historical entries included (pre-publication, the tool effectively always had this name). One exception preserved: gtm-campaign-zero-* strings name real external Instantly campaigns and keep their historical spelling. The operator's live state was copied ~/.gtm → ~/.gtme (non-destructively; the old directory remains until the operator removes it). Why: Renames get exponentially more expensive after publication — the module path is identity in Go, and env vars/home paths ossify into user scripts. This was the last free moment to do it. Spec impact: Changelog v0.12; §2 binary name and every env/path reference (applied wholesale).

2026-08-17 — M13 internals: delivers as steps

Question: Where does the role-gating of the deliver-only keys live, and how does "is this record stopped" survive fail verdicts that no longer stop records? Choice: (1) Role-gating (variables:/on_missing:/idempotency:/ record:/suppress: valid only on deliver-role steps) lives entirely in the planner, which is the layer that knows adapter roles — internal/pipeline keeps only value-shape checks (the on_missing enum, a well-formed suppress.within), valid on any step syntactically. A document carrying the old top-level deliver: block is rejected by the existing KnownFields decode, with the error rewritten to name the fix (move the block into steps:) per §0's errors-are-prompts. (2) A withheld send (on_missing: skip, suppression) now advances run_records.state to the deliver step — previously it did not, which was invisible while the deliver step was always last; advancement is what makes the record eligible for later steps and the terminus. (3) Deliver-step fail verdicts share run_records.verdicts with filter fails (the §3 shape is unchanged), so "is this record stopped" needs step roles: the runner classifies via its plan's deliver-step id set; gtme runs, holding only a bare run id, counts fail verdicts without classifying and its summary wording now says so ("filtered, or a send withheld"). (4) Two deliver steps sharing a target adapter share that target's (target, idempotency) dedupe scope — a direct consequence of the §3 key, so the M13 acceptance pipeline's two sends use two different targets (mock/deliver, csv/deliver), which is also ADR-031's motivating multi-target case. (5) gtme plan renders the §7 call-out as one block after the step list — send surface: N deliver step(s), one line per step with target and touch scope. Why: The M13 acceptance runs offline end to end: dry-run resolves variables for both sends with zero deliveries writes; armed delivers to both and re-runs deliver nothing twice on either; a record failing between the sends delivers to the first only and misses the terminus while a suppressed record completes and joins it; the misplaced keys and the old shape both fail at plan/validation naming step and key. Spec impact: None beyond v0.13 (marked built as v0.14).

2026-08-17 — CI: the local gate, mechanized

Question: The repo went public and PRs merge on local make check evidence alone — should CI exist, and what should it run? Choice: One GitHub Actions workflow (.github/workflows/ci.yml) running exactly make check on pushes to main and on PRs — no separate CI-only test list to drift from the local gate — plus a GOOS=darwin GOARCH=arm64 go build cross-compile job, which catches darwin-only build breakage on a linux runner (pure Go, no cgo, per §2; §13 targets darwin/linux). The live provider smoke tests (make live) stay a human gate per §12 and never run in CI. Why: The suite was designed to make this free — every test runs offline against fixture adapters, no keys, nothing sends — so CI is the existing gate on a runner, not a second quality system to maintain. Spec impact: None (repo tooling; the gate it runs is already the CLAUDE.md rule).

2026-08-28 — M14 step 1 internals: declared AI provides (ADR-033)

Question: How does a step-level provides: reach the adapter, where does the <pipeline>.<field> namespacing happen, how do a filter's RECORD and VERDICT interact in the runner, how do AI manifests become entity-agnostic without changing the manifest format, and what does "the config maps a name to a canonical field" (SPEC §4a/§7) mean in this build? Choice: (1) The planner derives the schema — each declared name namespaced <pipeline>.<name> unless it already carries a dot, the declared type/enum carried through, every declared field required (the operator asked for it; the model must produce it), nothing else admitted — and the runner injects it into OPEN config.provides, the variables pattern's second instance (declared in the AI manifests' config_schema with the same "never authored inside with:" note; a with: {provides:} fails plan pointing at the step-level key). The adapter is a pure schema consumer: prompt shape, answer validation, and what it emits all derive from the injected schema, or from the manifest's static shape when nothing is injected — so ai/filter and ai/compose now share one code path and the two hardcoded shapes are gone. (2) Bare names always namespace, including a name that coincides with a canonical field (state is a canonical person field — a location; a judgment called state silently landing there is exactly the collision ADR-033 exists to prevent); gtme plan notes the coincidence and names the opt-in. The opt-in is canonical: true on the declaration — the explicit form §4a/§7 implied without naming; queued as a spec question, human-approved and applied the same day (SPEC v0.16): the name must be canonical for the pipeline's entity type and a declared type/enum must agree with the registry entry, checked at plan. No aliasing (the declared name IS the field): the realistic need is "make this one global", and a rename would put a second name in the prompt for nothing. (3) Entity-agnosticism is a manifest declaration, "entity_type": "*" (SPEC §6, the second queued question — approved and applied the same day): an agnostic step's entity type is the pipeline's — its source step's resolved entity type, or none after a group source, in which case name validation is entity-blind exactly as SQL steps already are — and its static needs/provides validate against that type. The planner keys on the declaration, not on the ai/ id prefix, so external adapters can opt in; a source declaring "*" fails plan (it has no pipeline type to take). A compose declaring nothing inside a company pipeline fails plan naming first_line with the fix ("declare provides: on this step"), since its person-vocabulary default has nowhere legal to land. (4) In the runner a filter's RECORD is validated against the derived schema and written like any output, pass or fail, but never advances the record — only the VERDICT does (SPEC §5); a RECORD that fails validation freezes the record whatever the verdict says (new failed flag on the work item; previously failItem left a later verdict free to advance it). The adapter emits RECORD before VERDICT per key. The derived-schema validation replaces the manifest's static ValidateProvides only for steps that declared; csv/source and http/enrich keep their existing paths. (5) identity_key (every AI role) and pass (filter) are reserved — a declaration naming them fails plan; reason is allowed and, on a filter, feeds both the VERDICT and the stored field. (6) The fixture engine synthesises from the shape the adapter now passes in (ai.Request.Fields): first enum member, a typed sample, else Fixture <bare name> for <key> — so --simulate stays schema-valid for any declaration. (7) The plan note for a namespaced need whose prefix is the pipeline's own name says so ("this pipeline's own judgment field") instead of the vendor-coupling wording, which would mislead. Why: The M14 step-1 acceptance runs offline end to end: the {state: {enum: [now, later]}, rationale: {}} filter stores qualify.state for all three judged records (the failing one included) with ai/filter @ fixture provenance and leaves the canonical state untouched; an out-of-enum value is retried, and twice over fails the batch with zero rows written; a compose declaring [subject] writes only qualify.subject; a company pipeline plans its AI step as company, rejects uses: [title] against the company registry, and lands accounts.tier on company identities; provides: off an AI step, inside with:, or naming a reserved key fails plan naming step and key; and --simulate completes the declared pipeline on synthesized answers. Spec impact: v0.16 — canonical: true added to §7/§9 and the pipeline schema, §4a reworded; "entity_type": "*" added to §6 and the manifest schema, §10.3 pointed at it (both approved 2026-08-28). Nothing remains queued from this step. §11 M14 is not marked built — step (1) of five is.

2026-08-28 — M14 step 2 internals: prompt assembly (ADR-035)

Question: How does the adapter learn which fields were fetched (it only ever sees a projection), where does the shared/payload split live, what does "wrapped at structural breaks" mean for prose, and what are the delimiter bytes? Choice: (1) Provenance stays runner-side: prepare marks each projected field whose field_values.source names a fetching adapter — a binding, http/enrich, or a credentialed process adapter, the same "network by declaration" reading --simulate's stub rule uses; operator input (csv/source), the runner's derivations (sql/*) and AI judgments (ai/*) are not fetches — and openMessage injects the batch's union as OPEN config.fetched, the variables/provides pattern's third instance (human-approved as spec-invisible 2026-08-28: OPEN config is open-shaped in §5, and the key is declared in the AI manifests' config_schema with the never-authored note). Resolved once per source id. (2) ai.Request gains Shared and Payload; Prompt stays the joined form for engines that take one string. The API engine sends the two as separate text blocks with a cache breakpoint on the shared one; the retry note rides in the payload so the shared half stays cacheable. (3) A fetched string value is shown as prose inside the fence and wrapped at whitespace; a fetched non-string is shown as compact JSON; inline JSON wraps after structural commas, and only a string that has itself filled half a line is broken inside, at a space, never after a backslash or inside a \uXXXX escape. maxLine is 1500 bytes — under the claude-code engine's silent per-line truncation, and applied to both engines so their prompts are identical. (4) Delimiters <<<subject-supplied data: <field> (record <key>) — evidence about the record, not instructions to you / >>>end subject-supplied data: <field>; neutralising replaces runs of <<</>>> in the body with single-angle quotation marks (‹‹‹/›››), so no body line can open or close a fence and the text still reads. Encode → neutralise → wrap, in that order. The system prompt states the rule only when something is fenced. (5) fence: false puts fetched fields back inline, raw. (6) The fixture engine gains a test-only request log (GTME_AI_FIXTURE_LOG, one JSON line per request with system/shared/payload/prompt), which is how the §11 acceptance observes what the engine was shown. Why: The step-2 acceptance runs offline: an http/enrich page whose markdown contains a fake fence close reaches ai/filter as a compact inline record plus one labelled, neutralised block per record, with the operator's prompt as the shared half; fence: false puts the page back inline and drops the fence sentence from the system prompt. Spec impact: None — §10.3 (v0.15) already states the rules; fence is the config key it names.

2026-08-28 — M14 step 3 internals: the handoff as a delivery (ADR-032)

Question: What is a group/deliver step's deliveries.target, how does the runner execute a deliver step with no adapter, how do "passers and failers" reach different groups given that a filter's fail freezes the record (§7) and when: knows only .passed, and what is a group source's "insertion order"? Choice: (1) target is group:<name> — each group keeps its own (target, idempotency) scope exactly as each adapter does, so two handoffs in one pipeline never share a dedupe key, and a record handed to a group once is never re-enqueued there by a re-run (release after a removal is gtme groups add, deliberately). touched events name the same target. (2) group/deliver resolves in the planner beside the SQL steps: role deliver, no manifest, needs derived from variables: with no floor, config exactly with.group, entity-blind name validation against the pipeline's entity type. In the runner it goes through the same prepare path as any deliver — idempotency, suppression, completeness, dry-run receipting — and where an adapter step would open a session it instead advances each record: deliveries row, touched under the record: scope, and an added event on the target group (detail {pipeline, step, handoff: true}; an existing member is not re-asserted). The receipt gains a per-handoff line, armed or would-have. (3) In one pipeline, failers cannot follow a .passed gate, so the acceptance's "different groups" is intake-before-judgment plus passers-after: intake → judge → stage-2 (when: judge.passed). The failers' route is a consumer pipeline source: {group: intake} with exclude: [stage-2] into held — judgment memory does the routing, no sql/filter needed; ADR-032's "second sql/filter over the verdict" stays available for finer cuts. Noted for the spec keeper: §8's "routing different when: outcomes to different groups" is satisfiable across filter steps only where a record survives the first gate; a .failed form remains unjustified (ADR-032). (4) A group source serves current members ordered by the added event that made each one a member (newest added per identity — a re-added record queues at the back), tie-broken by event ULID, LIMIT N; gtme groups show keeps key order. limit: is validated in internal/pipeline (only a group source may carry it; ≥ 1), since no role knowledge is needed. (5) groups remove --note lands as detail.note; --note on add is refused (there is no reason to record). (6) The one-commit-point warning is a plan-level Warnings list printed after the send surface — the first non-blocking plan-level observation; step notes stay per step. Why: The step-3 acceptance runs offline: two handoffs dry-run to two receipts with zero group events, zero deliveries, zero groups; armed, intake gets 3, stage-2 gets 2, the re-run hands off nothing twice; the consumer parks the failer in held; limit: 2 sources the two oldest-added members of a hand-built group; --note is on the event; the warning fires only when a network send shares the pipeline. Spec impact: None — §7/§8/§9 (v0.15) state all of it.

2026-08-28 — M14 step 4 internals: the transform floor's read surface (ADR-037)

Question: What are the two vocabulary views called, how does the plan reach the ledger, what exactly is a "config value" (a sql/* step's own with: {query: …} must not be one), where do resolved values go, and how does help --agent learn the schema? Choice: (1) Migration 0007: current_values (= current_fields with json_extract(value, '$') so a query sees plain values) and group_membership (= group_members joined to groups for group_name), plus ADR-036's deliveries.status/sent_at (the same migration §3 queued; step 5 gives them semantics). Mirrored into spec/ledger.sql and §3's DDL as §3 said they would be. (2) planner.Build(ctx, p, ledger) — the ledger is read-only at plan (SPEC §7), so gtme plan now opens it like gtme run does; Scope carries it. (3) A config value is a map whose ONLY key is query or segment with a string value, found under a with: key at any depth — never the with: map itself, which is a container (a sql/* step's with: {query: …} is exactly that). One column → list, one row and one column → scalar, else a plan error; zero rows a plan error; a missing segment names gtme query --save. The pipeline's own config is never mutated: the step's resolved config is a copy, and Plan.ResolvedPipeline() is what CreateRun snapshots into runs.config_json, so gtme freeze reproduces the values a run actually used rather than a segment that may have drifted. Plan notes show the rows (first ten, then a count). (4) EXPLAIN QUERY PLAN runs on the read-only connection with :run_id bound to a placeholder when referenced; SQLite resolves every name without executing. A step whose query names relations, group_members or group_membership (whole words) gets the cross-record note. (5) help --agent migrates a throwaway ledger in a temp dir and reads sqlite_master + pragma_table_info, so the columns are what the binary builds; implementation-only objects (the conformance test's allowlist) are left out; four query shapes and the two config-value forms are static text, each shape checked by the e2e to run. (6) sql/enrich fails plan naming sql/transform; the runner's provenance follows the step id automatically (sql/transform @ <hash>). Why: The step-4 acceptance runs offline: {query:} resolves a scalar path and {segment:} a list into an AI step's fields, the plan shows both, the run records them, freeze carries them; zero rows, a missing segment and two columns each fail plan; an unknown column fails plan with SQLite's own message; the relations join is annotated and the per-record transform is not; help --agent lists the views with their columns and its shapes run. Spec impact: §3 DDL and spec/ledger.sql gained the queued deltas (v0.16 changelog); the §10a heading typo fixed. No normative change beyond what v0.15 stated.

2026-08-28 — M14 step 5 internals: attestation (ADR-036)

Question: How does an attesting adapter report its verdict (the ADR's spec-impact list named §3/§6/§8 but not §5), when does an attesting delivery advance, what does a contradicted delivery leave behind, and how does the Instantly re-read decide? Choice: (1) A new §5 message, ATTEST {key, status, reason}, emitted after the acknowledgement RECORD — approved 2026-08-28 over an optional field on the ack RECORD and over reusing VERDICT: it parallels VERDICT, the ack stays untouched, and §5's "unknown message types are ignored" makes old runners forward-compatible for free. msg-attest.schema.json, the wire README table, and attests in the manifest schema follow. (2) The runner hears ATTEST only from a step whose manifest declares attests (an undeclared adapter's ATTEST is logged and ignored). For such a step the ack RECORD does not advance the record; the ATTEST does — confirmed advances and refines the row; inconclusive advances, the row stays accepted, the receipt names the record and why; contradicted writes the deliveries row (the lead exists at the target — idempotency must hold so a re-run never re-sends into a duplicate), marks it contradicted, and fails the record. An attesting adapter that acknowledged a record but never attested it is settled inconclusive at session end. (3) sent_at is never written by this build; SetDeliveryStatus refuses sent — promotion is the listen verb's compare-and-swap. (4) Instantly re-reads GET /api/v2/leads/{id} once per lead with no retries (a failed re-read is inconclusive, not a retry storm) and compares the first-class fields by name and custom variables under payload or custom_variables; a field the response carries no readable value for is inconclusive, never confirmed by omission. (5) gtme show <key> gains a deliveries list with target, status, run_id, created_at, and sent_at only when set; the receipt prints one attestation line per attesting step and one line per inconclusive record. (6) The mock/attest fixture adapter (MOCK_ATTEST=confirmed|contradicted|inconclusive|silent) is the §11 acceptance's instrument. Why: The step-5 acceptance runs offline four ways: confirmed refines all three rows and advances; contradicted keeps three rows marked, fails three records, and a re-run sends nothing; inconclusive and silent both leave rows accepted, advance, and name every record in the receipt; a non-attesting adapter is accepted with no attestation lines; show carries the status and never a sent_at. The Instantly unit test drives all four outcomes against stubbed re-reads. Spec impact: §5 ATTEST (v0.16 changelog) — approved; nothing else beyond v0.15.

2026-08-29 — M15 internals: asynchronous steps (ADR-038)

Question: Where does the batch surface live, how does a collection find its records, what does a per-record batch request look like, and how does the fixture engine stand in for a provider that holds work across processes? Choice: (1) ai.BatchEngine (Submit, Collect) beside ai.Engine, with ai.Deferrable(engine) as the capability check; the api engine implements it over the Message Batches API and prices results at half; the fixture engine implements it only when GTME_AI_FIXTURE_DEFER is set (so --simulate stays synchronous), persisting each batch to <script>.batches/<token>.json and the script cursor to <script>.cursor — a $pending entry is consumed once across processes, as a provider's "still processing" would be observed once. (2) A deferred submit is one request per record, custom_id = identity key, the shared half of the prompt as a cached block in every request; collection parses each result against its own record through the existing parse, so the shape, enum and type rules are unchanged; an invalid or errored answer fails that record by omission (no retry exists against a batch) with a LOG naming it. (3) In the runner, PENDING marks every unanswered item in the session with a pending step event (detail: the token) and the run finishes pending when any step left work in flight; runStep reads PendingTokens for the step and dispatch opens one session per token — its OPEN carrying pending: {token} — ahead of fresh chunks; an answered collection logs collected (detail: the token), which is what retires the pending event. (4) Collect-first is the CLI's: before resolving a run id, gtme run looks up the pipeline's latest run and resumes it when pending; --simulate is exempt (throwaway ledger, never defers). (5) The last-step rule is checked in Build (which knows the position); the respend warning is a per-step Warnings list printed as warning: lines — an AI step is "remembered" when an exclude: names a group the pipeline writes (its terminus or a group/deliver target); a paid enrich/verify is one that declares credentials or a positive cost estimate. respend: is parsed in internal/pipeline (rejected on the source) and read by the planner. Why: The M15 acceptance runs offline: submit → pending with the token on the receipt and in gtme runs; a plain run collects (still processing) and submits nothing new; the next run collects — verdicts, one COST row under the same run, terminus, done; the run after sources fresh and judgment memory gates the judged; the last-step rule, the claude-code and dry-run warnings, the respend warning and its two silencers all fire as specified; the api engine's batch path is unit-tested against a stubbed Batches endpoint. Spec impact: None beyond v0.17 (marked built as v0.18).

2026-08-29 — M16 internals: the judgment cache (ADR-039)

Question: Who computes the signature (the ADR left it to the build), what exactly is hashed, where does the lookup sit, and how does a cached filter fail render? Choice: (1) The runner computes both keys in prepare (internal/runner/judgment.go), from what it already holds: the signature from the adapter id, the model ai.ProvenanceModel resolves for the step with its credentials only (never the simulate override, so a rehearsal skips what an armed run would), the trimmed operator prompt, the step's provides schema (declared or manifest) and the sorted uses: list; the input hash from the projection — the uses: fields when declared, else everything minus the step's own provides and every <pipeline>.* field — both as canonical JSON (encoding/json sorts map keys) → sha256 → twelve hex. The adapter is untouched. (2) The lookup (ledger.LastJudgment) is the newest done event for the identity, any run, whose detail carries the same signature and input, bounded by cache: when set; the keys are written onto every AI done event (pass, fail, collected) via the work item, and the signature joins provenance as #<sig>. (3) A reused filter fail sets the verdict, does not advance, and counts as cached and filtered on the receipt; a reused pass or compose advances and counts as cached; avoided cost prints ? (AI manifests carry no per-record estimate). (4) cache: 0d on an AI step is read by the planner as respend: true. (5) The AI half of ADR-038's respend warning is retired in the planner; the paid-enrich half stays. (6) Under --simulate the copy of the ledger carries the judgments, so the rehearsal cache-skips; the two tests that re-ask the same question on purpose (fence: false, the deferred fixture) now say respend: true, which is what an operator would say. Why: The M16 acceptance runs offline: an unchanged re-run makes zero model calls (fixture log) with every record same_judgment and the fail verdict re-applied; a prompt change re-judges the judge and leaves the compose cached; one changed input re-judges one record; cache: 1d with the events aged re-judges; respend: true re-judges; provenance carries the signature and gtme show --provenance shows it; the AI respend warning is gone; --simulate skips; a deferred step cache-checks before submitting and a re-run after collection submits nothing. Spec impact: None beyond v0.19 (marked built as v0.20).

2026-08-29 — M17 internals: deliver preflight (ADR-040)

Question: Where in the runner does the preflight session sit, what does a blocked run leave behind, which of Instantly's variables: targets are template variables, and how does an adapter opt out? Choice: (1) runStep runs the preflight before preparing any record of a preflighting deliver step, at dry-run and armed alike; the session is OPEN (preflight: true) + END, and the adapter's PREFLIGHT is recorded as a step-level preflight event with status, reason and checks. A blocked armed run returns an error from the step: no record was prepared, so nothing is claimed, nothing delivered, run_records.state stays at the previous step, the run finishes failed, and --resume preflights again; a dry run reports the block and continues. An adapter that emits a RECORD, VERDICT or ATTEST in a preflight session is an error; one that emits nothing is inconclusive. (2) Instantly reads GET /api/v2/campaigns/{id} once, decoded loosely (an unreadable status or sequence is inconclusive, never a guess); its first-class targets (first_name, last_name, company_name, personalization) map into the lead body and are not template variables, so only the remaining targets are checked for {{name}}; the assumed step count is the highest _step_N suffix among the targets; the variant check applies to a step's own copy — <x>_step_N must be in every variant of step N — while other variables are decoration a variant may omit (the first draft checked every variable in every variant and blocked on a {{title}} present in one variant only, which is not a hole). (3) Opt-out is adapter config (preflight: false), read by the runner before it opens the session, so an adapter never sees a preflight it was told to skip. (4) The mock/preflight fixture adapter (MOCK_PREFLIGHT=ok|blocked| inconclusive|silent) is the acceptance's instrument, delivering to MOCK_DELIVER_LOG like mock/deliver. Why: The M17 acceptance runs offline: blocked dry reports the check and writes nothing; blocked armed leaves zero deliveries, zero record sessions, three records at sourced, a failed run, and a resume after the fix delivers all three; inconclusive and silent deliver with a warning; preflight: false skips; a non-preflighting adapter beside a preflighting one is never asked. Instantly's checks are unit-tested for active, paused, too few steps, an unreferenced variable, an unfilled variant, an unreadable shape and an unreadable target. Spec impact: None beyond v0.21 (marked built as v0.22).

2026-08-30 — M18 internals: help --bindings (ADR-041)

Question: How does the document carry the schema byte for byte, which shipped binding is "the reference", and what does it say about registry verbs that ADR-042 accepted but M19 has not built? Choice: (1) The document is assembled as a struct and encoded with HTML escaping off, then spec/binding-schema.json is spliced in as the last member from the embedded bytes — encoding/json compacts a RawMessage, and §11 M18 wants the artifact identical. The e2e test decodes that member back into a RawMessage (which preserves the bytes) and compares it to the file. (2) The reference is chosen at run time as the fullest binding.yaml under the embedded spec/bindings/ (today apollo/search, a source binding; amended 2026-08-30 — first built as ADR-041's "smallest", which picked attio/assert: deliver is the one role exempt from extract.records/fields, so the example omitted extraction, pagination and error verdicts, the parts the round-trip's source-authoring agent actually needed), printed verbatim with its fixtures/conformance.json, plus its id, role, credentials and the directory name it installs under; nothing is hand-copied, so the example can never drift from what the binary validates. (3) The verbs that touch a binding today (plan, run --simulate, run --dry-run, freeze --bundle, help --bindings) are the verbs member; ADR-042's adapters verbs are a separate registry member whose status says they are queued for M19 — an agent given this document must not be told to call a verb the binary lacks. M19 moves them into verbs and drops the flag. (4) discovery prints the rule (id with slashes → dashes, nested also accepted, the id inside must match), the two environment variables that alter the path, and the live adapters.SearchPath(). (5) help --agent gains a bindings: {see, does} member and the verb, and nothing else; the e2e test asserts it does not carry the schema. Why: The acceptance is the round-trip: the test validates the printed reference against the printed schema in-process, installs it on the printed path under a shifted vendor prefix (so the built-in cannot be what resolves), and gtme plan accepts a pipeline that uses it, with only the credentials the document names set. ~170 LOC, not ADR-041's estimated ~80 — the difference is the discovery, fixtures and verbs sections, without which the round-trip criterion is not met by the schema alone. Spec impact: None beyond v0.23 (marked built as v0.24).

2026-08-30 — M19 internals: the bindings registry (ADR-042)

Question: How does the offline acceptance drive "a local tarball server" without changing the address grammar, what exactly does the content hash cover, how does verify drive a fixtures run for an arbitrary binding, and what happened to ADR-042's "minting verb"? Choice: (1) The two GitHub endpoints (api.github.com for ref → commit, codeload.github.com for the tarball) are env-overridable — GTME_GITHUB_API, GTME_GITHUB_CODELOAD — so the e2e stands up one local server speaking both path shapes; github.com/… stays the only address form. GITHUB_TOKEN is read from the secrets store first, the environment second. (2) The content hash is sha256 over the binding directory's files, sorted by slash path, each written as path NUL body NUL, with .source.json itself excluded; the registry's CI computes the same rule. .source.json carries §8's quartet plus the repository url and path — update needs them to re-fetch, and remove-and-re-add would otherwise be the only pin move. (3) fixtures/conformance.json gains two optional members: config (the step config verify opens the run with; must cover the config schema's required keys) and input (one sample record's fields, for a role that consumes records). verify drives the real engine with the fixture set as its HTTP seam — the conformance-kit shape — and fails a source whose fixtures yield zero records; a binding whose fixtures cannot be driven fails with the message naming the member to add. Older fixture files without the members still serve --simulate unchanged. (4) add installs into the home half of the §6 search path (never GTME_ADAPTER_PATH, the operator's own overlay), refuses an id that is already installed (update is the only pin move, staged and renamed so a failed update leaves the old install intact), and consults the index best-effort: an unreachable index warns and skips the hash check, a hash mismatch refuses. (5) ADR-042's consequences say "ADR-030's minting verb, built as part of this", but §8's DECIDED verb table — "the entire v0 verb set" — carries no such verb and ROADMAP still parks it ("needs a small design pass on invocation shape"). The verb table wins: no verb was added; the first registry entry's fixtures are minted registry-side from the round-trip's retained payloads (values synthesized — the payloads hold real contact data and the registry is public). Flagged in the M19 PR for the human; promoting the verb remains a session-packet decision. Why: The M19 acceptance runs offline end to end: search by vendor, verified pinned install with .source.json, failing/missing fixtures refuse, index mismatch refuses, the installed binding plans and simulates, update moves the pin only when asked, the listing shows source and pin, and a frozen bundle carries .source.json. Spec impact: None beyond v0.23 (marked built as v0.25).

ADR-043: Apollo splits along the vendor's own line — masked search, paid reveal

Status: Accepted (2026-08-30 — from Campaign 1's stop, VALIDATION.md 2026-08-30 and AUDIT.md (b) item 4; human-approved 2026-08-30) Context: Apollo withdrew the value-bearing search response from API callers: POST /api/v1/mixed_people/search returns HTTP 422 (LEGACY_PEOPLE_SEARCH_DEPRECATED); its designated replacement mixed_people/api_search returns masked rows — id, first_name, title, last_name_obfuscated, has_email/has_direct_phone/has_* booleans, organization name plus has_*, no pagination object (top level is total_entries + people). The revealed person now lives behind POST /api/v1/people/match (per-credit): probed live 2026-08-30, it returns the full old surface — email (+email_status), last_name, name, linkedin_url, city/state/country, and a full organization (name, website_url, linkedin_url, industry, primary_domain, estimated_num_employees). The shipped apollo/search binding's provides can no longer be satisfied by one call, and §10.2's description of it is now false against the live vendor. Decision: Split the capability where the vendor split it. (1) apollo/search stays a source and becomes honest about masking: it calls api_search, provides apollo.id, first_name, title, company_name, and apollo.has_email (the pay-signal, kept so a filter can prefer reachable contacts before anyone pays), pages by page with termination on empty/short pages (total_entries is informational; there is no pagination object), and costs $0. (2) A new apollo/enrich binding wraps people/match: needs.required: [apollo.id], provides the revealed surface (email, email_status, last_name, full_name, linkedin_url, city, state, country, company_name, company_website, company_linkedin_url, company_industry, company_domain, company_employees), declares its per-credit cost, and retains payloads. (3) The works_at relation emission (runner-owned, keyed on org domain) happens where the domain now first exists — after apollo/enrich, not after search. (4) The canonical §9 example and help --agent's examples move to the shape the economics now force: masked source → ai/filter on free fields (first_name, title, company_name) → apollo/enrich gated when: <filter>.passed → onward — reveal is paid only for records that survived judgment, which is the fetch-cheap-judge-then-pay composition the spec already prefers (ADR-024/030); Apollo has effectively adopted gtme's own economics, and the pipeline shape should teach that. (5) Both bindings stay shipped reference twins in spec/bindings/ (they are the conformance kit); fixtures re-record from live, sanitized. Consequences: Campaign 1 unblocks with a better cost story (reveal credits spent only past the filter, visible per-step in the receipt); the first live vendor withdrawal becomes a worked example of the maintenance loop (observed at $0 by the validation gate, fixed as data + fixtures, never code). Downstream needs that assumed full_name/email straight from the source now resolve through the enrich step — gtme plan narrates exactly this. ~0 LOC in the engine; the change is two YAML documents, their fixtures, and the examples. Spec impact: AMEND (proposed diff in this packet's second commit) — §10 item 2 rewritten and item 2a added; §9 and §8 example pipelines; §11 milestone M20; changelog v0.26. AUDIT.md (b) item 4 applied by it.

2026-08-30 — M20 internals: the Apollo split (ADR-043)

Question: How does a masked row get an identity, what happens to the obfuscated last name in the ledger, and how do the bundle/twin/example tests survive losing the value-bearing search? Choice: (1) Build-found and folded into §10 item 2 (v0.27): the masked provides gains last_name, carrying Apollo's own obfuscated form ("D.") — §4's name-hash tier is the only derivable identity path for a masked row and requires first AND last; gtme plan notes the weak tier; the reveal writes the true value, which supersedes at read time (current_values prefers the newer row at equal confidence). People sharing a first name and an obfuscated initial in one pull collide on this tier — the binding's header says so and says to reveal early when it matters. (2) apollo/search bumps to version 2; apollo/enrich (needs apollo.id, per-credit people/match, payloads retained) joins builtinBindings. (3) The plan-time missing-need error gains a provider hint — "installed adapters provide it: email ← apollo/enrich" — because both round-trip agents read error text as documentation, and "needs email" is only half a message when the answer is one step away. (4) The bundle acceptance now freezes two external bindings (masked source + reveal) feeding the built-in attio/assert; the builtin twin test finds Jane by field rather than by email key (masked rows key on the name hash). (5) Fixtures are synthesized from live-probed shapes (the probes are in the session log; values are fictional), and the live smoke ran the real pair end to end: 3 masked at $0, 3 reveals at $0.03, email legitimately absent on one (extraction treated it as absent, the run continued). Why: make check green including the rewritten conformance kit; TestHelpAgentExamplesPassPlan proves the new canonical example plans; the §11 M20 offline acceptance holds, and the live smoke closes the loop against the real vendor. Spec impact: The one-line §10.2 provides amend, recorded in v0.27.

2026-08-30 — Readiness fixes: workspace-scoped Anthropic keys; sql/* in the agent doc

Question: How does an identity-linked Anthropic key reach the api engine's headers, and how does help --agent list steps that have no manifest? Choice: (1) ANTHROPIC_WORKSPACE_ID becomes an optional credential on both AI manifests — it rides the existing secrets/injection path (gtme secret set, runner-injected session env), and the engine adds the anthropic-workspace-id header whenever it is present. No new config surface; a credential-shaped fact travels as a credential. (2) The doc gains a sql_steps member — hand-shaped usage + semantics for sql/filter and sql/transform — rather than synthetic entries in adapters: they have no manifest, no version, and no resolvable needs/provides, so pretending otherwise would teach agents to expect fields that don't exist. §8's MUST-list is a floor; this adds to it. Why: Both were found by the round-2 agent (VALIDATION 2026-08-30): it lost a run to the missing header and found the sql steps only via the ledger notes. Unit test asserts the header on a stub; the e2e surface test asserts the doc names both steps. Spec impact: None (§8 floor unchanged; optional credentials are §6 manifest surface already).

ADR-044: Delivery dedupe scopes to the campaign, not the adapter

Status: Accepted (2026-08-31 — from Campaign 1 story 5, VALIDATION.md 2026-08-30 and AUDIT.md (b) item 5; human-approved 2026-08-31. Instantly's name scope in (2) and the backstop wording in (3) amended 2026-09-29 by ADR-062: a scope is the destination's stable identifier) Context: deliveries dedupes on UNIQUE(target, idempotency) with target = the adapter id, so a record delivered to campaign A is silently cache-skipped when a later pipeline delivers to campaign B through the same adapter. Observed twice live: campaign zero's 2026-08-30 re-run skipped eight records whose leads no longer existed in any campaign, and Campaign 1's story 5 surfaced the shape. Group handoffs already scope naturally (target = group:<name>, ADR-032); only vendor deliver adapters are global. A global "never touch this address twice through this adapter" is a suppression policy, and gtme already has an explicit surface for policies: groups and suppression windows (ADR-021). A table constraint is the wrong place for it. Decision: (1) A deliver manifest MAY declare idempotency_scope: "<config key>" — the name of a config key whose resolved value becomes the delivery's scope. The ledger key becomes UNIQUE(target, scope, idempotency) (scope defaults to ''), so "same campaign, same record" still cannot double-add, while a different campaign is a fresh decision — protected, when the operator wants protection, by suppression groups, which see touches across every adapter. (2) Declarations: instantly/add-to-campaign scopes on campaign (the configured name — a renamed campaign is a new scope, stated in §10.6), attio/assert on object, csv/deliver on path; group deliveries keep their target scoping and scope = ''. (3) Migration 0008 rebuilds deliveries with the scope column and the triple UNIQUE; existing rows backfill scope = ''. One-time consequence, stated plainly: a record previously delivered through a now-scoped adapter no longer matches the new key, so the next armed run of the same campaign MAY re-add it once (the vendor's own idempotency — Instantly skips duplicate emails per campaign, Attio asserts — is the backstop). Pre-alpha, no external ledgers exist; acceptable. Consequences: Campaign semantics match operator expectations; the global guarantee moves to the surface built for choices (gtme groups + suppression), where it is visible and reviewable instead of implicit in a constraint. The dry-run receipt's cache-skip line now means "already in this campaign". Spec impact: AMEND (this packet's second commit) — §3 DDL; §6 manifest field; §8 deliver idempotency; §10 items (instantly, attio, csv/deliver); spec/schemas/manifest.schema.json, spec/binding-schema.json; §11 milestone M21. AUDIT.md (b) item 5 applied by it. Editorial: §8's doubled "deliver idempotency" heading.

2026-08-31 — M21 internals: scoped delivery dedupe (ADR-044)

Question: Where does the scope value come from at run time, and what does the migration do to a live ledger? Choice: (1) The runner resolves it per step from the resolved config (st.Config[manifest.IdempotencyScope]), trimmed and stringified; a group handoff (no manifest) or an undeclared adapter yields ''. It rides every delivery read and write: the dedupe check, the insert, and both attestation status updates — an attestation can only touch its own scope's row. (2) Migration 0008 rebuilds the table (SQLite cannot alter a UNIQUE) and backfills scope = ''; the canonical spec/ledger.sql mirrors the post-0008 shape, which is what the conformance schema test compares. (3) Declarations landed as spec'd: instantly campaign (configured name), attio object, csv/deliver path; the binding schema rejects idempotency_scope on a non-deliver binding. (4) Acceptance: one record through one adapter into two scopes lands two rows, re-running either adds nothing, each scope's artifact holds exactly one row, and no csv/deliver row carries an empty scope. Why: make check green; the e2e proves the §11 M21 clauses offline. Spec impact: None beyond v0.28 (marked built as v0.29).

ADR-045: Native idempotency unlocks on-change re-delivery

Status: Accepted (2026-08-31 — design conversation on ADR-044's Attio consequence; human-approved 2026-08-31) Context: ADR-044's scoped dedupe still hard-blocks every repeat delivery, which is right for send-shaped targets (an email touch is one-shot) and wrong for sync-shaped ones: attio/assert is an upsert — its own endpoint matches on matching_attribute, re-delivering cannot duplicate, and the operator wants changed values to land. The binding tier already carries the distinction (idempotency: native | ledger, §10a) but only as documentation: the runner never used it. Whether a repeat is safe is a property of the target adapter; whether it is wanted is the operator's. A pure step-level "send twice OK" knob would let a pipeline opt an email campaign into double-sends — the exact mistake the floor exists to prevent. Decision: (1) The manifest surface gains idempotency: native | ledger (§6, mirroring §10a's binding key; bindings bridge it through): native declares the target upserts, so re-delivery cannot duplicate. (2) deliveries gains variables_hash — the hash of the record's resolved variables: values at delivery time. (3) A deliver step MAY set redeliver: always | on_change | never (§9). Defaults: a native-idempotent target defaults to on_change; everything else defaults to never (today's block). always and on_change are plan errors on a target that did not declare native — safety is not configurable onto an unsafe target. (4) On-change semantics: an already-delivered record re-delivers only when its resolved variables hash differs from the stored one; unchanged skips with reason unchanged; a re-delivery updates the row's hash and run and resets its status to accepted for a fresh attestation cycle (created_at keeps first delivery). (5) Declarations: attio/assert is native (already declared); instantly and csv/deliver stay undeclared (csv appends — repeating duplicates the row; an email add gains nothing from repeats). Consequences: Attio becomes a true sync target out of the box — changed values flow on every run, unchanged records cost nothing and say so in the receipt — while send-shaped targets keep the hard floor. The dry-run receipt distinguishes already_delivered from unchanged, so a reviewer sees why nothing will send. Spec impact: AMEND (this packet's second commit) — §3 (variables_hash), §6 (manifest idempotency), §8 (redeliver modes and defaults), §9 (redeliver: grammar), §10a (the binding key's meaning completed); §11 milestone M22; schema artifacts ride the build (machine-compared).

2026-08-31 — M22 internals: on-change re-delivery (ADR-045)

Question: Where does the change signature come from, and what does a re-delivery do to the existing row? Choice: (1) Variables now resolve before the dedupe decision on a deliver step (they were resolved after it): under on_change, "the same delivery" means the same resolved values, so the hash — sha256 over the resolved targets, sorted, target NUL value NUL — must exist when the skip/deliver call is made. A step with no variables: hashes the empty map, so it re-delivers only under always. (2) RecordDelivery becomes an upsert on the (target, scope, idempotency) key: a conflict is a re-delivery — the row keeps its first created_at, takes the new hash and run, and returns to accepted so attestation runs a fresh cycle (a contradicted row heals the same way when the next changed delivery succeeds). (3) The mode resolves at plan time onto the step (RedeliverMode), printed in the plan for deliver steps whenever it is not never, and validated there: always/on_change without idempotency: native on the manifest is a config problem naming the rule. (4) attio/assert needed no change — its binding already declared native; the bridge now carries the declaration onto the manifest. Why: The M22 acceptance runs offline against a counting local server: one assert, an unchanged skip with reason unchanged, a changed re-assert with the same single row and a moved hash, always repeating, never restoring the floor, and csv/deliver refusing the key at plan. Spec impact: None beyond v0.30 (marked built as v0.31).

ADR-046: Honest costs — a recorded basis, and operator-declared rates

Status: Accepted (2026-08-31 — drafted by the backlog session from issues #29 and #31; human-approved 2026-09-01 by merging the packet) Context: Nothing in the ledger or a receipt distinguishes a cost that was measured (read back from a vendor's response) from one that was estimated (multiplied out from a number a binding author typed). SPEC §10.3 already draws the distinction for harvest/profile — "emit COST from response metadata if present, else config-estimated" — and then the ledger boundary discards it: both land in costs.amount_usd and render identically as dollars spent (#29). Compounding it, cost.amount_usd in a binding is a fixed number, so for any vendor whose price depends on the customer's plan no honest value exists at authoring time — a shared binding must choose between a confidently wrong receipt and a silent one, while the Go tier already solves this by exposing the rate as config (harvest/profile's cost_per_profile_usd) (#31). "Every dollar has a receipt" is the project's own framing; a receipt that cannot separate a measurement from an assumption is weakest exactly where it is trusted most. Decision: (1) The COST wire message MAY carry basis: "measured" | "estimated". Absent means estimated — an unlabeled dollar figure is a guess until proven otherwise. measured is reserved for an amount derived from vendor-reported cost metadata in the response; an amount multiplied out from a config or manifest rate is estimated even when the unit count is exact. (2) costs gains basis TEXT NOT NULL DEFAULT 'estimated' (migration and spec/ledger.sql ride the build; existing rows backfill estimated, which under the rule above is what every pre-M23 amount was). (3) Receipts show it: a purely measured total prints as today; a purely estimated total prints total: $X (estimated); a mixed run splits — total: $X ($Y measured + $Z estimated). gtme runs <id> mirrors the live receipt. (4) cost.amount_usd MAY be a template resolved from config ("{{config.cost_per_record_usd}}"), the exact mechanism pagination.page_size already uses; the binding declares its knob in its own config_schema. An unresolved or unset template costs $0 at run time, and gtme plan prints est/record: unset instead of $0.0000 — the gap is visible at the moment it matters, before anything is spent. (5) Authoring guidance (§10a, gtme help --bindings, CONTRIBUTING's checklist): a page-billed endpoint declares per: request — per: record counts emitted records, and a limit truncates emission after the vendor has billed the whole page. Not in this ADR: templating cost_estimate_usd (the manifest-level plan figure; same shape, less urgent — a wrong estimate is less damaging than a wrong ledger row) and a registry-maintained per-binding cost declaration re-checked on the fixture cadence (#29's durable fix — it is registry work, not runner work). Both go to ROADMAP.md, not to M23. Consequences: Totals become explicit about their epistemic status, and an agent or operator reading a receipt after the fact can tell counted dollars from arithmetic. Responsibility for an estimated rate's accuracy moves to the operator wherever pricing is plan-dependent — deliberately: the operator's own figure replaces a stranger's guess, and that shift is a stated property of the design, not a side effect. Spec impact: AMEND (this packet's second commit) — §3 (costs.basis), §5 (COST basis), §7 (plan prints unset), §8 (receipt totals), §10a (cost declaration + per: request guidance); §11 milestone M23. spec/binding-schema.json (amount_usd anyOf) and spec/ledger.sql ride the build, machine-compared as always.

2026-10-05 — Backlog wording and small fixes (#97, #116, #124, #130, #138, #140, #141, #158, #159, #165, #169, #170)

Question: Twelve open issues each named a line the binary prints that was wrong, or a small behavior that worked against the operator. Which of them are spec-invisible and can be fixed without a spec diff? Choice: (1) Plan notes. A need for this pipeline's own field prints needs this pipeline's own field "x" (declared by an earlier step): the declaring step may be text/compose or a participant step, so the note no longer says "judgment" or "AI step" (#116). A namespaced need prints needs namespaced field "x" — this pipeline is coupled to the adapter that provides it, which is true of an operator's own adapter as well as a vendor's (#169). An explicit cache: 0d prints off (cache: 0d) (#130). (2) Unknown adapter. An id within edit distance 2 of a built-in or installed adapter gets did you mean "..."? in place of the install hint, and a looked-for path prints once. Once a step's adapter is not found, the unmet needs of later steps are not reported: what the missing adapter provides is unknown, so those lines only restated the first problem (#159). (3) adapters search prints gtme adapters add <id> for every entry, the form plan's hint prints and the one that installs the index's pinned commit, and a help flag after an adapters subcommand prints usage and exits 0 (#158). (4) adapters verify reads a config default into the URL for {{ config.x }} as well as {{config.x}} (#165). (5) A refused token. When GitHub answers a request carrying GITHUB_TOKEN with 401, the request is asked again without it and the token is not sent again in that process; a failure after that names the token (#97). (6) groups add with a key the ledger has not seen names the key and the fix (#124). (7) query --save stores the statement after it has run, so one that fails is not saved (#140). (8) help --agent lists gtme run --concurrency N and calls a traverse's typed stretch a leg (#138, #141). (9) The create-adapter skill points at the wire protocol reference, which is where the process protocol is documented (#170). Why: Each is a string or an internal choice no second implementation needs to match. The search change follows ADR-042: the index's commit is the verified one, and @main pinned whatever the branch pointed to. The token retry keeps ADR-042's rule that the token is for private repositories; a public install never needed it, and codeload answers a bad token with 404, so a refused token is dropped for the rest of the process rather than turned into a phantom missing repository. Left for a spec decision: the (N already in the ledger) source line (#144, SPEC §8 fixes the words), the simulate receipt's held (dry run) and spent (estimated) (#99, both SPEC §8 vocabulary), gtme run --as (#148) and a way to drop a saved segment (#140), both new CLI surface, csv/deliver on a company leg (#145, a manifest contract), and the stale size figure in SPEC §8 (#114). The verb synopsis in SPEC §8 does not list --concurrency, though §8's prose and §11's acceptance use it. Spec impact: None. test/e2e/backlog_wording_test.go, internal/cli/adapters_host_test.go and internal/adapterinstall/token_test.go cover the changes; the docs pages that quote the changed lines are updated, and docs/reference is regenerated.

2026-10-02 — Runner-owned steps' declared needs meet the contract walk (#117)

Question: SPEC §7 and §10a say a SQL step's with: {uses: [...]} validates against the available-field set like any dynamic needs, and §9 says the same of a deliver step's variables:. The planner's contract walk ran only for steps with a resolved manifest, and runner-owned steps (sql/*, group/deliver) have none, so uses: [title] on a sql/filter planned clean when nothing upstream provided title, and the run then filtered every record. Choice: The walk runs for every non-source step. A step with no manifest contributes the needs it declared (Required), which for runner-owned steps is exactly their with.uses or variables: values; a step that failed to resolve has none. The usual needs X, which no earlier step provides error names the step and field. An open available set (a group source, a traverse) still skips the check, as it does for adapters. Why: The rule is already in the spec; the manifest guard was an accident of where needs used to come from. Spec impact: None; this brings plan to §7 and §10a. Covered by TestPlanFailsOnUnsatisfiableRunnerOwnedNeeds.

2026-10-02 — A step-level uses: on a SQL step is a plan error (#115)

Question: SPEC §10a puts a SQL step's uses: in its config. A step-level uses: on sql/filter was silently ignored: uses: [titel] planned with reads: (none). §9 lists uses: as valid on filter-role steps, and sql/filter is filter-role, but the same sentence names the participant adapters (ai/*, human/*, agent/*, text/*) as the steps it means. Honor the key or refuse it? Choice: Refuse it on every sql/* step, naming where it goes (with: {uses: [...]}). One place per step kind keeps a pipeline reviewable, matches how provides: is already refused on non-participant steps, and a pipeline that carried the key never had it read, so nothing that worked before stops working. Why: A key the runner ignores is the failure the uses: gate exists to prevent. Spec impact: None; §10a already places the key in config. Covered by TestPlanRejectsStepLevelUsesOnSQLStep.

2026-10-02 — Plan prints a source adapter's limit: (#132)

Question: A group source's and a traverse's limit: print in plan, but a source adapter's with: {limit: N} (ADR-047) did not, so an operator could not confirm the cap on a small run, or a spend ceiling, before running. Choice: The source step prints limit: N record(s) at most when its config carries a positive limit, beside the group source's line of the same shape. Nothing else changes. Why: The cap is a fact plan already holds; showing it costs one line. Spec impact: None; an added informational line in the default listing, which §7 item 4 does not enumerate exhaustively. Covered by TestPlanPrintsTheSourceLimit.

2026-10-02 — gtme plan takes a bundle folder (#93)

Question: SPEC §8 says a bundled pipeline's group references meet plan's referenced-groups-exist check, so a bundle moved to a clean ledger fails at plan. gtme plan <bundle> failed reading a directory, and gtme plan <bundle>/pipeline.yaml resolved adapters from the install rather than the bundle, so that rung was unreachable. Choice: plan and run share one loader, loadPipeline in internal/cli/run.go: a bundle path verifies its hashes, loads the pipeline from inside, and resolves the bundle's own bindings first, for plan exactly as for run. A path to a file inside a bundle is still a plain pipeline file, as it is for run. Why: The spec's gate ladder starts at plan, and the bundle READMEs already tell readers to run gtme plan .. Spec impact: None; this brings plan to §8's bundle paragraph. Covered by TestPlanBundleChecksGroups and the plan step in TestBundleFreezeMoveSimulateDry.

2026-10-02 — when: naming a step that is not a filter fails plan (#88)

Question: ADR-048 says when: <step>.passed reads the filter role only, and the planner refused a review there, but nothing refused an enrich, compose, verify, traverse, deliver or source step. Only a filter writes a pass verdict (a deliver writes only fail verdicts, for withheld sends), so such a when: planned clean and then held every record at the gated step: in counted them, out read 0, and the only trace was a gated event per record. Choice: Plan refuses when: STEP.passed whenever STEP resolves to an adapter whose role is not filter, with the contract-problem shape the review case already had (exit 2, the step named, the fix named: gate on a sql/filter over what the step wrote). The review message is unchanged. Why: It is ADR-048's rule, enforced for every role rather than one; refusing at plan is the loud failure, and a runner that honoured when: on a non-filter would have to invent what passed means for it. Spec impact: None: this brings the planner to ADR-048 and §9. Covered by test/e2e/when_gate_test.go.

2026-10-02 — csv/deliver appends under a file lock and checks the size (#185)

Question: A 416-record csv/deliver run reported 415 rows written, and the file held 292 lines with rows cut at arbitrary bytes. Each row was one O_APPEND write, which a local filesystem keeps whole but a network or synced folder (SMB, NFS, a folder a sync client manages) does not have to, so concurrent sessions interleaved. Choice: appendRow takes an exclusive flock on the file around the write, and a process-wide mutex as well, because a network filesystem may emulate flock with a per-process lock that would let one process's sessions through together. Under the lock it compares the file's size before and after: a write that did not grow the file by exactly the row's bytes fails the record instead of acknowledging a delivery the file does not hold. ensureHeader holds the same mutex from its O_EXCL create to the header's write, so no session of the process appends a row ahead of the header. Why: Serializing the writers removes the dependence on O_APPEND atomicity, which is the property that failed. The lock is on the output file itself, which the operator named, so it adds no file beside the ledger. The size check is the cheap guard the issue asked for: a step whose file lost rows now fails them, and the rows that fail get no deliveries row, so a re-run sends them again. Spec impact: None. The adapter's output, manifest and acceptance are unchanged. internal/adapters/csvdeliver/csvdeliver_test.go asserts that an append waits while another holder has the file locked and that eight sessions writing long quoted rows to a new file leave the header first and every row whole.

2026-09-30 — M38 internals: Instantly's lead fields (ADR-066)

Question: Where the lead-field set lives, how the duplicate refusal gets exit 2, and how the tag check reads the sequence. Choice: One map in internal/adapters/instantly, leadFields, from every accepted target name to its lead field, replaces the four-name switch and firstClass; addLead, preflight and attest all read it, so the set cannot drift between them. The tag list preflight checks is a second, ordered list beside it (leadFieldTags), without {{jobTitle}}. The duplicate refusal is a contractError implementing ExitCode() 2, raised by parseConfig, which cmd/gtme-instantly already turns into its exit code through httpx.ExitCodeFor; the preflight session parses the same config first, so a duplicate stops the step before any send. The tag check reuses campaignDetail.steps(), which already joins each variant's subject and body, so a tag in a subject counts. Why: One table is the smallest change that makes ADR-066's set a single fact; the exit code follows the adapter's existing error path. Spec impact: None beyond ADR-066. Covered by TestLeadFieldsTakeEitherSpelling, TestSnakeCaseJobTitleReachesTheLeadBody, TestOneLeadFieldOneTarget, TestPreflightChecksLeadFieldTags and TestAttestComparesTheNewLeadFields.

2026-09-30 — A run that ends failed still adds its completers to the terminus group (#203)

Question: SPEC §8 says every record that completes the run's final step is added to the terminus group. The runner asserted the terminus only when every step returned without error, so a run that ended failed (a step that stopped, ADR-065, or any step error) or was interrupted added nobody, even the records that had completed. once: (ADR-052) counts those same records finished, so a downstream pipeline sourcing the group missed people that were demonstrably sent, and a once: consumer would never source them again. Choice: The terminus asserts however the run ends: execute runs the steps, then adds the records at the final step's state (the same completers rule as before: no filter fail verdict, the last segment's records only) on a context an interrupt did not cancel. The steps' error still decides the run's status and exit code; a terminus error surfaces only when the steps succeeded. Records a stop left unsent never reached the final step, so they do not join; a --resume that sends them adds them, and nobody is added twice (existing members are skipped). The receipt's group "NAME": N record(s) added line now prints for a failed run too. Why: The spec's rule is per record, not per run, and it is the rule once: already keeps; asserting at the end of whatever the run did is the smallest change that makes the two agree without moving the terminus into the step loop. Spec impact: None: this brings the runner to §8's terminus rule. Covered by test/e2e/terminus_stopped_test.go.

2026-09-30 — M37 internals: a step that stops (ADR-065)

Question: What does the build decide where §5, §8 "A step that stops" and §10a leave room? Choice: (1) Schema name. ERROR's schema is spec/schemas/msg-error.schema.json, beside the other msg-* files the conformance test maps by type. (2) Rule lookup. The engine tries the exact status, then its class (4xx), then default; within an entry the first rule that applies wins, and an entry none of whose rules applies falls through to the next. (3) Where a verdict has no record. A source's request belongs to no record, so skip and fail_record there warn and end the source as before, and an exhausted retry ends the session; a traverse's request is its parent's, so its ERROR names the parent. (4) class: auth on a record verdict (fail_record, skip, retry): the engine reports the verdict for the record, then ends the session as an auth failure, so the step stops (§10a says the step stops as an auth failure does). (5) No accepts. When OPEN does not list ERROR the engine and the Instantly adapter end the session on every verdict, as before M37 (§5's fallback). (6) Retry wording. The engine reports the rule's reason with its attempt count; the runner prefixes gave up retrying:, so any adapter's retry says the attempts ran out. (7) The stop in the pool. A stepStop from a session closes the same channel #82's runner-side errors close; the dispatcher checks it before every send and a worker drops a chunk taken after it without opening a session, so no chunk slips through select's random choice. A stop wins over another session's crash as the step's error. (8) An ERROR skip. It writes a done event with skipped: true and outcome skipped, moves the record past the step, and counts it skipped; on a deliver step it also records a fail verdict, the withheld send ADR-031 describes, so later steps and the terminus still see it. On any other step no verdict is recorded, since a fail verdict there would freeze the record. The receipt prints STEP: N skipped — reason the way it prints failures. (9) gtme runs RUN_ID. The latest detail.stopped for a step counts, less the records a later session of the run reached for the first time, so a resumed run's line does not keep reporting records the resume sent. (10) A fail_run whose adapter then exits 0 stops the step with exit

  1. (11) Fixtures. mock-enrich-py's induced failure exits 1 instead of 3: it stands for a crash, and exit 3 now stops the step. The email-waterfall bundle is refrozen: its finders' 404 skip now counts skipped instead of empty, and the refreeze also picked up the current registry/person.json. Why: Each is the smallest reading that keeps the pre-M37 behavior where ADR-065 did not ask for a change, and where it did, makes the receipt and the ledger say the same thing. Spec impact: None beyond ADR-065. Covered by test/e2e/step_stop_test.go, internal/binding/verdicts_test.go, internal/adapters/instantly/instantly_test.go, and the conformance and protocol tests for ERROR and accepts.

2026-09-30 — A full Instantly plan is not an auth failure; a process adapter's last stderr line reaches the ledger (#202)

Question: Instantly answers an add-lead call on a workspace at its plan's lead limit with 403 {"message":"Lead limit reached. Remaining uploads: 0"}. httpx classes every 401/403 as auth, so the adapter exited 3 and printed "credentials were rejected" for a key that was fine. Separately, the failed step event for a process adapter's crashed session recorded only adapters: …/run: exit status 3: the adapter's own explanation went to the operator's terminal and nowhere else, so gtme runs could not tell a revoked key from a full plan. Choice: (1) The Instantly adapter reclassifies an add-lead 403 whose body mentions "lead limit" as a provider error (exit 1) whose message says the workspace's lead limit is reached and to upgrade the plan or delete leads, then re-run. The vendor body stays in the message. Any other 403 is still auth (exit 3). The check is in the adapter, not in httpx: the wording is Instantly's, and another vendor's 403 body means nothing to it. (2) An external adapter session keeps the last non-empty line its process wrote to stderr (bounded to 500 bytes), and the exit error appends it: adapters: …/run: exit status 1: instantly: the workspace's lead limit is reached …. That error is what chunkFailed already writes as the failed event's reason. The operator's stderr stream is unchanged. Why: A quota is an ordinary operating condition, and naming it as bad credentials sends the operator to fix the wrong thing. The last line is where a process adapter prints its fatal error (the shipped cmd/gtme-instantly prints exactly that and exits), so it is the line that answers "why"; carrying all of stderr would put progress noise in the ledger. A built-in adapter's error already carried its message. Spec impact: None. §8 names exit 3 for auth and 1 for other; a full plan is other, so this corrects a misclassification rather than changing a code's meaning. The failed event's reason text is not specified beyond being present. What this does not do is stop the step: with one record per deliver session (ADR-060), each remaining record still opens a session and fails the same way. Holding them once the destination says it is full needs a signal from the adapter to the runner, which is spec-visible and is proposed separately on #202. internal/adapters/session_test.go and internal/adapters/instantly/instantly_test.go (TestLeadLimitIsNotAnAuthFailure) cover both halves.

2026-09-29 — An AI step takes an unambiguous object answer as its array (#186)

Question: About 8% of one operator's ai/review calls at batch_size: 1 came back as a JSON object instead of the array the system prompt asks for, and each one paid for a full retry. Should the adapter take such an answer, and can the prompt head it off? Choice: Two object shapes are read as the array before validation. A bare object carrying an identity_key stands for a one-element array, but only when the batch has exactly one record. An object whose only key holds an array stands for that array, at any batch size. Every element is then validated exactly as before (identity keys, completeness, the declared shape), and any other shape, including a bare object for a larger batch, still fails and gets the one retry. The system prompt also gains a sentence saying the answer is an array even for one record, and never a bare or wrapping object. Structured outputs and an assistant prefill were considered and left out: both change the engine request rather than the adapter, and a prefill is not available on every model the step may name. Why: Both shapes say the same thing as the array with nothing left to guess, so a retry buys no information and costs a whole call. This is the same recovery stripFence already makes for a fenced answer. The issue tied the rise to the template: rename (ADR-057), but that change moved the operator text key and nothing else in prompt assembly; an operator template that describes a single output object is the likelier trigger, and the added sentence answers it from the system side. Spec impact: None. §10 item 3 fixes the output schema the model is asked for, and §2 fixes the retry on a validation failure; both stand. Reading an unambiguous answer as the array it denotes is decoding, the same kind of choice as stripping a markdown fence, and the system prompt's wording is not a contract.

2026-09-29 — Concurrent first opens of a new ledger are serialized by SQLite (#153)

Question: Two processes opening a ledger file that does not exist yet raced the migrations. Each read schema_migrations before its own migration transaction, so both saw nothing applied and the second failed with table ... already exists. Separately, the first connection's journal_mode(WAL) switch on a new file needs an exclusive lock, and SQLite reports SQLITE_BUSY for it without consulting busy_timeout, so the other process failed at open with database is locked. Choice: Each migration's transaction (already BEGIN IMMEDIATE, via _txlock=immediate) re-reads schema_migrations for its own name and skips a migration another process recorded while it waited for the write lock. The earlier read of the table stays as a fast path only. Open retries the first ping while SQLite reports the database locked, with a short capped backoff for up to 10 seconds, the same bound as busy_timeout. The creation of schema_migrations itself runs in a transaction too, so it waits on the same lock. Why: SQLite's write lock already serializes writers across processes, so it serializes migration without a second lock file beside the ledger. A new file would be something a second implementation sharing the ledger directory would have to know about; the write lock is not. The migration files stay as written (no IF NOT EXISTS rewrite), because the recorded name, checked under the lock, is what makes a migration idempotent. Spec impact: None. SPEC §0 (principle 10) leaves concurrency strategy to the implementation, and no DDL, output or exit code changes. internal/ledger/open_race_test.go opens one fresh path from eight goroutines and from six processes and asserts every open succeeds and each migration is recorded once.

2026-09-29 — csv/deliver writes a non-string value as JSON (#164)

Question: SPEC §10a fixes csv/deliver's columns but not how a cell renders a value that is not a string. The adapter fell back to Go's fmt.Sprint, so a list arrived as [HubSpot (Marketing automation) Salesforce (CRM)], whose items cannot be split back out, an object as map[a:x], and a large number as 2.5e+06. Dry-run and simulate print the same values as JSON, so the two disagreed. Choice: A string is written as-is and a missing value as an empty cell, as before. Every other value (list, object, number, boolean) is written as compact JSON with HTML escaping off, so R&D stays R&D. A number therefore prints as JSON does (2500000, 42), and a boolean as true or false, which is what it printed before. Why: JSON is the form the ledger stores values in and the form the rest of the CLI prints, and it round-trips: an importer or a person can recover the list. The CSV writer already quotes the cell, so the JSON's commas and quotes are safe inside it. Spec impact: None. Cell rendering is an unspecified detail of one built-in adapter's output file; no protocol, DDL, CLI output or exit code changes. internal/adapters/csvdeliver/csvdeliver_test.go covers a list, an object, numbers, a boolean, a quoted string and a missing value.

2026-09-29 — adapters update moves a bare-id install to the index's current pin (#175)

Question: A bare-id add pins the binding at the index row's sha (ADR-059) and records that sha as the ref in .source.json. update re-resolved the recorded ref, so it re-fetched the same commit forever and reported "pin unchanged" after the index had moved. How does update with no @ref find the new pin? Choice: When the recorded ref is a full commit sha and the registry index lists the same id at the same source (url and path) as a binding entry, update fetches the sha that row lists now. The fetch is verified and hash-checked against the index as on any add, and .source.json records the new sha as its ref. A branch or tag ref keeps following itself, as before. An explicit @ref always wins. If the index is unreachable, the pin stays where it is and update says so. A row whose id or source no longer matches is not followed: moving an install to a different repository or path is a remove and re-add. .source.json gains no field, so installs made by v0.7.0 and earlier update the same way. Why: update is the explicit request that SPEC §8 says moves a pin, and for a bare-id install the index is where the pin came from. This matches update of a process entry, which already moves to the release the index lists. Reading the recorded ref (a sha) rather than adding an "installed by id" marker covers the installs that already exist; an explicit full-sha install of the index's own source also follows the index, which is the only pin such an install could move to without an @ref. Spec impact: None. §8's "update re-fetches only when asked; nothing moves a pin implicitly" holds: the move happens only on an explicit update.

2026-09-29 — The registry index lists every SPEC role, and a row the binary cannot read is skipped (#174)

Question: spec/schemas/registry-index.schema.json limited role to six values, so a traverse or review entry could not be listed, and LoadIndex validated the whole document, so one such row made gtme adapters search and every bare-id add fail for every client. That whole-index refusal is also what broke pre-v0.7.0 binaries when the first process entry appeared. What should the schema allow, and what should a client do with a row it cannot read? Choice: (1) The index schema's role enum is SPEC §6's role list: source, traverse, filter, enrich, verify, compose, review, deliver. This conforms the schema to roles SPEC already defines. (2) LoadIndex validates the document (version, bindings an array, no unknown top-level members) whole, as before, and then each row on its own against the schema's row definition. A row that fails, whether for an unknown role or kind or a missing member, is left out of the index and recorded as skipped. search and a bare-id add or update print one warning per skipped row, naming its id (or row N when it has none) and the first schema failure, and carry on with the rest. (3) When add or update fetches a source whose index row was skipped, the content-hash check is skipped with a warning, exactly as when the index is unreachable, because the row's sha256 cannot be trusted either. Why: a registry has to be able to list a new kind or role without breaking every older client, and one malformed row is the registry's problem, not every operator's. The document-level checks stay whole because a wrong version means the client cannot read any of it. Spec impact: None. SPEC §8 says search reads the index against the schema and does not say the index is refused as a whole; the enum change adds only roles §6 already defines. If the skip should become a promise registries can rely on for older clients, §8 would need a sentence to that effect, which is a spec change for approval.

2026-09-29 — Fixture matches on query parameters are exact (#166)

Question: A fixture's match was a substring of "METHOD path" or of the full URL, so page=2& also answered a request carrying per_page=2&, and gtme adapters verify passed with a page served twice. SPEC names fixtures (§10a, §8) but not how a response is chosen. How should a match pick its request? Choice: A match made only of key=value pairs (an optional leading ? or & and trailing & are anchoring and ignored) is parsed as a query: every pair must be one of the request's parameters, key and decoded value exact, in any order. A match with a ? splits there: the part before is a substring of "METHOD path", the part after is matched the same way. Anything else ("GET /verify", "jane-doe") stays a substring of "METHOD path" or of the full URL. The first matching response still answers. The email-waterfall bundle's verifier fixture matched on value prefixes (email=bob), so it now names the full addresses, and its manifest hash moves with it; its simulated receipt is unchanged. gtme help --bindings and the create-adapter skill say the new rule. Why: the issue's own fix; the path and plain forms that every registry binding uses keep working, and the only value-prefix matches in the tree were two hand-written stand-ins. A verify warning for a fixture that answers two requests was also suggested; exact matching removes the case it was meant to catch, so it is not added. Spec impact: None to SPEC.md or the schemas; the fixture file's match rule lives in gtme help --bindings, which now states it.

2026-09-29 — Binding retry.backoff_seconds is the base delay (#163)

Question: retry.backoff_seconds was parsed and never read; httpx backed off from its own 1s base. How should a declared value act? Choice: It is the first retry's wait, doubling per retry, in place of the 1s base. A provider's Retry-After still wins when it sends one. The 30s cap on the doubling stays, but never falls below the declared base, so a binding that asks for 60s gets 60s. An explicit backoff_seconds: 0 (the schema's minimum) means no wait; an absent key keeps the 1s default, so the field is a pointer in the Go struct. The wait goes through a swappable httpx.Sleep, which is how the tests observe it without sleeping. Why: the field's name says what it is, the schema already allows it, and refusing it at load would break the registry bindings that declare it. Honouring it beats refusing it. Spec impact: None. The schema already declares the field; this is the engine conforming to it.

2026-09-29 — Binding error verdicts: what fail_run and retry do (#162)

Question: spec/binding-schema.json accepts four errors: verdicts, but the engine acted only on skip and fail_record; a fail_run or retry rule fell through to the default, and its reason never printed. SPEC §10a names "error→verdict mapping" without spelling out what each verdict does. What should the two ignored ones do? Choice: fail_run stops the run as an unnamed status already does, but the error now leads with the binding id and the rule's reason, wrapping the classified error so its exit-code class (§8: 3 auth, 4 rate limit, 5 network) is unchanged. retry makes the status retryable inside the HTTP layer under the binding's retry.max_attempts (default 3) and backoff, even a status the engine would not retry by default (a 409, a 404); when the attempts run out, the run stops with the reason and the attempt count, again wrapping the classified error. Statuses mapped to skip, fail_record or fail_run keep the default retry classification (429, network failures and 5xx are still retried before the verdict applies). httpx.Request grows a Retryable hook for this; nothing else in httpx changes. Why: the issue offered "honour all four or refuse the two at load"; both verdicts have one obvious meaning, and honouring them keeps every binding already written with them valid. Keeping the exit-code class means fail_run adds a reason without inventing a new outcome, so no §8 text moves. Spec impact: None. The schema already lists the verdicts; this is the engine conforming to it.

2026-09-29 — M36 internals: the receipt from the ledger, crashed sends, settling (ADR-064)

Question: How does gtme runs RUN_ID stay equal to the live receipt, how is "net across sessions" computed, what does an old run print, and how do the two held-delivery flags combine? Choice: (1) One renderer. The receipt's table and its total line are PrintTable and TotalLine, and both the live receipt and gtme runs RUN_ID call them, so the columns cannot drift. The ledger side rebuilds StepStat rows (LedgerSteps) and hands them over. (2) Net means latest. For each step, a record's column is its latest event carrying detail.outcome in the run, across every session; in is the records with any event at that step, leaving out a traverse's child events, which belong to the next segment. A reused filter judgment that failed carries pass: false and counts in both cached and filtered, as the live line does. The source row's out is the source's own done count. An unfinished run lists steps up to the last one the ledger saw, as the live receipt omits steps that never ran. (3) Old runs are inferred. Events without outcome are read by event, verdict and reason, the way the receipt read them; a step with a gate prints in as N+? with a note, and a cache skip's avoided is ?. (4) Unanswered excludes held. The interrupted run's count leaves out dispatched sends that already have a deliveries row under that run, so a record shows once, as unanswered or as held:. (5) Two flags, one record each. A key list is checked before the run is reopened, so a refusal writes nothing. With both flags, a flag without a list takes what the other did not name; both without lists refuse. SPEC says a flag without a list applies to every held record and that a key named by both is an error; read literally, --settle-unconfirmed=K1 --resend-unconfirmed would always refuse, which defeats the case the ADR names (settle three, resend one). (6) Flags parse as optional lists. --flag and --flag=K1,K2 both work (a bool-style flag value). Why: equality by construction is cheaper to keep than equality by test, and the rest follows the ADR's "each record once, by its latest outcome." Spec impact: None beyond ADR-064's reconciliation (v0.58); v0.60 records the behavioural notes.

2026-09-29 — A runner-side error stops a step's dispatch; an adapter crash does not

Question: Since #79, a worker keeps draining the step's queue after a chunk fails, which is right when the adapter crashed (the next session may succeed) and is what stopped the pool from hanging (#77). But a chunk also fails when the runner or the ledger does: a ledger write refused, a session that cannot open. Draining then dispatched, and for a paid step billed, every remaining chunk with nothing recordable (#82). Choice: The two kinds are told apart by type. chunkFailed, the one path for a session that died or broke protocol, returns a sessionCrash; the pool keeps draining on it as before. Any other error closes a stop channel: the dispatcher sends no further chunk, the chunks already in a session finish (they are not cancelled, since their work is under way and often paid for), and the step returns the first error. The undispatched records stay at the previous state, so --resume picks them up once the cause is fixed. The issue suggested cancelling a derived context instead; that would also kill the sessions in flight and throw away answers already paid for. Why: a failure the runner cannot record is a reason to stop spending, and an adapter's failure is not; the error's type is where that difference already lives. Spec impact: None. The e2e test provokes the runner-side failure with an adapter that adds a SQLite trigger refusing its own field's writes.

2026-09-29 — M35 internals: destinations and Instantly's move (ADR-062, ADR-063)

Question: How does an already-delivered skip leave the cached count without changing the event vocabulary, how does migration 0015 treat ADR-060's in-flight events, what does a plan error say for a campaign name, and what exactly does gtme adapters add accept for a process entry? Choice: (1) Already delivered is a count bucket, not an event. The skip stays skipped_cache with reason already_delivered or unchanged. The runner counts those reasons in StepStat.AlreadyDelivered (one predicate, ledger.AlreadyDeliveredReason), outside CacheSkips and the avoided total, and StepEventCounts buckets them the same way for gtme runs RUN_ID. A group/deliver handoff to a group that already holds the record uses the same reason, so it counts the same. The step line ends N already delivered, and both receipts print STEP: N already delivered under their table. (2) Migration 0015 also backfills ADR-060's dispatched events. Their detail carries the delivery's scope, and a later run matches on it to hold what a dead run left in flight, so an event written before the upgrade with scope '' would never match again. An event names its step, so the url is exact, unlike a deliveries row, which keeps ADR-062's exactly-one-step rule. (3) A config value that fails its property's pattern is refused with the property's description, the way refusedConfig already names a dropped key, so a campaign name reads config campaign "Q3 VP Marketing" is not accepted: The campaign's id, a lowercase UUID (in the campaign's URL in Instantly), never its name… instead of a jsonschema pattern dump. The rule is generic: any manifest that constrains a key's shape gets the same message. (4) The campaign id is lowercase only. Instantly shows and returns lowercase ids, and one spelling per destination keeps the scope from forking on case. (5) The destination rides PREFLIGHT and the preflight event. The runner prints it on the live preflight line and on the receipt's, and stores it in the preflight step event's detail. When the campaign cannot be read, the adapter still names campaign <id>. (6) cmd/gtme-instantly is the existing package run as a process. It exits with the httpx error class's code (auth 3, rate limit 4), and --manifest prints the compiled-in manifest, from which the release writes manifest.json, so the pair cannot drift. The e2e harness builds it into a directory on GTME_ADAPTER_PATH the same way. (7) A process archive holds exactly manifest.json and run at its root. Any other member is refused, as is an archive over 128 MiB, a checksum mismatch, a manifest whose id is not the entry's, a run that is not executable, and a demo/ id. A malformed archive exits 2 like a checksum mismatch, never as a network error an agent would retry. The install keeps run executable and is staged beside its destination, then renamed into place, so a failed add or update never leaves a half-written adapter; .source.json gains kind, asset and release, and its sha256 is the archive's. The release builds with CGO_ENABLED=0, which §2 already required and the native linux/amd64 build did not enforce. (8) update of a process entry moves to the release the index lists, and refuses an @ref, because a process entry has no ref of its own to fetch. search prints gtme adapters add <id> for a process entry, and gtme adapters names its release. (9) The index schema requires sha256 only of a binding and forbids assets and release on one; a process entry requires both and tier: verified. (10) The generated adapter catalog drops Instantly's page, since docs/reference/adapters/ covers built-ins only, as it did for the entries M33 moved. Why: each keeps one account of a fact: one predicate for "already delivered", one scope per destination on every row and event that keys on it, one manifest per adapter build. Spec impact: None beyond ADR-062/063's reconciliation (v0.56). The receipt's STEP: N already delivered line under the table restates the step line's count. Still to do after this merges: the gtme-bindings index gains the Instantly process entry once a gtme release carries the archives (until then gtme adapters add instantly/add-to-campaign finds nothing), the site's catalog relabels Instantly, and the first live run of the installed adapter against a shell campaign is recorded in VALIDATION.md by hand.

2026-09-29 — M34 internals: crash and resume (ADR-060, ADR-061)

Question: How are the records a crash left in flight found and held, how does the key reach http/deliver, where does the lock live, what does gtme runs touch, and what happens when the receipt's own release command meets the done refusal? Choice: (1) The held set is read once per deliver step, across runs. A dispatched event's detail carries the delivery's target, scope, idempotency key and variables hash. At step start (armed runs only) the runner reads every dispatched event to the step's target and scope with no later done or failed at its run and step, skips those of a run whose process is alive (in flight, not crashed), and writes an unconfirmed row for the rest under the run that sent them. The ordinary pre-send check then skips them. Reading across runs matters: the first cut read only the current run's events, so a plain gtme run after a kill -9 sent the dead run's in-flight records again (found by re-running the recover guide). A held record gets no step event and keeps its state, so it is the step's non-terminal remainder, counted N unconfirmed and never as cached. The pre-send check releases a held row only under --resend-unconfirmed on the run that held it; any other run, whatever its redeliver:, counts it held and names that run in the release command. (2) An interrupted deliver session holds instead of failing. When the session dies and the run's context was cancelled, its unanswered records get unconfirmed rows rather than failed events; a session that dies on its own still fails them, since the adapter's exit is its answer. (3) Claimed and dispatched are committed before the session opens, for every step (only deliver steps log dispatched), and a deliver step's chunk size is 1. (4) The key rides a reserved record field. For http/deliver only, the runner adds $idempotency_key to the record; the engine removes it before building the template context and sets Idempotency-Key unless the config set that header under any capitalization. The key is computed from deliveryScope, so a scope change reaches it with no further code. (5) The lock is flock on locks/<run_id>.lock beside the ledger. Creating a run locks right after the insert; resuming locks before ReopenRun, so a refused resume writes nothing. A refusal is retried four times at 50 ms, because a gtme runs probe holds a shared lock for an instant. The probe opens the file read-only and treats a missing file as free, so gtme runs creates nothing. A run that finishes removes its lock file before unlocking it, so locks/ holds files only for runs whose process died; a process that locked the file just as it was removed sees the path no longer names what it locked and starts over. (6) A done run with held deliveries can be resumed for their release. ADR-061 refused every resume of a done run, while ADR-060's receipt prints --resume RUN_ID --resend-unconfirmed for exactly such a run. The release is allowed, and without the flag the refusal says how to release; SPEC §8 gained the clause (v0.57). (7) The resume command gtme runs prints names <pipeline>.yaml, since the ledger does not record the file; the live receipt and the plain-run notice use the file the operator ran. Why: each keeps the ledger's account exact with the fewest moving parts: one read per step, one row per held record, one lock file per run, and nothing written by a read verb. Spec impact: None beyond ADR-060/061's reconciliation (v0.55) and the §8 clause in (6) (v0.57), which resolves the two ADRs' disagreement in the direction the receipt already promised.

2026-09-28 — Resume and interrupt: last is per pipeline, Ctrl-C finishes the run, a resume records its config

Question: Three recover bugs found while the recover and launch guides were written. --resume last resolved to the newest run of any pipeline (#122). Ctrl-C during an ordinary step printed a failed receipt but left the run running, and the records the receipt counted failed had no step events (#135). Resuming after editing the pipeline ran the new file while runs.config_json kept the old one, so gtme runs and gtme freeze described a file the run no longer matched (#137). Choice: (1) last is the newest run of the named pipeline, via LastRunForPipeline, the same scope collect-first uses. SPEC §8 calls --resume RUN_ID|last "the explicit form of the same thing", so this is conformance, not a new rule. A pipeline with no runs of its own has nothing to resume (exit 2). An explicit RUN_ID of another pipeline is still allowed, with the existing warning. (2) What already happened is recorded on a context the signal did not cancel. The run is finished that way for any interrupt, not only the ADR-049 walk. A session's messages are applied that way too, once the records are sent, because a RECORD that arrived before the kill is work done and often paid for, so it is kept. A record failed by the interrupt gets its failed event the same way. The kill still ends the adapter process and the stream, so the interrupt cancels the work, not the bookkeeping. (3) A resume records the config it resumes with. When the resolved pipeline differs from runs.config_json, the snapshot is replaced and stderr says so. Refusing a changed file was the alternative, but "fix the file and --resume" is the recovery SPEC describes (§8 preflight, and the M4 resume test resumes an edited file). The run finishes under the new file, so that is the file freeze should rebuild. Why: each makes the ledger say what the receipt and the operator already believe: the run resumed is the pipeline's own, a run the receipt calls failed is failed, and the recorded config is the one that finished the run. The snapshot keeps only the latest config. The original stays reconstructable from its step events' provenance, and keeping a history of configs would be a DDL change. Spec impact: None. config_json remains "the resolved config snapshot"; the DDL is unchanged. Refusing to resume a done run, and the in-flight delivery (#134) and dead-run (#136) fixes, are spec-visible and proposed separately.

2026-09-28 — The generator writes the six planned reference pages from their spec artifacts

Question: The outline listed six reference pages as planned — pipeline.yaml keys, binding manifest, wire protocol, bundles, plugin skills, and the conformance kit — and concept and start pages already linked them. Each has a machine-checkable source in the repo. Are they written by hand or generated, and in what shape? Choice: Generated by cmd/docsgen, one file each at docs/reference/<slug>.md (none is long enough to need an index and children), in the shape docs/DESIGN.md gives reference: key tables, one example read from a repo file at generation time, a see-also list, and the "Used in" list. Inputs gains Repo, the files repoSources globs (the schemas, spec/wire, spec/bindings, spec/acceptance, examples/hello.yaml, bundle manifests, plugin/skills/*/SKILL.md, test/conformance/*.go), so tests and the e2e feed the generator the same way. Per page: pipeline-yaml flattens pipeline.schema.json's top level and its step definition into dotted keys; binding-manifest splits binding-schema.json into the manifest surface (keys a process manifest.json also has, falling back to that schema's description, labelled, where the binding's is missing), the process-manifest keys a binding does not take, and one table per binding-only block; wire-protocol has one row per msg-*.schema.json in the order spec/wire/README.md's table lists them, a section per message, and the transcript's first stream (csv/source) as the example, linking the whole file on GitHub; bundles shows the first single-folder bundle's manifest.json; plugin-skills reads each SKILL.md's frontmatter and the namespace from plugin.json, and links the authored page whose code block carries /plugin install; conformance parses test/conformance/*.go with go/parser (stdlib, nothing compiled or run), lists each Test with its doc comment, and lists a file set under every test whose body, or a package function or value it names, carries one of the set's source markers. Descriptions are the schemas' own, verbatim; a key the schema leaves undescribed prints "—". Why: each page states something a spec file already fixes, so a hand-written copy would drift the day the schema changes; generating them puts them under the same drift gate as the rest of the reference. Schema order (an order-preserving JSON decode) reads the way the schema's author wrote it, where alphabetical order would put group before name. Also decided here:

  • The rewrite of docs/_outline.yaml now sets each single page's status: to generated, and Write/Drift treat docs/reference/*.md as generator-owned (stale generated files there are removed or reported, as in the four folders).
  • The two YAML examples drop their file's leading comment block: a column-0 # line inside a fence reads as a second h1 to the docs lint. The document itself is shown verbatim.
  • GeneratedBy names the new inputs, so every generated page's generated_by: line changed once.
  • Thirteen description strings lost "e.g." (a word the docs lint bans), as 2026-09-26 did for four: use and cache in pipeline.schema.json; id, auth.prefix, pagination.page_size, extract.fields.*.absent and .skip_if_input, idempotency, cost.amount_usd and retry.windows in binding-schema.json; credentials_optional in manifest.schema.json; token and detail in msg-pending.schema.json. Wording only. Spec impact: None. Generated docs only; the schema edits change description strings, which no validator or second implementation reads. internal/docsgen/singles.go, wire.go, conformance.go, schema.go, and singles_test.go.

2026-09-28 — The secrets file never follows GTME_LEDGER (#119)

Question: secrets.Path() used ledger.Home(), which is filepath.Dir($GTME_LEDGER) when that is set, so gtme secret set wrote to a secrets file beside a scratch ledger, often a working folder the docs point at (export GTME_LEDGER=./ledger.db). Keys already in ~/.gtme/secrets also stopped resolving. Where does the file live? Choice: Where SPEC §6 says: ~/.gtme/secrets, or $GTME_HOME/secrets when GTME_HOME is set (it already replaces ~/.gtme for adapters/ and types/). GTME_LEDGER moves the ledger and nothing else. A key a user stored beside a scratch ledger before this fix no longer resolves; plan names it as a missing credential with the gtme secret set fix, which is the loud failure we want over reading a key from a working folder. Why: a secrets file in a working folder is one git add . away from a public commit, and SPEC §6 never allowed it. Spec impact: None; the code now matches §6.

2026-09-27 — M33 internals: vendors leave the binary (ADR-059)

Question: How does the engine hold each: without a second expression language, where do the e2e and conformance suites get the entries now that the binary has none, how does harvest/profile@2's plan error name harvest/recent-posts without vendor code in the binary, what does add do when one of several references fails, and how do the Harvest templates reproduce the Go formatting? Choice: (1) each: is a field-rule form, checked at Parse. A FieldRule carries each, template and limit; each does not mix with path, paths or transform. The template goes through internal/template.Check under a new Item scope: tags allowed, the filter allowlist as everywhere, and item.* the only variable (a record.* or config.* reference is refused). limit is a positive whole number or exactly {{ config.<key> }}, resolved per step from config with defaults applied; the schema only types it, so the refusal message is the engine's. The path takes | alternatives like any other path (elements|element). Kept renders count toward the limit, as the Go adapter counted non-empty posts. (2) The suites install a local copy. test/fixtures/registry/ holds the five entries as committed to gtme-bindings; the e2e harness appends it to GTME_ADAPTER_PATH, and the conformance kit loads from it. spec/bindings/apollo-search/ is kept byte-identical to its registry copy, and a conformance test fails when they differ. The copy is a test fixture: an entry changed in gtme-bindings is copied here in the same change. (3) A refused config key is a not: {} property. ValidateConfig checks, before the schema, whether a key present in the step's config is declared not: {} with a description, and if so fails with config <key> is not accepted: <description>. harvest/profile@2 declares posts_limit that way, so the binary carries no Harvest knowledge and any entry can retire a key the same way. (4) Several references install independently. Each is resolved, verified and installed in turn; a failure prints gtme: <arg>: <error> and the rest continue; the command exits with the first failure's code and a one-line count. A single reference behaves exactly as before. A bare id is vendor/name with no dot; the index is fetched once per command, and the ref installed is the entry's source.sha (source.ref only when an entry has no sha). (5) The uninstalled-id hint rides the resolve error. unknown adapter "apollo/search" gains — if it is a registry entry, install it: gtme adapters add apollo/search for any id with a slash, so plan, run and the verbs that resolve all say it, offline. (6) The Harvest templates avoid comparisons on emptiness. In the dialect an empty string is truthy and != blank does not catch it, while | default: does treat it as empty, and case accepts a filter chain. So role_history picks role and company with default: chains ending in the (… not stated) text, drops a position with {% case position | strip | default: title | strip | default: companyName | strip %}{% when "" %}, and treats a year of 0, "0", "" or nil as absent with {% when 0, "0", "", nil %}. Over the Go adapter's fixtures and eleven edge-case positions (missing, whitespace-only and numeric-string fields, text-only dates, duration only), the binding's lines matched position.line() exactly before the Go adapter was deleted. One divergence remains by construction: a non-numeric year label ("Present") prints as itself, where the Go code fell back to the date's text. Why: each choice keeps the binary free of vendor knowledge while the operator-facing behaviour stays what ADR-059 promised: same fields, same lines, an error that names where a key went, and installs that fail one at a time. Spec impact: None beyond ADR-059's reconciliation (v0.51); v0.53 records the behavioural notes.

2026-09-26 — The CLI index lists verbs; the verb pages carry the forms

Question: The generated docs/reference/cli/index.md printed a forms table per verb, and every verb page printed the same forms table again plus the shared exit-code table, so the two shapes repeated each other and a verb page added only its example and its "Used in" list. Choice: One place for each thing. The index is the verb table (each verb linking its own page) and the exit codes, and nothing per verb. A verb page is its forms, its flags, one line linking the index's exit codes, its example, and where the docs use it. The site shows the verb pages under the index in the sidebar only while the reader is inside the CLI reference, so the index no longer has to stand in for them. Why: a table that appears twice is read once and skimmed once, and the generator's job is to make the reference the shortest true thing. Spec impact: None; generated docs only. internal/docsgen/cli.go plus a test that the index and a verb page do not repeat each other.

2026-09-26 — The docs reference generator is a Go command, cmd/docsgen

Question: The reference collection under docs/ (CLI verbs, the adapter catalog, the canonical field registry, the ledger schema) and the glossary are generated, never hand-edited (docs/DESIGN.md). Where does the generator live and in what language? A Go command under cmd/ and a Python script under docs/_gen/ were both defensible. Choice: Go: internal/docsgen (the parsers and renderers, unit-tested) and cmd/docsgen (the command), run by make docs-reference. Its inputs are surfaces that already exist: gtme help --agent captured from a clean home, docs/_adapters.json, spec/fields/*.json, spec/ledger.sql, and every concept page's frontmatter. It writes two shapes from each source — an index page and one node per item — plus docs/glossary.md, and it owns two blocks of docs/_outline.yaml: the reference children and the terms: map. test/e2e/docs_reference_test.go regenerates from the built binary and fails when the committed pages drift, so make check is the gate and a verb, adapter, field, table, or definition cannot change without the pages following. Why Go: the repo's one toolchain (§2) already carries the YAML and JSON parsing the generator needs, so no dependency is added, where a Python script would have needed PyYAML for frontmatter in a repo whose fixtures promise stdlib-only Python. go test covers the parsers and CI runs the drift test with no second runtime. The generator does not import internal/cli; it reads the agent document as JSON, so it is a pure function of files and the e2e can feed it the live binary's output. Also decided here:

  • gtme help --agent gains exit_codes (SPEC §8's table as data, mirrored from the Exit* constants). Additive, like the files key on examples; an agent branching on an exit reads it here, and the generated CLI index prints it (once; see 2026-09-26, the CLI index).
  • Each concept page carries defines:, a list of {term, definition} it owns, one sentence each; the glossary and the outline's terms: map are derived from it, and two pages defining one term is a generator error. A generated page's links: are derived the same way: one link per concept page whose terms its prose mentions, so the lint's "mentioned but not linked" check is satisfied by construction.
  • Four source strings lost "e.g." (a word the docs lint bans) so the generated tables pass the same lint as authored pages: the relations note in help --agent, two config_schema descriptions in spec/bindings/apollo-search/binding.yaml, and linkedin_internal_url's description in spec/fields/person.json. Wording only; no field, key, or behavior changed.
  • ADR-058's word: docs/concepts/types-and-traverse.md now says leg for a typed stretch of a run, where it had said segment. Spec impact: None. §8's help surface is additive; the field registry and the reference binding changed a description string each, which no second implementation reads.

2026-09-14 — help --agent examples carry their template files

Question: How does an agent-help example show template: {file: …} when the e2e plans every example from the YAML alone? Choice: agentExample gains files (path → contents, omitted when empty): the sidecars an example references beside its pipeline. The e2e writes them before planning; an agent reading the document can do the same. Additive to the help JSON, so nothing that reads it breaks. The fifth example, persona-file-and-text-compose, is the ADR-057 surface end to end — a persona file over config.* feeding ai/compose, a per-record file with a conditional, a capped loop and | default: rendered by text/compose, a deliver reading the result. The participants note now says a text step runs for real under --simulate; the create-pipeline skill shows the shape. Why: the help had the rules (one note, per-adapter descriptions) and no worked example of the dialect; an agent writing its first conditional had a tag list to infer from. Spec impact: None (§8's help surface is additive).

2026-09-13 — M31 internals: the binding tier on the one dialect (ADR-057)

Question: How does the binding engine keep its typed-leaf, omitempty and $variables splice rules on a Liquid parser, and where is the retired {{a|b}} fallback caught? Choice: (1) internal/template gains a Binding scope for Check (record/config/variables/session allowed without a declared list; block tags refused — a request template is an object) and an Eval that evaluates one object expression to a typed value with the standard filters registered (the allowlist is Check's job at verify, as for Render). (2) binding.tmplContext keeps its API — resolveValue, resolveString, renderString, resolveBody, the splice — and swaps lookup for template.Eval over the four namespaces, the record nested as well as flat; the in-house lookupOne/alternatives resolver is deleted. The placeholder regex stays only to find {{ }} spans, which is what preserves the rules: one span alone → typed; any empty span → the leaf is omitted. collectVariableRefs finds variables.<name> by a word regex inside each span, so | default: variables.x still excludes x from the splice. (3) Binding.check walks url, query, headers, body, page_size and cost.amount_usd through Check, so Parse — hence adapters verify, adapters add and every load — refuses the first problem naming the leaf. (4) The bare fallback is a namespace in filter position, which the parser calls a syntax error; retiredAlternatives matches it before parsing and prints the rewrite with every alternative turned into | default:. (5) Harvest is the one built-in rewrite; spec/binding-schema.json's description drops "no expression language may ever grow in here" for the dialect sentence — the boundedness moved from "substitution only" to "the closed dialect, objects only". Why: make check green; the binding unit tests cover typed leaves, omission, chained default, namespaced fields and the splice; the verify e2e proves a stale binding fails naming leaf and rewrite; the existing conformance, twin and bundle tests prove the built-ins' requests are byte-identical. Spec impact: None beyond v0.47 (marked built as v0.49).

2026-09-13 — M30 internals: template: and text/compose (ADR-057)

Question: Where does a template file get read, how does the closed adapter config schema admit the keys a template references, how is the dialect bounded when the library ships the whole language, and how does a text/* output inherit the fence? Choice: (1) pipeline.Load resolves {file: <path>} relative to the pipeline file into With["template"] and keeps the reference in Step.TemplateFile (yaml:"-", json:"template_file"): the planner, the runner and runs.config_json see the source; bare freeze marshals YAML and so inlines; freeze --bundle reads the reference back from the snapshot, packs the bytes under templates/<base> (<step>-<base> on a same-name, different-bytes collision) and points the bundled pipeline at it, so a bundle run loads it from inside. (2) One package, internal/template: Load, Check, Render. The dialect is enforced by Check walking the parsed render tree — tag names against the closed set, filter names and variable chains lexed out of each expression — rather than by an engine with fewer tags registered, so an operator reads "not in the dialect" with the list, not the library's parse error. Render is lenient on an absent field on purpose (a per-record step under on_missing: run still dispatches; | default: is the tool), so strict mode is not used. A namespaced field is nested (record.review.first_line) as well as flat (record["review.first_line"]). (3) A with: key the template references is dropped from the map the adapter's config_schema validates (like engine: and prompt: are on the way to their plan errors); unreferenced strays are still typos. (4) text/compose is adapters.KindText: IsParticipant (the cache, provides:, uses:, of:, fencing all key on it), not RunnerOwned (nothing waits), dispatched by runner.runTextStep — one render per record, ValidateProvides and the registry check as for an answer, advance with fields: 1, or fields: 0, empty_render: true for an empty render so the receipt counts it empty. (5) The signature adds template (the loaded source) and config (the referenced keys' values) for every participant; prompt and render.template leave it. An ai/* template renders once in runner.New over config.* and rides to the adapter under the same key, so the adapter's config struct just renamed its field. (6) Fence transitivity: runner.textFetched finds a value whose provenance starts text/, locates the plan step by the signature it carries, and projects that step's uses:/of: from the ledger to judge them — the judging step's own projection holds only its uses:, never the page the text step read, which the first cut of this missed and the fencing e2e caught; a text input is followed the same way to a bounded depth; a text value whose step is not in this plan counts as fetched — the safe reading. (7) github.com/osteele/liquid v1 is the dependency (ADR-057 (8)); its tool directive brings golangci-lint into go.sum and nothing into the binary. Why: make check green; the three e2e tests prove the §11 M30 clauses offline (file and inline share a signature, an edit re-judges, record.* on a batch step and with.prompt fail plan naming the fix, text/compose renders two of three and writes nothing for the empty one, is cached on the second run and identical under --simulate, plan refuses an undeclared field, {% include %}, an unlisted filter and two provided fields; a bundle packs the file, runs from inside, and a tampered template fails the hash). The eight pattern bundles were refrozen for the rename; their receipts are byte-identical but for run ids and the version. Spec impact: None beyond v0.47 (marked built as v0.48).

2026-09-07 — The Claude Code plugin: four skills in plugin/, tested against the binary

Question: Launch 11 wants three or four thin skills that lean on gtme help --agent, gtme help --bindings and the bundles. Where do they live, what are they, how thin, and how does CI keep them true? Choice: (1) In this repo: the repo root is the marketplace (.claude-plugin/marketplace.json, name gtme-run, one plugin at ./plugin), plugin/ is the plugin (name gtme, so skills read /gtme:<skill>); Claude Code supports a repo that is both. Install: /plugin marketplace add gtme-run/gtme, /plugin install gtme@gtme-run. (2) Four skills, verb-noun: create-pipeline (a conversation in the human's words, a playback, options with a recommendation when the answers admit more than one honest shape, then one step per gtme plan), run-pipeline (the ladder — plan, simulate, dry-run, armed, again — with the arm gate as a timing rule: a yes given after the dry-run receipt arms a vendor target, a yes given before it never does, however firm), create-adapter (binding or process, the contract from help --bindings, per-record fixtures by query-value match, hand-written fixtures marked in a note and recorded before any armed run, the type half for records that are not people or companies), analyze (a question-to-command table over runs, show --provenance, groups, freeze, query, with the model read from help --agent | jq .ledger). A first-run "start" skill was dropped: it fires once per machine, and START.md door 1 already is that instruction; each skill carries a three-line preflight instead. (3) Each skill is under 600 words and copies no facts from the CLI: adapters, fields, flags and receipts come from the binary or the bundles at run time. (4) Tests: test/e2e/plugin_test.go runs every ```sh block in every skill against the built binary in a harness seeded with pipeline.yaml/leads.csv and one armed run (blocks with placeholders, key handling, installs, pipes or redirects are documentation and skipped), and checks every gtme <verb> a skill names in code against help --agent's verb list; test/conformance checks the two manifests and each skill's frontmatter. Plugin version tracks the binary's tag. (5) Written by the skill-writing discipline: four baseline runs without a skill (an agent under "don't make me babysit" promised to arm the live send itself after the dry run; an adapter author shipped a hand-written fixture as if recorded and one fixture for every record; a pipeline author built one shape with no alternatives and all steps before the first plan), then the skills, then reruns with the skills present — which also caught the first draft of the gate being a magic word (arm) that refused a plain "yes, go" given after the receipt; the rule is now about when, not what. Why: The plugin is instruction, the binary is knowledge (the launch split); keeping the skills in the repo lets CI prove them against the version they describe the way it proves the bundles. Spec-invisible: no verb, flag or behaviour changes; plugin/ and .claude-plugin/ are distribution, like bundles/ and the tap.

2026-09-06 — The repo moves to github.com/gtme-run/gtme; the module path follows

Question: The four public repos (the binary, the bindings registry, the site, the Homebrew tap) sat under the agency's GitHub org. The handle gtme-run was claimed the same day the domain was. Move before the announce, or keep the org as a commitment? Choice: Move all four to gtme-run now, and rewrite the Go module path to github.com/gtme-run/gtme in the same change: every import, go.mod, the registry default (internal/adapterinstall/index.go), the release and install lines in README.md, START.md and bundles/, the tap's formula and bump script, the registry index's url, and the site. Old URLs keep working through GitHub's repository redirects, releases included; the old module path is left to the module proxy's cache and is not maintained. Attribution moves to where a reader looks: the README, the LICENSE, the site footer. Why: A module path is identity, and a rename after the announce breaks every go install line and import in the world. Before it, with every package internal/ and no importer, the same rename is a sed and a tag — this week is the only cheap moment, and the tool's name, address (gtme.run) and handle (gtme-run) now match on every install line: brew install gtme-run/tap/gtme, go install github.com/gtme-run/gtme/cmd/gtme@latest. Spec-invisible: no observable behaviour changes; the default registry URL is a constant the environment already overrides (GTME_REGISTRY).

2026-09-06 — Pattern bundles: five patterns frozen under bundles/, and what sits beside a manifest

Question: Launch 10 wants the campaign shapes (qualify → group → send; an email waterfall; the account shape; events via CSV + cron; posts to engagers via a traverse) shipped as runnable bundles — each gtme freeze --bundle output, each simulating offline from a clean checkout, each with a README and its receipt, all proven by CI. Where do they live, how are they produced when a --simulate run persists nothing to freeze from, how does a pattern that is two runs fit a format that is one, what travels beside the manifest, and where do the traverse bindings no shipped adapter provides come from? Choice: (1) bundles/<pattern>/ at the repo root; a chain is a folder of numbered bundles (qualify-group-send/1-qualify, 2-send; account-shape/1- … 4-), one frozen run each, with the pattern's README at the folder root walking the order. (2) bundles/refreeze.py (python3 stdlib, like the fixture adapters) mints the run to freeze from: a throwaway home, the pattern's bindings served from their own conformance fixtures by a local server (their base_url default pointed at it for the producing run and restored before the freeze, so the bundle carries the binding as authored), AI on the fixture engine, delivery held by --dry-run, HTTP(S)_PROXY at a dead port so no other host is reachable (loopback is never proxied), placeholder values for whatever credentials the plan reports missing; --after runs a chain's earlier bundles armed first so their groups exist. It then lays the frozen files over the directory and rewrites receipt.txt from a fresh gtme run . --simulate — the receipt a clean checkout sees. (3) Beside the manifest, unlisted: README.md, receipt.txt, and the input CSV. The manifest lists frozen files only, so an operator may replace the CSV under the same name and the bundle still verifies: the pipeline is frozen, the data is theirs. (4) gtme run . --simulate from inside the bundle is the documented form: csv/source opens paths relative to the working directory, and IsBundle(".") holds. (5) Deliver steps use the built-in Go instantly/add-to-campaign — it wins resolution over an installed binding of the same id, ships in the binary, and takes a campaign id in these bundles because name resolution is a hard network error under the producer's dead proxy while an unreadable campaign by id is a preflight inconclusive … proceeding. (6) The vendors gtme does not ship travel inside the bundle as pattern-local bindings, never as built-ins: the waterfall's finders and verifier are fictional (finder-a/email, finder-b/email, verifier/email-status on reserved .example hosts) — the slot is the point; the traverse pattern's harvest/profile-posts and harvest/post-reactions are shaped from HarvestAPI's docs with fixtures hand-written to the documented shape and marked so in each conformance.json's note, per the handoff's instruction not to record with the stored key unasked. (7) test/e2e/bundles_test.go walks every manifest.json under bundles/, refuses one not listed in a chain, simulates each (hashes verified, SIMULATED, no simulation gap, no failed record), diffs the receipt's step table against the committed receipt.txt, and runs a chain's earlier bundles armed on the fixture engine between simulations, asserting no priced cost row but demo/enrich's and no delivery but to a group. (8) START.md gains a "Five patterns, frozen" section after the doors with the tarball fetch line (curl … archive/refs/heads/main.tar.gz | tar xz --strip-components=2 gtme-main/bundles/<pattern>); the four doors are unchanged, because a bundle cannot be edited in place and doors 2–4 are about editing a file. Why: Bundles are the SPEC §8 artifact and the handoff's spec for Launch 10; every choice above is layout, tooling or test structure. The one code change en route is a bug: bundle.Write resolved group/deliver on the adapter path (the first frozen handoff failed unknown adapter "group/deliver"), the same class as #28's SQL steps — fixed with planner.RunnerOwnedID, which names every runner-owned id in one place, and covered by TestBundleWithSQLSteps growing a handoff and a sql/traverse. Two warts seen and left: a record that fails a step's needs prints a file:// schema path into the receipt (the patterns gate with a sql/filter ahead of the step instead, which is also the better pipeline), and a receipt's avoided for a cached judgment prints ?.

2026-09-06 — The receipt names why records failed

Question: An armed run of the CSV example with no model key ended fit: 3 in, 0 out, … 3 failed and run … failed, and nothing on the receipt said the key was missing — the reason sat in step_events detail, one gtme show --run last away. §8 says every error names its fix; a bare count is not an error naming anything. Choice: StepStat tallies failure reasons verbatim from the failed event (FailReasons), and the receipt prints one line per distinct reason after the table — fit: 3 failed — ai: ANTHROPIC_API_KEY is not set (run \gtme secret set ANTHROPIC_API_KEY`)— most frequent first. Nothing new is written; the ledger already held the reason. **Why:** The receipt is the artifact a first run leaves behind, and the first failure a newcomer meets is a missing credential the plan only warned about. Spec-invisible: the receipt's step table and its annotations are §8's, and this is one more annotation in the sloton_missing` and suppression already use.

2026-09-06 — A deliver preflight under --simulate is a counted gap

Question: The zero-key demo moved its deliver step from a binding (attio/assert, fixture-served) to the built-in instantly/add-to-campaign, which preflights (ADR-040). The runner ran the preflight under --simulate, the adapter demanded its key and the network at OPEN, and the simulated run failed — against §8's "a simulated run MUST perform zero network calls" and its own banner. Skip, stub, or serve? Choice: Skip, and say so. Under --simulate a preflighting deliver step is not asked about its target; the receipt prints send: preflight skipped — the target is not read under --simulate; --dry-run checks it in the preflight slot, and the step's records are held as under --dry-run, as every simulated deliver already is. No preflight event is written. --dry-run remains the rung that reads the live target. Why: §8 already says a stubbed adapter's preflight is part of the counted gap; a deliver step is never stubbed (its records must be held and rendered), so the preflight fell through to the live path by omission, not decision. Serving the preflight from fixtures would need the process-adapter protocol to carry a fixture answer to a preflight session — a real addition to §5 for a check whose whole point is the live target's state. Spec-invisible: no verb, flag, schema or wire change; the receipt line lands in the slot ADR-040 defined.

2026-09-05 — M28 internals: types and traverse (ADR-054)

Question: How does a type file express §4's name fallback without code, how does the runner know which records belong to the segment after a traverse, how does a child RECORD say which parent it came from, what happens to a group that predates types, where does a reference get written, what vocabulary does a SQL step validate against after a traverse, and what does plan print so the graph a run builds is visible? Choice: (1) A hash tier is a list of components, the first required. A component is a field name, {any: […]} — the first alternative that yields a value — or {join: […]} — the named fields' values joined with a space, present only when every one is. Values are normalized, lowercased with whitespace collapsed, joined with | and sha256-hashed under the tier's prefix; the first component MUST yield or the tier yields nothing, and later components contribute their value or an empty string. person.json declares {hash: [{any: [full_name, {join: [first_name, last_name]}]}, company_domain], prefix: "nh:"}, which is §4's sha256(lower(full_name) + "|" + lower(company_domain)) exactly, plus the first+last form the old Go path already accepted; the golden keys are byte-identical. The domain is a salt, never a key on its own, which is what first-required encodes. (2) The segment after a traverse is derived from the traverse's own events, not a column. A record is eligible for the step after a traverse when its state is the traverse and it appears among that traverse's traversed/coalesced step events. Parents finished at the traverse share the state string, and in the same-type case ADR-054 (6) allows (people to their coworkers) they share the type too, so neither state nor type can separate parents from children; the events can, and they exist already for ADR-053's accounting. No migration, no run_records column. (3) One parent per session is the wire's parent attribution. A traverse dispatches one parent per adapter session; every RECORD the session emits is that parent's child. A child RECORD carries no parent reference on the wire — ADR-054 kept §5 unchanged — so the session is the attribution. The cost is a session per parent, which is what an enrich pays per record already. (4) A legacy untyped group stays untyped on its first write. EnsureGroup keeps whatever type a group has, null included; a terminus or group/deliver writing into an old group does not type it, and only gtme groups add --type does, once. ADR-054 (8) says so, and the alternative — a run's first write deciding a fact the operator never reviewed — is the kind of silent decision groups exist to prevent. The handoff named this as a likely ask; the conformant reading was cheap enough to keep. (5) References are written wherever their field lands. writeReferences runs after every field write — a source row, an enrich, a transform — not only at the source: company_domain arrives from apollo/enrich, not from the search, which is why the old relateCompany fired after enrich. A referenced identity that cannot be keyed is skipped, not a record failure — the old rule, kept. (6) A SQL step takes its segment's type. sql/transform, sql/filter and sql/traverse validate uses:/provides: against the registry of the segment they sit in; before this they were validated against the source's type, which is the wrong vocabulary after a traverse. An entity-blind pipeline leaves the type empty and the check open, as for any step. (7) Plan prints the graph. Every step gets an entity: line ((untyped group — entity-blind) for a legacy group source); a step whose provides carry a reference field prints writes: works_at → company (from company_domain); a traverse prints its type change with the relation and, with limit:, 1 child(ren) per parent (engine-owned); a cross-segment when: fails naming the step it precedes and the fix (gate at the traverse instead); a from mismatch names both types (mock/engagers traverses from post, but the records here are person). Why: Each choice makes something derivable from a fact the ledger or the plan already holds — the traverse's events, the session, the group row, the segment — rather than adding a column, a wire field or a flag to carry it. (1) is the one place the schema grew past the ADR's text: the ADR said a hash names fields, and the person fallback cannot be said that way without losing the first+last case, so the type-file schema admits the component forms and §4a now describes them.

2026-09-04 — M27 internals: record accounting (ADR-053)

Question: What exactly is empty, what exactly is a coalesce, what does in have to mean for the reconciliation to hold, and how does an adapter hand the runner a response to retain without claiming a field? Choice: (1) empty is an advance that wrote zero non-null fields. WriteFields already skips nil values and reports what it wrote, so the runner reads that count off the done event's fields detail in one place (advance) and applies it only to field-writing roles — enrich, verify, compose, review, and sql/transform (whose role is enrich). A filter's advance is a verdict and a deliver's a send, so theirs stay out. The "adapter said nothing" path already advanced with fields: 0; it now lands in empty with no other change. (2) in counts eligibility, not dispatch. It is bumped once, in runStep, for every record at the previous step's state — before when:, membership gates and caches — and nowhere else; the four per-item bumps (session chunks, the handoff loop, SQL steps, participant steps) are gone. That is the only reading under which `in = out + empty + filtered + failed

  • gated + skipped + cachedcan be true, and the price is that lines which read0 in, 0 out, 3 cachednow read3 in, 0 out, 3 cached. Three non-terminal states are named so an unsettled step still reconciles: in flight(pending),held (dry run)(a rehearsed deliver),simulated(a stub under--simulate). (3) **A coalesce is two rows, one identity, one run.** AddRunRecordreports whether the row was new; a row that resolved to an identity already in the run is not lost — its fields were written — but it is not a second record. Thecoalescedevent sits on the identity that absorbed the row, with the row's own identity-key candidates indetail.keysand the winner indetail.into, so SELECT ... FROM step_events WHERE event = 'coalesced'answers "which row merged into which identity" without a join through aliases. An identity known from an earlier run is *not* a coalesce: it is a new run record like any other, and the count was never wrong for it. (4) **A RECORD with no fields is the retention channel.**http/enrichused to answer an oversized response with a LOG alone; it now also emits a RECORD carrying no fields and the payload. The runner treats a fieldless RECORD as "nothing acquired" — no provides validation, no registry check, payload kept under the step's ADR-030 window, advance countedempty— becausefields: {}is dropped on the wire byomitempty and arrives as null, which no object schema accepts, and because validating an assertion of nothing is meaningless. Filters and attesting deliverers are excluded: their RECORD is never the whole answer. (5) **on_missing:on a participant step is decided inprepare,** before the caches: skipandfailsettle the record without a dispatch,runlets it through and counts it only if it is actually handed to the adapter (a cache hit is not "dispatched with a field absent"). "Absent" isstringify(value) == ""— missing or blank — the same testvariables:resolution uses, so the two policies agree on what a hole is. A skipped record reuses the deliver policy's receipt block andSkippedcolumn rather than growing a second vocabulary. (6) **The paid-zero mark is one phrase,FormatSpent,** used by the live receipt title and by gtme runs` (list and receipt) from the ledger, so the two can never say different things; the condition is "the source produced no record and any step recorded a cost". Why: ADR-053's one rule is that a receipt may not claim more than the run can substantiate. Every choice here makes a count derivable from a ledger fact that already exists — the write count on the event, the insert result, the pending event, the cost rows — rather than from a second tally the runner keeps beside the first, which is how the original defect arose.

2026-09-04 — M26 internals: once: (ADR-052)

Question: What does "finished" read from when the pipeline's shape has changed since a record was worked, where does the selection happen, and how does a rehearsal stay out of it when a dry run is an ordinary run? Choice: (1) Terminality is judged against each run's own snapshot: PipelineRecords reads the final step id out of runs.config_json (the resolved pipeline gtme answer and gtme freeze already reconstruct from), so a record that completed the last step of the pipeline as it ran is finished even if the pipeline has since grown a step. The alternative — the current plan's final step — would re-offer everyone already worked the moment an operator appends a notification step to a drain, which is the replay bug in a new coat. A snapshot without steps finishes at sourced. "Stopped" reuses the plan's own rule: a fail verdict on a non-deliver step (Plan.Stopped, the same judgment the terminus makes), so a withheld send never counts as a stop. (2) The selection is in Go, not SQL: every current member oldest-first, minus the finished set, capped at limit. The finished set is one join over runs and run_records per pipeline name; a 481-member group is nothing, and pushing the set into SQL would mean a temp table or a parameter list per run. Revisit if a group reaches the tens of thousands. (3) The plan-time count lives in CheckGroups, which already has the ledger and already fetched the group, so gtme plan and gtme run print the same numbers from the same code with no new call site. (4) runs.dry (migration 0012) is set by the runner from the same flag that withholds delivery, so it can never disagree with the receipt; the simulated case needs nothing because its ledger is a throwaway copy (§8). FinishedRecords skips dry records; nothing else reads the column yet except gtme runs, which appends (dry) to the status. Why: Each choice keeps one rule in one place: terminality in the snapshot the run itself wrote, stoppedness in the plan, the count where the group is already in hand, and the rehearsal flag on the row the rehearsal created. The one that cost a migration (4) is the one that could not be derived — nothing in a dry run's records distinguishes it from an armed run once the deliver step has receipted its variables.

2026-09-02 — M24 internals: participants (ADR-048, ADR-049, ADR-050)

Question: Where does the one implementation behind human/* and agent/* live, how does gtme answer know a step's contract when it is handed only a run, what does --set grade=B name when provides: landed the field namespaced, and how is the in-run walk proved without a pty? Choice: (1) internal/participant is the one implementation: the answer contract (ContractFor, Parse, Validate), the surface (Render), and the interactive Walker. The runner and the CLI both call it, so a person at a terminal inside gtme run and an agent answering later through gtme answer are held to exactly the same contract by construction rather than by two code paths agreeing. The human/* and agent/* manifests in internal/adapters/participants exist so the planner resolves and validates them like any adapter; the adapter they register fails loudly if anything ever opens a session. (2) gtme answer reconstructs the step from runs.config_json — the resolved pipeline snapshot gtme freeze already reads — and re-plans it, so the answer is validated against the declaration that actually pended the record, not against whatever the pipeline file says now. It needs no pipeline path, which is what lets gtme answer last work at all. (3) --set accepts the bare declared name (grade) as well as the namespaced one (review.grade): ADR-033 namespaces a declared provides: but the operator wrote the bare name in the YAML, and SPEC §11's own acceptance answers with it. A bare name resolves only when exactly one declared output ends in it; two that collide require the full name. (4) The answered event names who answered as human/<name> / agent/<name> (ADR-049 (6)), while field_values.source keeps ADR-026's form with the bare name after the @ — agent/review @ claude-code#<sig> (ADR-049 (8)). participant.Bare is the one place that reconciles them; without it provenance read agent/review @ agent/claude-code#<sig>. (5) The note lives on the answered event, so gtme show --provenance joins it back by run: AnswerNotes returns the note per run for an identity, latest in a run winning, matching collection's own rule. A value written by a participant therefore shows its note; nothing else does. (6) --pending writes NDJSON to stdout and the rendered surface to stderr, like every other read verb, so one call serves both an agent and a person. A pending step that is not a participant (a deferred adapter batch) is named and skipped rather than failing the listing. (7) The acceptance's pseudo-TTY leg is proved below the terminal: the walk's asking, validation and stop-on-interrupt in internal/participant, and the ask/do-not-ask decision (canAsk: human + prompt: tty + interactive + not a rehearsal) in internal/runner. What is left unproved by a test is the single term.IsTerminal(stdin) call that sets Interactive, because a pty is a dependency beyond §2 and this did not seem worth one. Recorded here rather than silently narrowed; SPEC §11's M24 acceptance says the same. Why: Every one of these is a place where the obvious implementation would have split a rule across two code paths that then drift — two validators, two provenance formats, two surfaces. The participants package exists to make the drift impossible rather than to remove duplication.

2026-09-01 — M23 internals: honest costs + engine-owned limit (ADR-046, ADR-047)

Question: How does a binding's templated rate reach the planner, what does "labels its emissions" mean for built-ins that never read a vendor cost, and where exactly is limit stripped? Choice: (1) Migration 0010 rebuilds costs (not ALTER) so basis lands where §3 declares it, before detail; the schema-conformance test compares column order, and the spec's DDL is the one that was approved. (2) protocol.Cost() now sets basis: estimated explicitly and protocol.MeasuredCost() is the only constructor that says measured; the reader's CostBasis() maps absent to estimated, so foreign and pre-M23 adapters degrade honestly. Of the built-ins, only the claude-code engine ever measures (total_cost_usd in the CLI's JSON is vendor-reported); the Anthropic API path prices tokens from our table and is estimated; harvest, instantly and the binding engine multiply rates and are estimated. (3) ai.Response gains Measured; a batch collection is measured only if every summed item was. (4) The binding tier's Cost.AmountUSD becomes any (number | template, schema anyOf with a single-placeholder pattern); the engine resolves it through the same tmplContext as page_size, and an unresolved template costs $0. The manifest bridge carries a CostRate func(config) (float64, bool) (never serialized, json:"-") instead of a static cost_estimate_usd, resolved per step at plan time with the binding's config defaults applied; the planner prints est/record: unset when it resolves to nothing. (5) ledger.CostTotal{Measured, Estimated, Estimates} — the count of estimated rows is what lets a $0 guess print (estimated) while a run that spent nothing prints bare; runner.FormatCost is the one formatter and gtme runs calls it, so both surfaces agree by construction. The per-step cost column stays a plain figure; only totals carry the basis (§8). (6) limit is dropped before config validation only when the step is a source binding whose config_schema does not declare it (planner.withoutReservedKeys); the engine reads the cap from the OPEN config and, when undeclared, removes it from the template context so the binding never sees it. A declared limit (apollo/search) is untouched end to end. Why: make check green; the e2e proves the §11 M23 clauses offline (templated rate at the operator's figure, unset → unset/$0 estimated, measured row + split total on both receipts, undeclared limit: 1 → one request, one record; an unknown key still refused). Spec impact: None beyond v0.32 (marked built as v0.33).

ADR-047: Source limit is engine-owned, as documented

Status: Accepted (2026-08-31 — drafted by the backlog session from issue #32; human-approved 2026-09-01 by merging the packet) Context: gtme help --bindings describes limit as engine config for source bindings ("config limit caps emitted records"), and the cap genuinely works — it terminates pagination early rather than trimming the result. But limit is validated against the binding's own config_schema like any other key, so a binding with additionalProperties: false — which the docs encourage and every shipped binding uses — rejects it unless its author happened to declare it. The failure reads as "the operator passed a bad key," and every strict community binding silently opts out of the one control an operator has over what a paginated source spends (#32). pagination.max is a fixed integer and cannot be templated, so limit has no substitute. Decision: limit is a reserved engine key for role: source bindings. Config validation MUST accept it whether or not the binding's config_schema declares it: when undeclared, the engine validates the remaining config with limit removed and keeps the cap to itself; when declared (as apollo/search does), the binding receives it unchanged — existing bindings keep working, templating included. Either way the engine caps emitted records and terminates pagination at the cap. The documentation becomes true as written; no binding has to opt in. Consequences: Retroactively fixes every community binding whose author did not copy the key across. Removes a per-binding correctness requirement with no upside. A step further — stripping limit from declared schemas too — was rejected: bindings legitimately template it into requests. Spec impact: AMEND (this packet's second commit) — §10a (the source role's reserved key); §11 (folded into milestone M23). gtme help --bindings and CONTRIBUTING ride the build.

ADR-048: Three roles, any participant — filter, compose, review; and the referent

Status: Accepted (2026-09-02 — drafted by the M23 session from ROADMAP.md "Participants — humans and AI in the pipeline" and "Interactive review step", then reshaped in conversation with the human on 2026-09-01/02; human-approved 2026-09-02 by merging the packet) Context: Judgment and writing in a pipeline are done today by one kind of participant — an API model behind ai/filter and ai/compose — and every other kind is either absent (a person reviewing records, an agent judging with its own model) or bolted on as a subprocess (the claude-code engine, §2). Before any of those grows its own step kind, the roles themselves need stating, because the first draft of this packet confused them: it framed "review" as a third kind of step and "engine" as who answers, and neither survived a plain reading. In the ledger's model a participant does exactly one thing — write facts about a record (ADR-003) — and what differs between roles is what the facts are for: whether they gate, whether they are the value or an opinion about a value. One mechanical gap exists for opinions: field_values cannot say which value a judgment was about, only which identity, so a second draft of a line cannot be told apart from the first in the review's provenance, and the judgment cache (ADR-039) cannot know a revised draft deserves a fresh look. Decision: (1) Three roles, distinguished by what goes in, what comes out, and what it is for — and nothing else:

RoleInOutGates?Purpose
filterthe record's fields (uses:)pass: true|false + reason (VERDICT), optional declared fieldsyes — a fail freezes the recorddecide which records continue
composethe record's fieldsnew field values (declared provides:, or the adapter's default)nowrite something new; with of: (below) it is an edit — a new value of the field named
reviewone value (of:, required) + context (uses:)labels about that value — a grade, a yes/no, notes — as declared fieldsno, neverjudge a specific value, for tuning, reporting, or a later sql/filter

Whether "looks good / doesn't" or "A–F", a review differs only in its declared vocabulary; an edit is a compose, not a fourth role. A verdict on the wire stays a boolean and in run_records.verdicts stays pass|fail; when: <step>.passed reads the filter role only. (2) Any participant fills any role under one contract: the output is validated against the step's declared (or default) output schema, written as facts with provenance naming the participant, and consumed downstream by what already exists (when:, sql/filter, groups) — never control flow. Participants are adapters named for who answers (ADR-026's rule, applied honestly): ai/* (the API), human/* and agent/* (ADR-049). No engine: key (ADR-050). (3) The referent. of: <field> in a compose or review step's config names the value the step is about. The planner validates it as one more uses: entry (§7); the runtime includes its current value in the record's input hash (ADR-039) — a rewritten draft is re-reviewed, an unchanged one is not — and records its field_values.id on every fact the step writes: field_values gains referent TEXT NULL (was-about), shown by gtme show --provenance. One nullable column; no relation, no table. (4) review is a manifest role (§6, beside filter and compose): the runner treats it as compose-shaped — records advance on RECORD, no VERDICT is expected — and the planner requires of: on it and refuses when: <review>.passed. ai/review joins ai/filter and ai/compose under that role (same adapter code, a manifest and a prompt shape); ai/filter and ai/compose may carry of: and are otherwise unchanged. (Validated against the code 2026-09-02: a filter-role step that returns no verdict fails the record, so review could not ride the filter role.) (5) The arm gate is not a role — dry-vs-armed stays a property of the run (ADR-019, ADR-031), never a step, so nothing this ADR admits can compose it away. (6) Routing stays a pattern. A route: key is declined on the record: a verdict fact, a per-branch sql/filter and group/deliver route N ways today, and "rejected → nurture" is a second pipeline reading the verdict; gtme help --agent gains the example. Consequences: The vocabulary is three words with a table behind each, and adding a participant kind is a naming and surface question, never a grammar one. Review gains the one thing it lacked (a referent) for one nullable column, and the cache becomes correct for reviews and edits without a new rule. The cost of "review never gates" is one extra step when a review should gate (a sql/filter on the grade) — deliberately, so a grade stays a fact and a gate stays a filter. Spec impact: AMEND (this packet's second commit) — §3 (field_values.referent), §7 (of: validated as uses:), §8 (gtme show --provenance shows the referent; the routing example in help --agent), §9 (of: grammar), §10 items 3, 5 and a new ai/review; §11 milestone M24 with ADR-049/050. spec/ledger.sql and the manifest schema ride the build.

ADR-049: People and agents are adapters — human/*, agent/*, and gtme answer

Status: Accepted (2026-09-02 — drafted with ADR-048; human-approved 2026-09-02 by merging the packet) Context: A person reviewing records was named in ROADMAP.md as "an ai/filter with a human behind the contract" and an agent judging on its own as a "session" engine. Both put who answers in a config switch under a name that says someone else answers, which reads as a contradiction the moment it is written down. A person's step also has a genuinely different contract from a model's: its config is what to show and whether to wait, not a prompt and a batch size; its runtime is a terminal or nothing; its cost is nothing. The rule "no waiting stays in the runner" (ADR-038) was written for batch APIs and cron, and applying it absolutely to a person at a terminal turned the simplest case — sit down, review twelve drafts — into three commands. What was missing underneath was a write path: no verb lets a participant that is not an adapter put a validated judgment into the ledger. Decision: (1) human/filter, human/compose, human/review are runner-owned adapters (no subprocess, no protocol session) filling the three roles of ADR-048 for a person. Config: render: {fields: [..], template: ".."} — what the person is shown (default: the uses: fields, or the of: value alone) — and prompt: tty | never (default tty). (2) At a terminal the run asks; otherwise it waits in the ledger. With prompt: tty and a TTY, the step walks its records inside gtme run: for each, the rendered record, then the declared outputs as a menu (an enum) or a field to fill, validated on the spot; Ctrl-C leaves the rest pending. With no TTY, or prompt: never, every unanswered record ends pending under the runner-owned token <run-id>/<step-id>, the run finishes pending exactly as a deferred step does (ADR-038), and the receipt names the count, the participant, and the verb that answers. (3) agent/* are aliases of human/* — one implementation, three more manifests — for a step whose judgment an agent driving gtme supplies itself (its own model, its own reasoning, or a person relayed through a conversation): prompt: never always, provenance prefix agent/. They exist for legibility only: the pipeline file says whose work a step is, and gtme runs says who is awaited. (4) gtme answer is the write path — one verb, for every pending human/*/agent/* step in every role: gtme answer [RUN_ID|last|PIPELINE] [STEP] [IDENTITY_KEY] [--set field=value ...] [--as NAME] [--cost USD [--measured]] [--note TEXT]. The run is a RUN_ID, last, or a pipeline name or path (the most recent pending run of that pipeline — the lookup collect-first already makes); STEP may be omitted when one step is pending. With a key and --set, it records that participant's answer as an answered step event (§3; detail: the fields, the participant, the note, the cost), validated against the step's declared or default outputs — a filter takes pass=true|false and reason, a compose the declared fields, a review the declared labels; a value outside an enum is refused naming the allowed values; a record not pending under that step is refused. With no key and a TTY it walks the pending records interactively — the same code the in-run walk uses. --as names the participant (human/<name>, default the OS user; an agent passes --as <name> under an agent/* step and the prefix follows the adapter); --cost records what the participant spent, estimated unless --measured (ADR-046); --note is free text kept in the event and shown by gtme show --provenance, never part of any cache key. Answers are ledger state, idempotent per (run, step, identity), the latest before collection wins. gtme answer writes only answered events, never sends, and never appears in gtme freeze output. (5) gtme show --run RUN_ID --pending [STEP] prints the pending records with their rendered surface as text and as JSON — what an agent reads before it answers. (6) gtme run collects answers as it collects batches: when a pending step is human/*/agent/*, collection reads the answered events instead of opening a session; each answered record completes the step (VERDICT for a filter, fields for the rest, provenance human/<name> or agent/<name>, the referent when of: was declared, COST under the run) and continues to the next stage with the others; unanswered records stay pending and the run stays pending. (7) A human or agent step may sit anywhere. ADR-038's last-step rule stays for deferred batches and is not applied here: an unanswered record never reaches a later step, and the person answering is the review. The consequence to know: a pipeline is run stage by stage (every record through step N before step N+1), so a human step is a slow stage, and under cron a pending run is resumed, not re-sourced (ADR-038 collect-first) — the pipeline waits for its person and sources nothing new until answered. That is the intended shape, and the documented pattern is the project's own: the reviewing pipeline is one a person runs and ends in a group; the cron pipeline sources from that group. gtme plan prints one note when a deliver step follows a human/* step: "under cron this pipeline waits for a person." (8) Provenance and the cache: field_values.source takes ADR-026's form with the participant in the model's place — human/review @ <participant>#<sig>, agent/filter @ claude-code#<sig>. The ADR-039 judgment signature for these adapters is over the step declaration alone — the adapter id, render:, the declared outputs, uses:, of: — never the participant name: the cache is checked at dispatch, before anyone has answered (validated against the code 2026-09-02), so the name cannot be in the key. A person's answer on the same value is remembered like a model's, whoever answers next; cache: 0d / respend: true ask again on purpose. Nothing an agent does internally is signed, because the runner cannot attest to it. Consequences: The human review ROADMAP.md named becomes three small adapters and one verb, with no new grammar beyond render: and prompt:; the agent case is the same code under a name that says so. Guidance to carry into help --agent and the README, because each is a nuance someone will trip on: a cron pipeline with a human step stalls until answered; Ctrl-C mid-walk leaves the rest pending; the latest answer before collection wins; an unchanged value is not re-asked; a review never gates by itself. Multi-pass agent workflows need nothing here — they answer under an agent/* step like any agent — and stay in ROADMAP.md only as the question of whether a signed workflow identity should ever enter the cache key. Under --simulate a human/*/agent/* step is a simulation gap (§8): records pass through untouched, counted in the receipt — there is no prompt to script and no person to rehearse. A build note: every "AI step" predicate in the runner and planner (the cache, provides: gating, entity-agnosticism, the simulate exemption, the respend warning) keys on the ai/ id prefix; it becomes "participant adapter" (ai/, human/, agent/), spec-invisible. Spec impact: AMEND (this packet's second commit) — §3 (step_events.event gains answered), §6 (review role), §7 (render: fields validated as uses:; the cron note), §8 (gtme answer, gtme show --run --pending, the participants subsection, receipt and gtme runs wording), §9 (render:, prompt:), §10 (the human/* and agent/* adapters), §10a (provenance form), §11 milestone M24; spec/schemas/pipeline.schema.json and the manifests ride the build.

ADR-050: The claude-code shell-out and the engine: key retire

Status: Accepted (2026-09-02 — drafted with ADR-048; human-approved 2026-09-02 by merging the packet) Context: The claude-code engine (§2) runs one claude -p subprocess per batch so an operator with Claude Code authenticated need not hold an API key. It blocks the runner on a process it does not control, has no batch surface (ADR-038), truncates long prompt lines (ADR-035's finding), and inverts the relationship the project actually has with agents: Claude Code drives gtme — it runs pipelines, reads receipts, queries the ledger — it is not a service the CLI calls. With agent/* steps and gtme answer (ADR-049), an agent that wants its own judgment in the ledger records it as the driver, on its own trigger, with provenance that says so. That leaves engine: with one legal value (api; the fixture engine is test-only and chosen by environment, never in YAML). Decision: (1) The claude-code engine is deleted: §2 names the API as the only model engine; the claude binary is no longer looked for. (2) The engine: config key is removed from the AI manifests and the pipeline grammar; engine: anywhere is a plan error, and engine: claude-code names the replacement (agent/* + gtme answer). (3) The fixture engine is unchanged: GTME_AI_ENGINE=fixture and --simulate select it. Consequences: One fewer way to block the runner; the vocabulary loses "engine" entirely (adapters run steps, participants answer them). Any pipeline naming claude-code breaks at plan with the fix named; no shipped example uses it. The only built-in that emitted a measured cost (ADR-046: the CLI's reported total_cost_usd) goes with it — an agent reports its own spend through gtme answer --cost --measured, and everything else stays honestly estimated. Spec impact: AMEND (this packet's second commit) — §2, §6 (the credentials_optional example), §9, §10 item 3; spec/schemas/ manifests ride the build.

ADR-055: webhook/source is deferred — the recipe stands, the adapter does not ship in v0

Status: Accepted (2026-09-06 — from AUDIT.md's deferred (a) item, the one place the spec described an adapter the binary does not contain; human-approved 2026-09-06 by merging the packet, PR #58) Context: ADR-009 answered "run a pipeline when an event happens" without a daemon: a commodity receiver appends payloads to a spool, and a scheduled gtme run drains it through a webhook/source adapter. §8 documents the recipe, §10 item 8 specifies the adapter, §11 M5 lists it among that milestone's acceptance and marks the milestone built, §13 cites it as the reason no scheduler exists, and README.md and ADAPTERS.md list it among what ships. No package, manifest or fixture for it has ever existed: AUDIT.md flagged the gap at the reconciliation pass and deferred it to the build backlog behind the validation campaign, and nothing since has asked for it — eight operator stories, two live campaigns and three agent round-trips ran without an event source. ROADMAP.md's listen entry names the shape events will actually need, a record that correlates to an identity rather than minting one, which item 8's csv-clone does not attempt. With a public launch ahead, the one claim a stranger could falsify in five minutes should be withdrawn by saying so, not answered by building the guessed shape. Decision: (1) The adapter moves to ROADMAP.md. webhook/source leaves §10; the M5 acceptance clause that was never met is struck with a note; ADAPTERS.md and README.md stop listing it. (2) The recipe stands, stated as what ships. A scheduled gtme run (cron, launchd, a CI schedule) is the v0 answer to events, and a receiver that appends rows to a CSV works today through csv/source: rows re-source on every run, and identity coalescing (§4), the judgment cache (§7) and delivery idempotency (§8) make that cheap and safe. What is deferred is the NDJSON spool adapter that marks lines consumed. (3) §13 keeps "no scheduler, no daemon" and cites the scheduled run over a receiver-written file as the answer, not an adapter that does not exist. (4) ADR-009 is not retired. Its decision — spool plus scheduled run, no daemon — still governs; only its "add webhook/source to the adapter list" spec impact is superseded here. (5) It returns with listen. An event source that correlates to identities is one design pass, and the spool adapter is its transport; designing the transport first would fix the shape before the semantics are decided. Consequences: Zero code changes — nothing deletes because nothing exists — and make check is unaffected (help --agent's examples were written to avoid the adapter for exactly this reason). The docs stop overpromising on the one point that could be checked. The event-driven story becomes "scheduled runs over a CSV a receiver writes," which is true and shipped. The cost is that a spool re-sources on every run until the adapter exists; downstream spend is bounded by the caches. Rejected: Building it now — a near-clone of csv/source is a day's work, but it builds the event shape ADR-009 guessed at before listen decides what an event is, and no receipt asks for it. Leaving §10 as it is until it is built — that is the docs-lying risk this closes. Retiring ADR-009 — the no-daemon decision is right and still governs. Spec impact: AMEND (this packet's second commit) — §8 (the recipe, reworded to what ships), §10 (item 8 removed), §10a (the universal floor's In set), §11 (M5's unmet clause struck with a note), §13 (the no-daemon answer). README.md and ADAPTERS.md drop the adapter. AUDIT.md's deferred item closes by reference. ROADMAP.md gains the entry.

ADR-056: demo/enrich — a priced, keyless enrichment so the zero-key path shows the cache

Status: Accepted (2026-09-06 — from the launch onboarding work: the zero-key demo cannot print the top-up receipt, because a simulated run persists nothing by ADR-028's own design; human-approved 2026-09-06 by merging the packet, PR #63; built as M29) Context: The README's strongest line is the second run: overlapping records cache-skip and the receipt prints dollars avoided. Today that receipt needs a key. Every priced step either calls a vendor or a model, and --simulate, the only keyless rung, executes against a throwaway copy of the ledger (§8; ADR-028 chose ephemerality so nothing synthetic reaches the durable layer). So START.md's first door shows two identical SIMULATED receipts and explains why, and the cache story waits for the first key. Three ways out were weighed: a flagged simulated layer that simulated runs read (ADR-028 left ephemeral-vs-flagged open, but it is a migration, view and cache-query changes, a new banner, and synthetic facts living beside real ones in every operator's ledger, all for one receipt line); accepting that the second receipt is the keyed door (true, and the zero-key path stays half a story); or an enrichment that is honest about being pretend and priced like a real one. Decision: (1) A built-in enrich adapter, demo/enrich. Role enrich, entity person, needs any identity (email or full_name), provides demo.score (integer 0–100) and demo.note (string). It is deterministic — the score derives from the identity key's hash, the note is the fixed string synthetic — demo/enrich called no vendor — and it performs no network call, needs no credential, retains no payload. It runs armed, and under --simulate it runs exactly as armed: it is never a simulation gap, because there is nothing to serve. (2) Priced, honestly. Config cost_per_record_usd (default 0.01) through the ADR-046 mechanism, basis estimated; cost rows land under demo/enrich@1 like any adapter's, gtme plan prints est/record: $0.0100, and the adapter id is the label wherever a dollar appears (the receipt, gtme runs, costs). The arithmetic is real over a stated pretend price. (3) Cacheable. freshness_days 30 by default (config overrides; cache: per step as always), so a second armed run cache-skips and prints avoided. (4) The zero-key door gains a second file. examples/cache.yaml: csv/source over a shipped examples/contacts.csv (three fictional rows) → demo/enrich → sql/filter on demo.score → csv/deliver. Two armed runs, no keys: the second reads cached 3, avoided $0.0300, 0 out on delivery. examples/demo.yaml keeps its job — the real-vendor shape, simulated. (5) Not a vendor. It is never listed in the registry index, gtme adapters verify has nothing to verify, help --agent carries it with a one-line note, and the demo/ prefix is reserved so no pipeline mistakes its output for data. Consequences: About 150 lines of Go behind the existing built-in interface (registered like csv/source), a manifest, unit tests, an e2e over the example, ADAPTERS.md, README and START.md door 1. An operator's ledger can hold pretend cost rows, labelled by adapter id — as a mock run's rows would be. Onboarding's first five minutes gain the cache story with zero keys, on a persisting ledger, with gtme show --provenance and gtme runs last working against it. Rejected: The flagged simulated layer — bigger, touches every ledger for one line, and may return with simulate-replay (ROADMAP.md) when replaying retained payloads gives simulation a reason to remember. Pricing mock-enrich-py — an external example adapter in Python, installed by install.sh only, absent from the tarball and the tap. Teaching --simulate to persist — contradicts ADR-028's reason for existing. Spec impact: AMEND (this packet's second commit) — §10 gains item 9; changelog. README.md, ADAPTERS.md, START.md and the example ride the build.

ADR-057: template: — one key for operator text, rendered by role

Status: Accepted (2026-09-13 — from a design session on how prompts, templated copy and files fit the grammar; human-approved 2026-09-14 by merging the packet, PR #71; built as M30 and M31, shipped in v0.6.0) Context: Three places in gtme already hold operator-authored text with holes: a binding's request template ({{config.x}}, {{record.x}}, {{variables.x}}, with a | fallback), a human/* step's render: {template:} (bare {{field}}), and a templated cost.amount_usd (ADR-046). Each is substitution only — "no expressions, no computation" — and each is its own small dialect. A fourth kind of text, the AI step's prompt:, has no holes at all and is deliberately kept that way: ADR-035 appends records to the prompt as a fenced, mechanically encoded payload and never interpolates them, so the prompt stays the shared block a cache breakpoint can sit behind and fetched content cannot bypass the fence. ADR-019 already named prompts and campaign templates as one class — text whose needs a static manifest cannot know — which is why uses: and variables: exist. ROADMAP.md's "Packs" entry (2026-09-05) asked for the missing half: a prompt that comes from a file, hashed into the judgment signature and carried by the bundle, so a persona authored once serves several pipelines. And nothing in the grammar renders text per record without a model: a first line that is "Hi {{first_name}}, saw your post on {{topic}}" is deterministic and free, and today needs an ai/compose to write it. Two questions were open. Whether prompts and templates are the same thing: they are, in what you write; they differ in what the text is rendered against — a batch prompt sees config, a per-record template sees the record. And whether conditional logic belongs in text at all, given the no-computation rule: that rule protects binding requests (an expression in a wire request hides a contract) and the ledger's determinism; a sandboxed template that produces a text field breaks neither, so long as the logic never decides what advances or what is judged. Decision: (1) template: is the one key for operator text. On every participant-role step (ai/*, human/*, agent/*, and text/* below) with.template is a string or {file: <path>}. prompt: as the AI step's text key retires: with.prompt on an ai/* step fails gtme plan naming template:. The human/* mode key prompt: tty | never (ADR-049) is unaffected — it never held text. render.template (ADR-049) folds into template:; render.fields stays as the no-template surface. (2) The dialect is Liquid, bounded. Objects {{ … }}; tags if / elsif / else / unless / case / when, for (with limit, offset, reversed), comment, raw; filters default, truncate, truncatewords, size, first, last, join, upcase, downcase, capitalize, strip, date. Nothing else: no assign / capture / increment (no state), no include / render (no file reaches another file), no unlisted filter. An unknown tag or filter, or a parse error, is a plan error. Liquid over a logic-less dialect because campaign copy outgrows presence-only sections on the first | default: or "first two posts", and migrating templates between dialects later is worse than choosing the fuller one now; Liquid over Handlebars because it was built for untrusted authors — a closed tag and filter set, no host helpers. It is declared the dialect for every {{ }} in gtme; the binding request templates, render:, and the cost template move to it in M31. (3) What a template sees is set by its role, and plan enforces it. Every reference is namespaced — config.<key> for the step's own with: (minus template itself), record.<field> for the record. A batch step (ai/*) renders once per step over config.* only; the rendered text is ADR-035's shared block, unchanged in every other respect (records still arrive as the fenced payload), and record.* in an ai/* template is a plan error that names the rule. A per-record step (text/*, human/*, agent/*) renders once per record over config.* and record.*, where record.* is limited to the step's uses: and of:; a reference outside them is a plan error, exactly as an undeclared need is. uses: stays explicit — the template may reference a subset of it, never a superset. (4) text/compose is a runner-owned compose-role adapter, the sibling of human/compose: no subprocess, no session, no credential, no cost, entity-agnostic. Config template:; uses: as any compose; provides: required and exactly one field — the rendered text is that field's value. A render that is empty after trimming writes nothing and the record continues (a later step's needs decide what that means). It emits RECORDs only, never a VERDICT: a template's if shapes text, it does not gate; a comparison that encodes judgment ("is this a large account") belongs upstream as a labelled field from a review, which the template then tests for. Runs identically armed and under --simulate; never a simulation gap. (5) Files. {file: <path>} resolves relative to the pipeline file; a missing or unreadable file is a plan error. The contents are the template source, treated exactly as an inline string — same dialect, same role context, same checks; a file with no placeholders is verbatim text. The loaded source enters runs.config_json in place of the reference, so the snapshot is self-contained; freeze --bundle keeps the reference and packs the file by content hash; bare freeze inlines the contents as a block scalar so its stdout stays self-contained. (6) Signature and provenance. ADR-039's judgment signature hashes the template source after loading plus the config.* values the template references — so a changed file or a changed persona re-judges, an inline and a file template with the same bytes share a signature, and batch_size stays out of it as today. text/compose takes ADR-026's provenance form with nothing in the engine's place — text/compose @ #<sig> — and the input hash over uses: as every participant adapter, so an unchanged record under an unchanged template is skipped_cache like a model's answer. (7) Fencing is transitive. A field written by a text/* step counts as externally fetched for ADR-035's fence when any field the step uses: counts as fetched; the runner computes this from the step's declaration at projection — no ledger change. (8) Dependency. github.com/osteele/liquid (MIT) joins §2's list, with its transitive github.com/osteele/tuesday (strftime, for date) and gopkg.in/yaml.v2; its module graph also lists lint tooling under a tool directive, which reaches go.sum and not the binary. Verified 2026-09-13: its basic engine registers no tags or filters, so the allowlist is exactly what the runner registers, and strict mode errors on an undefined reference. Bindings keep the in-house substitution engine until M31. Consequences: One loader (string | {file:}), one parser, one reference check, reused by four adapters; the binding engine's "no expressions" comment retires in M31, and the rule it protected is restated as (3) and (4): logic renders text, and never chooses what advances. text/compose makes a rendered first line, a subject, a note an ordinary field a deliver step's variables: maps or an ai/* step uses: — the deterministic half of personalisation stops costing a model call. The rename touches every shipped pipeline (README, ADAPTERS.md, VALIDATION.md, three examples, eight bundles), the pipeline schema, the AI adapter's config struct and manifests, help --agent, and the create-pipeline skill; all ride M30 as one mechanical pass. Under --simulate a text/compose step runs for real. Guidance for help --agent, each a nuance someone will trip on: record.* works only on per-record steps; uses: is what the template may read, not what it must; an empty render writes nothing; a comparison in a template shapes text and gates nothing. Deferred to ROADMAP.md: {{ variables.* }} in a text/* template (a constant merge value is a text/compose field), further filters, and a template file including another (no, until a pack needs composition). Rejected: Two keys (prompt: for instruction, template: for text) — the same artefact under two names, and prompt: was a misnomer on the one step where the rendered text is the output. prompt: as the one key — collides with ADR-049's prompt: tty | never, and renaming that built key to make room is worse than naming the text by what it is. Records in AI prompts — collapses ADR-035's shared/payload split, makes the signature per-record, and routes fetched content around the fence. Mustache-style sections only — no default, no limit, outgrown immediately and migrated later. Handlebars — logic lives in host-registered helpers, each a function to write and version. Inferring uses: from the template — ADR-004 made it explicit for a reason; the check runs both ways instead. A multi-field text/compose — one template renders one text; a second field is a second step. Spec impact: AMEND (this packet's second commit) — §2 (dependency), §7 (signature over the template source; the reference-scope check), §8 (freeze and bundle carry template files; the participants' surface), §9 (template: grammar, the file: form, the example), §10 (items 3, 3b, 5 say template:; new item 10 text/compose), §10a (provenance form), §11 (M30 and M31 queued), changelog v0.47. spec/schemas/pipeline.schema.json, the manifests, examples, bundles, README, ADAPTERS.md, VALIDATION.md and the plugin skill ride the build.

ADR-058: Plain words on the operator surface

Status: Accepted (2026-09-26 — from a design session reading the whole vocabulary against a semi-technical operator; human-approved by merging this packet, PR #105; built as M32) Context: docs/DESIGN.md fixes the project's nouns and the gate-ladder words, and each concept page glosses its term once. A read of the full vocabulary against the reader the docs are for — someone who runs go-to-market and is not a programmer — found the words an operator meets daily are mostly plain (pipeline, step, run, receipt, plan, dry-run, armed, group), and that the trouble concentrates on the two artifacts they read most, the plan and the receipt, where engineering vocabulary and process citations print by default: projects: for what a step reads; of: … (the referent — its value joins the cache key, its id the provenance; ADR-048); terminus:; coalesced on every source and traverse line; fixtures only on every simulated run; and ADR-nnn / SPEC §n citations on plan lines, receipt titles and some sixty error messages. Two names collide with the reader's prior. Segment means a saved audience to every GTM reader (the §8 sense, ADR-021) and ADR-054 also uses it for a typed stretch of a run. The first example pipeline, examples/cache.yaml, is named cache, so on the pages that teach the cache: key and the cached column every sentence about caching carries three senses of the word. And a name-hash identity key prints as a bare 64-character string with nothing saying what it is. The reference surfaces are a different case. help --agent, help --bindings, SPEC.md and DECISIONS.md are read by agents and adapter authors, for whom the citations are the point; they are unchanged. Decision: (1) The default surface speaks the docs' vocabulary and carries no citations. Everything gtme prints for an operator by default — plan, receipts, progress lines, errors — names the thing and the fix and carries no ADR- or § reference. Citations stay on the reference surfaces (help --agent, help --bindings) and in the canon, which the docs link. Test assertions pin the plain form. (2) Plan labels. projects: becomes reads:. The of: line prints of: <field> (the value under review). The terminus: line prints ends in group "<name>", with as <type> when the run is typed. send surface: and the deferred: line keep their words, minus the citation. (3) Receipt words. On a source line (N coalesced into known identities) becomes (N already in the ledger); on a traverse line N coalesced becomes N already in this run. The step_events.event value coalesced is unchanged: it is a ledger enum, an agent's surface, not an operator's. (4) Simulate says what it does. The banner reads simulate: recorded responses only — no network, no spend, nothing sends, nothing persists and the receipt title (SIMULATED — recorded responses only; nothing sent, nothing persisted). Fixture stays the adapter author's word on the adapters verbs and in help --bindings. (5) A typed stretch of a run is a leg. §7's "typed segments" become typed legs; segment keeps one meaning, a saved query over the ledger. ADR-054's wording is amended by this entry, not superseded. Plan errors that said "typed segment" say "leg". (6) gtme show names the key's tier. The record object gains identity_key_tier: the type file's identity field the key was derived from (email, linkedin_url, domain, …) or name_hash, so a bare nh: key is explained where it prints. Additive; no other schema change. (7) The first example is examples/hello.yaml, pipeline hello. Its steps and fields are unchanged; only the file and the pipeline name move, so the docs' first pipeline no longer shares a name with the key and the column it teaches. examples/cache.yaml is removed; README, START.md, ADAPTERS.md, the e2e tests and the docs follow. Consequences: The plan and the receipt read in the words the concept pages use, so a page can print output without glossing a label. Error text gets shorter, and no operator has to know what an ADR is. Agents lose nothing: the citations they use are in help --agent and the canon. The docs pages that print plan or receipt output (Concepts 1–4, 10–12, and Start's show-me) are re-run after M32 so their artifacts stay real; until then they show the old labels. Pipelines are unaffected: no key, no manifest field, no wire message and no DDL changes. Spec impact: AMEND §7 (legs; plan labels), §8 (the plain-words rule, receipt words, simulate banner, gtme show), §10 item 9 and §11 M29 (the example's name), §11 (M32 queued), Changelog (v0.50). docs/DESIGN.md's noun list gains leg; docs/_outline.yaml's terms gain leg.

ADR-059: Vendors leave the binary — every vendor adapter is a registry entry

Status: Accepted (2026-09-26 — from a design session after the registry's catalog pages went live; human-approved by merging this packet; build queued as M33, Instantly's move sequenced behind the engine work ROADMAP.md names. Decision (4) superseded 2026-09-29 by ADR-063: Instantly leaves the binary as a registry process entry, and the engine work closes) Context: ADR-042 put vendors in a registry and kept a carve-out: "the binary carries the floor and the reference twins, nothing else … the reference twins stay because they are the conformance kit for the Go adapters." Three of those twins are not a kit for anything: apollo/search, apollo/enrich and attio/assert are the registered adapters themselves, compiled in. Two Go process adapters remain, harvest/profile and instantly/add-to-campaign, each with an unregistered binding twin that is narrower than it. So the binary ships five vendor adapters and the registry ships one, and the line between them is historical, not principled. It will not hold: the next Harvest adapters (the harvest/profile-posts traverse in bundles/posts-to-engagers, harvest/post-reactions) are bindings, and a vendor whose first adapter is compiled in while the rest install from the registry is two distribution models for one API. The catalog page makes the split visible: five entries read "built in", the rest "verified", with nothing a reader can use to tell why.

What each Go adapter does that its twin cannot, read from the code (2026-09-26): harvest/profile makes a second call per record for recent_posts (posts_limit), formats role_history as one line per position ("Role at Company (2019–2023)", with stated fallbacks), and reads its cost from cost_per_profile_usd. instantly/add-to-campaign resolves a campaign name to an id once per run, runs ADR-040's four preflight checks (the campaign is Active; the sequence has as many steps as the highest _step_N target assumes; every non-first-class target appears as {{name}} in some step; every variant of step N carries its own <x>_step_N), and attests (ADR-036) by re-reading the lead and comparing every field sent. The binding engine has none of this: its extraction language is a dotted path, with no way to map an array's elements.

Decision: (1) The binary carries the floor and no vendor. The floor is csv/*, http/*, sql/*, ai/*, group/*, the runner-owned participant and template steps (human/*, agent/*, text/compose), and the keyless demo/enrich. Every adapter named for a vendor is a registry entry in gtme-bindings, verified tier, installed with gtme adapters add. ADR-042's carve-out for reference twins is retired: an entry in the registry is its own conformance kit, and the registry's CI runs its fixtures. spec/bindings/ keeps one binding, printed by gtme help --bindings as the worked example (ADR-041) and registered as no adapter; the print says which registry entry it mirrors. (2) Four move in M33: apollo/search, apollo/enrich and attio/assert move unchanged (same id, version, fixtures). harvest/profile moves as version 2, a single call: it keeps the one-of LinkedIn needs, the public-URL recovery (ADR-020) and role_history, loses recent_posts and posts_limit, and takes its cost from config through ADR-046's template (cost_per_profile_usd, default 0.012). A new entry, harvest/recent-posts (enrich, person), needs linkedin_url, makes one call to the profile-posts endpoint, and provides recent_posts (string array, at most config posts_limit, default 3; not limit, which ADR-047 reserves for sources). It is a person's field for a compose to read, which is why it is an enrich and not the harvest/profile-posts traverse, which mints post records. The composition is two steps, one call each, composed in the pipeline rather than bundled in one adapter: a pipeline that wants posts asks for them by name, and one that does not never pays for the second call. (3) The engine gains one extraction form, each:. A field's extraction may be {each: <dotted path to an array>, template: <a template in the ADR-057 dialect over item.*>, limit: <n | a config reference>}: the engine renders the template once per element, drops elements whose render is empty after trimming (text/compose's rule, §10 item 10), stops at limit, and provides a string array. The template may use the dialect's tags (if/unless/case), unlike a request leaf (M31), because it renders text and is not a typed value. This is enough for role_history and recent_posts to keep their shapes, and the graduation rule is unchanged: each: introduces no expression language. It reuses ADR-057's closed dialect, which already runs inside bindings. (4) Instantly moves last. instantly/add-to-campaign stays a built-in Go adapter until the engine can declare what it does today: a resolve: pre-request (one lookup per run, its result templated into later requests), a preflight: block (a request plus checks from a closed vocabulary that ROADMAP.md starts), and an attest: block (a read-back request plus a field comparison against what was sent). A deliver binding that lacked preflight and attestation would break the gate ladder's promises (ADR-040, ADR-036), which the docs state and the home page's dry-run receipt shows. Moving it early would trade a guarantee for consistency, and that trade is the wrong way round. The engine work is its own packet: the check vocabulary is spec-visible and needs its own argument that it is not the logic the graduation rule forbids. Until it lands, the binary carries exactly one vendor, and §10 says so. (5) Installing several is one command. gtme adapters add takes more than one reference, and a bare id (apollo/search) resolves through the registry index to that entry's pinned source (verified and community alike; the index's sha is the pin, so a bare id is exactly as pinned as a full reference). gtme adapters add apollo/search apollo/enrich harvest/profile is the first real-stack step. When gtme plan meets a use: id that is not installed, the error names the command that installs it, gtme adapters add <id>. Plan stays offline: it does not consult the index to say whether the id exists there.

Consequences: Every vendor adapter has one home, one tier label, one update path (gtme adapters update) and one CI. A vendor fix no longer waits for a binary release. The keyless path (csv/source, demo/enrich, --simulate over the floor) is untouched. The first run against a real stack gains one line before gtme plan, and START.md, docs/start/my-stack.md, docs/start/show-me.md, docs/concepts/gate-ladder.md and docs/concepts/types-and-traverse.md gain it. examples/apollo-to-instantly.yaml and examples/demo.yaml name registry ids, so the examples say so in a header comment and the e2e tests install from a local copy of the entries (GTME_ADAPTER_PATH) rather than the network. The canonical §9 pipeline gains a posts step. Cost rows for Harvest profile lookups move from harvest/profile@1 to @2; the judgment cache treats the new version as a new adapter, as for any version bump. "Verified" means what ADR-042 said, and the former built-ins are verified by construction: they live in gtme-bindings and its CI runs their fixtures on every change. The site's catalog relabels the four from "built in" to "verified" with no site change beyond one list. What is lost: recent_posts no longer rides the profile call, and a pipeline that set posts_limit on harvest/profile must move it to a harvest/recent-posts step. Version 2's config schema refuses the key, and the plan error says so. M33 is roughly: the each: form in internal/binding (~120 LOC with tests), one new registry entry plus four moved, and deletions (the Harvest Go adapter, three embedded bindings, the twin tests) that exceed the additions. The Instantly move is sized in its own packet. Spec impact: AMEND §6 (the plan error for an uninstalled id), §8 (adapters add takes several references and bare ids), §8's gtme adapters paragraph (the binary carries the floor and no vendor), §9 (the canonical pipeline's posts step), §10 (intro; items 2, 2a and 4 served from the registry; new item 4a harvest/recent-posts; item 6 the one remaining built-in vendor until it moves), §10a (the each: extraction form; the reference-twin sentence), §11 (M33 queued), Changelog (v0.51). ROADMAP.md gains the engine's resolve:/preflight:/attest: and amends the registry entry's "reference twins" line. spec/binding-schema.json gains each in M33.

ADR-060: A delivery in flight at a crash is held, not sent again

Status: Accepted (2026-09-28 — from issue #134, found while writing the recover guide; human-approved by merging this packet; build queued as M34) Context: §8 inserts a record's deliveries row after the adapter's RECORD comes back, as it must: a row is a claim that the target took the request. A deliver step today streams a chunk of records into each session (the chunk is the work divided across the pool, at most 64), and the adapters send them one at a time. A process killed mid-step therefore leaves one record per open session that reached the target with no row, and --resume sends it again: four records, the pool size, in four of four reproductions with http/deliver against a local target (kill -9 three times, Ctrl-C once). The ledger cannot say which records those were, because claimed is logged for the whole chunk before the send (40 claimed on a 40-record crash with 12 done). The Recover story promises that resume never redoes completed work, and Top-up promises that nothing is delivered twice; this window broke both without saying so. Decision: (1) An adapter-backed deliver step runs one record per session, and the runner commits a dispatched step event for the record before it opens that session. A record with dispatched and no later done or failed at that step is unconfirmed: its request may have reached the target. group/deliver is unchanged; it has no session and sends nothing outside the ledger. (2) An unconfirmed record is held, not sent again. When a run resumes, and when an interrupted run finishes, the runner writes the record's deliveries row with the new status unconfirmed and does not send it. §8's pre-send check skips an unconfirmed row with reason unconfirmed, so no later run sends it by habit either. (3) A natively idempotent target is the exception. Where the manifest declares idempotency: native (§6), the target upserts, so the record is sent again and gets an ordinary row; this is ADR-045's rule that repeat-safety belongs to the adapter. (4) Release is explicit. gtme run PIPELINE --resume RUN_ID --resend-unconfirmed sends that run's unconfirmed records, and the adapter's answer replaces each row's status. Nothing else sends a held record. (5) http/deliver sends Idempotency-Key, the hex SHA-256 of the delivery's target, scope and idempotency key joined by NUL, identical on every run and resume, so a target that honors the header can dedupe the one case the ledger cannot see. A headers.Idempotency-Key in config overrides it. (6) The receipt names the held records and prints the release command. Consequences: At-most-once delivery holds across a crash for every target that is not natively idempotent, and the receipt names the few records (at most --concurrency) that need a person to check the target. Natively idempotent targets lose nothing. Deliver steps give up multi-record sessions. No shipped deliver adapter depends on them for correctness: http/deliver, the deliver bindings, csv/deliver and instantly/add-to-campaign all send and acknowledge one record at a time. The cost is per-session setup, and it adds no vendor requests: instantly/add-to-campaign already caches a resolved campaign name for the life of the process, and a campaign given as an id is never looked up. A false hold (a crash after dispatched commits and before the request leaves) costs one explicit release, which is the safe direction. Rejected: Stating an at-least-once window in SPEC and leaving the behavior — it weakens two operator stories to match a bug. Holding every claimed record — 28 held for 4 in flight in the reproduction. A wire message in which the adapter announces each send — it is only safe if the adapter waits for the runner's commit before sending, a protocol round-trip for what one record per session gives for free. Asking an adapter that declares attests to re-read held records on resume — a real refinement, deferred to ROADMAP.md with batch delivery. Spec impact: AMEND §3 (the step_events.event and deliveries.status vocabularies; both columns are TEXT, so there is no migration), §8 (the run line, deliver idempotency, record accounting), §10a (http/deliver sends Idempotency-Key), §11 (M34 queued), the Top-up and Recover stories, Changelog (v0.55). ROADMAP.md gains re-reading held records and batch delivery.

ADR-061: A run holds a lock while it executes; a dead running run reads as interrupted

Status: Accepted (2026-09-28 — from issues #136 and #122, found while writing the recover and launch guides; human-approved by merging this packet; build queued as M34) Context: A run's status is written when it finishes, so a process killed without finishing (kill -9, an out-of-memory kill, a closed laptop, a reboot) leaves running behind for good. runs records no process, host or heartbeat, so gtme runs cannot tell a dead run from a live one, and the operator has to go looking for a gtme process. Nothing stops --resume from reopening a run that another process is still executing, which puts two writers, and with ADR-060 two senders, on one run. Separately, --resume of a run that finished done reopens it and prints an empty receipt, which reads as if something happened. Decision: (1) Every executing run holds an exclusive advisory lock (flock) on locks/<run_id>.lock in the ledger file's directory, from the moment it creates or reopens the run until the process exits. The kernel releases the lock on any death, and a reboot clears it. (2) runs gains pid and host, written at create and at reopen. They are for display: liveness is the lock, never the pid, because pids are reused after a reboot. (3) gtme runs derives interrupted for a running run whose lock it can take, taking and releasing it at once. interrupted is never stored; runs.status keeps its four values and gtme runs stays read-only. A run recorded on another host shows running (on HOST), since this machine cannot test that lock. (4) --resume takes the lock or refuses with exit 2, naming the pid and host. (5) An interrupted run's receipt prints the command that resumes it. A plain gtme run whose pipeline's latest run is interrupted says so on stderr with the same command and sources anew; it never resumes an interrupted run by itself, because a crashed deliver step may hold unconfirmed records (ADR-060) that need a person to look first. Collect-first for a pending run (ADR-038) is unchanged. (6) --resume of a done run refuses with exit 2: run RUN_ID is done; nothing to resume. A failed, pending or interrupted run resumes as before. Consequences: gtme runs tells the truth about a crash with no clock and no daemon. Two processes can never execute one run, which also closes a double-send path. A ledger on a network filesystem gets no liveness, which is already an unsupported place for SQLite. Runs created before the migration have no pid, no host and no lock file, so a running one reads as interrupted, which is true of any run older than the upgraded binary. test/e2e/resume_test.go's TestResumeLastAndUnknownRun asserts today that resuming a finished run is a no-op; M34 changes that assertion to the refusal. Rejected: A heartbeat column — it needs a staleness threshold and a periodic write, a suspended process comes back to life after being declared dead, and it gives no mutual exclusion. Liveness by kill(pid, 0) — pid reuse after a reboot is the case the issue names. Having gtme runs store interrupted — a read verb that writes, and a status that goes stale when a process was only unreachable. Resuming an interrupted run on a plain gtme run — resuming after a crash is a decision, not a habit. Spec impact: AMEND §3 (runs.pid, runs.host; migration 0014), §8 (a subsection on the run lock and interrupted runs; the --resume refusals), §11 (M34 queued), the Recover story, Changelog (v0.55). The ledger's directory gains locks/.

ADR-062: A delivery's scope is the destination's stable identifier

Status: Accepted (2026-09-29 — from two failing offline tests written while tracing delivery dedupe per destination; human-approved by merging this packet; build queued as M35) Context: ADR-044 keys deliveries on (target, scope, idempotency), where scope is the value of the config key a deliver manifest names in idempotency_scope. The runner computes it from config before any session opens, so an adapter cannot correct it. Per destination today: attio/assert scopes on the object slug, csv/deliver on the path, and a group handoff on group:<name>, which gtme owns. All three are stable. Two destinations are not:

  • http/deliver declares no idempotency_scope, so every URL shares the scope ''. A record delivered to webhook A is skipped at webhook B, which has never seen it. The skip goes through the same path as a cache hit, so the receipt counts it cached and adds it to "avoided via cache".
  • instantly/add-to-campaign scopes on the configured campaign, which accepts a display name. ADR-044 said so in the open: a renamed campaign is a new scope. In practice, renaming a campaign in Instantly (and in the YAML to match) makes the same campaign a new destination, and the next armed run adds everyone again.

Both are reproduced offline on the branch fix/deliver-scope (TestHTTPDeliverScopesToTheURL, TestInstantlyRenameIsTheSameCampaign). ADR-044's migration also named a backstop that is narrower than it said. The adapter sends skip_if_in_campaign: true unless config sets it false, and Instantly's API reference describes that flag in one line ("Whether to skip if the lead is already in the campaign"), with no default and no documented response for a skipped lead. Decision: (1) The rule. idempotency_scope MUST name a config key whose value identifies the destination stably: an id, an API slug, a file path or a URL. It MUST NOT name a display name that the destination's owner can change. The rule binds deliver bindings as well as built-ins, so spec/binding-schema.json states it and so do gtme-bindings' contribution rules. The runner cannot test stability, so where an identifier has a shape (a UUID), the manifest's config_schema SHOULD constrain the key to it, which turns a name into a plan error. (2) Instantly takes the campaign id only. campaign is the campaign's UUID, lowercase, and any other value is a plan error that says the adapter takes the id and says where to find it. Plan stays offline and does not look the name up. The adapter's name-to-id lookup and its per-process cache are deleted, and the manifest's version becomes 2 because a config that was valid is now refused. The operator still sees the campaign's name: the preflight session already reads the campaign, and the PREFLIGHT message gains an optional destination string, a display label for what the step delivers to. The preflight line prints it at --dry-run and at the start of an armed run: send: preflight ok — campaign "Q3 VP Marketing" (0198a0b1-…) — 4 checks (…). The field is additive: any deliver adapter that preflights may fill it, and a runner that predates it ignores it. With preflight: false the line is not printed and no request is added. (3) http/deliver declares idempotency_scope: url. The scope is the url as configured, before rendering, so a URL that templates {{record.*}} still has one scope per step: the destination is the endpoint the operator wrote, not each record's path. Changing the URL's text, even a trailing slash, makes a new destination. A destination told apart only by query:, headers: or body is not told apart, so the operator gives it its own URL or a named binding with its own scope. The value is the one runs.config_json already stores, so the ledger holds no URL it did not already hold, and no receipt prints it. ADR-060's Idempotency-Key includes the scope, so after this decision it differs per URL as well. (4) An already-delivered skip is not a cache hit. A skip with reason already_delivered or unchanged stays a skipped_cache event with its reason, so the event vocabulary does not change. The receipt counts it apart, as N already delivered on the step line. It is not in cached, and it is not in "avoided via cache", because not re-sending is the contract and nothing was saved. Record accounting gains the term. (5) Migration 0015 backfills http/deliver scopes. For each http/deliver row whose scope is '', the migration reads the url of the http/deliver step in that run's runs.config_json (joined on deliveries.run_id). A run with exactly one such step backfills. A run with two or more is ambiguous, so its rows keep '', match nothing, and the next run to each URL may send those records once. Instantly rows scoped on a name cannot be mapped offline, because the ledger never stored the id. They stay as they are and match no id scope, so the first armed run of that campaign after the upgrade may add those leads again, once. The backstop is the adapter's default skip_if_in_campaign: true, as documented above, and it is absent where config set the flag false. Consequences: A renamed campaign stays the same destination, a second webhook receives what the first received, and the receipt stops reporting a withheld send as money saved. The operator copies a campaign id once instead of typing a name. ADR-060's note that the adapter caches a resolved campaign name becomes moot, because no name is ever resolved: one record per session adds no Instantly request, even when the adapter runs as its own process (ADR-063). The failing tests change with the decision. TestInstantlyRenameIsTheSameCampaign configures the id both times, renames only in the fake, and also asserts that the preflight line prints the new name. M35 adds a test that a name is a plan error. examples/demo.yaml, examples/apollo-to-instantly.yaml and the docs that configure a campaign by name change to an id. The bundles already use one. Rejected: Scoping on the id that preflight resolves. It keeps names working, but it makes dedupe correctness depend on a network check that the operator can switch off, and --simulate could not compute the scope. Re-keying name-scoped rows at the first preflight after the upgrade. It couples the wire to a one-time migration on a pre-alpha ledger. Normalizing URLs (case, trailing slash, default port). The rules are the target's, not gtme's, and a normalized key that merges two real endpoints fails silently, while an unnormalized one fails loudly by sending. Spec impact: AMEND §3 (the deliveries.scope comment; migration 0015), §5 (PREFLIGHT destination), §6 (idempotency_scope), §8 (record accounting, the terminal receipt, deliver idempotency, deliver preflight), §10 item 6, §10a (http/deliver), §11 (M35 queued), the Top-up story, Changelog (v0.56). spec/schemas/ and spec/binding-schema.json change in M35.

ADR-063: A process adapter can be a registry entry — Instantly leaves the binary in Go

Status: Accepted (2026-09-29 — follows ADR-062, which removes Instantly's name resolution; human-approved by merging this packet; build queued as M35. Supersedes ADR-059 (4)) Context: ADR-059 moved every vendor adapter to the registry except instantly/add-to-campaign, which stayed built in until the binding engine could declare resolve:, preflight: and attest:. ADR-062 removes the need for resolve:. The other two are harder. Two of the four preflight checks count and cross-reference: the sequence must have as many steps as the highest _step_N target assumes, and every variant of step N must carry its own step's target. That is the computation §10a's graduation rule sends to a process adapter. Making it engine vocabulary would grow logic inside binding YAML for one consumer, at several times the Go adapter's 200 lines. So Instantly stays a Go process adapter. What kept it in the binary was distribution, not language: the registry installs bindings only (§8), and a process adapter reaches the discovery path (§6: manifest.json plus an executable run) only by hand. Decision: (1) The registry index gains process entries. An entry gains kind: binding | process, which defaults to binding. A process entry carries assets, one per platform in §13's targets (darwin/arm64, darwin/amd64, linux/amd64, linux/arm64), each an archive URL with its SHA-256. Its source names the code it was built from (repository, path, ref, commit), so a reviewer can read what runs. (2) Install. gtme adapters add <id> downloads the archive for this platform and refuses a checksum mismatch. It unpacks manifest.json and run into ~/.gtme/adapters/<id, slashes → dashes>/, validates the manifest, and writes .source.json (asset URL, SHA-256, release tag, commit). It prints the same reviewable surface as for a binding: credentials, needs, provides and declared capabilities. With no asset for this platform, it refuses and names the platforms that have one. gtme adapters update moves the pin as it does for a binding, and nothing moves it implicitly, so upgrading gtme does not upgrade the adapter. (3) Process entries are verified only. A verified process entry is built by gtme-run CI from a tagged commit whose tests passed, and published as release assets. For a process entry, "nothing installs unverified" means a pinned checksum, a manifest that validates, and tests that ran in that CI. Install does not re-run fixtures, because a process adapter's fixtures need its own harness. Community entries stay bindings: an index that points at strangers' executables is a different trust model, and that model is not decided here. (4) Instantly moves in M35. Its source stays in the gtme module, as cmd/gtme-instantly over the existing package, and leaves internal/adapters/all. gtme's release workflow builds gtme-instantly_<tag>_<os>_<arch>.tar.gz (holding manifest.json and run) beside the gtme archives, listed in the same checksums.txt, and gtme-bindings' index gains the process entry at that release. The id stays the same, and the version is 2 (ADR-062). (5) The binary carries the floor and no vendor. That is ADR-059's end state, reached through distribution and not through the engine. ROADMAP.md's "Deliver bindings that preflight and attest" closes. A later deliver adapter that needs preflight or attestation is a process entry. §10a's graduation rule gains the sentence that a graduated adapter can still be a registry entry. Consequences: A pipeline that delivers to Instantly needs one install line, gtme adapters add instantly/add-to-campaign, as Apollo and Attio already do. The plan error for an uninstalled id already names it (ADR-059). With one record per session (ADR-060), each lead costs a process start, which is milliseconds against the network round-trip. Because the adapter stays in the gtme module, it keeps sharing internal/protocol and internal/httpx, and a protocol change ships both at one release. The e2e tests build it into a temporary directory on the discovery path, as M33's tests install registry entries from a local copy. The Homebrew formula is unchanged. The catalog relabels Instantly from "built in" to "verified", and docs/_adapters.json regenerates. The live validations in VALIDATION.md were run against the built-in adapter, and the first live run of the installed one is an M35 follow-up, run by hand. Rejected: Engine preflight: and attest: blocks. See the context: this is the graduation rule's case, with one consumer. Keeping Instantly built in. It leaves two distribution models, and a catalog "built in" label that nothing explains. A Homebrew formula per adapter. Discovery is per-user (~/.gtme/adapters/), and it would tie installing an adapter to one package manager. go install from source. Operators do not have a Go toolchain. Moving the Go source to gtme-bindings. That puts a Go toolchain and a copy of the protocol packages in that repository's CI, and Go's internal/ packages cannot be imported across modules. Spec impact: AMEND §6 (discovery: adapters add also installs process entries), §8 (gtme adapters: process entries, install, verification, the floor), §10 (intro, item 6), §10a (graduation rule sentence; the binary ships no vendor), §11 (M35 queued), Changelog (v0.56). spec/schemas/registry-index.schema.json changes in M35. ROADMAP.md closes the deliver-binding engine entry and gains community process entries.

ADR-064: gtme runs RUN_ID is the receipt; a crashed run's sends show before the resume; a held delivery can be settled

Status: Accepted (2026-09-29 — from issues #90, #189 and #190, found while writing the report and recover guides and building M34; human-approved by merging this packet; build queued as M36, after M35) Context: ADR-046 says gtme runs <id> mirrors the live receipt, and SPEC §8 repeats it, but the two print different tables: the receipt's in, out, empty, cached, filtered, failed, avoided and adapter against gtme runs' claimed, done, cached, failed (#90). A reader who learned one meets a second vocabulary in the other. The mirror cannot be built from today's ledger alone: a record held by when: or a membership gate writes no event; a filter's fail, an on_missing hold and a suppression all write done with pass: false, told apart only by reason text; and a cache skip does not record what it avoided. Separately, after a kill -9 during a deliver step, gtme runs says interrupted and nothing more until an armed run reaches the step and holds the unanswered sends (#189), though the dispatched events already name them. And once the operator has checked the target, nothing can record the answer: a held delivery stays unconfirmed and on every receipt, and --resend-unconfirmed sends all of a run's held records or none (#190). Decision: (1) gtme runs RUN_ID prints the receipt's table — step, adapter, in, out, empty, cached, filtered, failed, cost, avoided — and, after M35, the same already delivered count the live receipt prints, followed by the run-level lines it prints today (status, started, finished, records by state, config, held:, resume:). It reports the run's net outcome: for each step, every record counted once, by its latest outcome at that step across all of the run's sessions, so a run resumed three times reads as one run. adapter comes from runs.config_json. (2) Every per-record step event says which column it is. done, failed, skipped_cache, simulated and dry_run events for a record carry detail.outcome, one of out, empty, filtered, skipped, failed, cached, already_delivered, simulated or held_dry; a record a when: or membership gate holds back writes a new event gated; a cache skip's detail carries avoided_usd (null when the estimate is unknown, which prints ?). The columns reconcile exactly as the live line does (§8 record accounting). No DDL change: step_events.event and detail are TEXT. A run recorded before M36 has no outcome: its receipt infers each column from the event and reason as today, and prints ? for gated records and avoided, which were not recorded. (3) An interrupted run shows its unanswered sends. For an interrupted run, gtme runs puts the count of dispatched events with no later done or failed in the in flight column, and gtme runs RUN_ID prints one line per target, read only: send: 4 sent to http/deliver with no answer before the run stopped; the resume, or the next run to that target, holds them. (4) A held delivery can be settled, and the release can be selective. --resume RUN_ID takes --settle-unconfirmed[=KEY,...] beside --resend-unconfirmed[=KEY,...]. With no keys, each applies to every record the run holds; with keys (identity keys, as the receipt names them), to those only, and a key the run does not hold is a validation error, as is a key given to both. Settling writes no request: the row's status becomes the new settled — the operator checked the target and found the record there — and a settled step event records it for the run. The pre-send check treats settled as delivered (already delivered). Records neither settled nor resent stay held. A done run accepts either flag, as it accepts --resend-unconfirmed today. Consequences: One vocabulary for "what happened in this run", live or after the fact, which is what the Report story asks. The mirror costs one detail field per event and one new event; nothing is backfilled, and old runs say ? where the ledger never knew. An operator who finds three of four held leads at the target settles three and resends one. The receipt's held lines and the held: line disappear once nothing is unconfirmed. settled is the operator's word, not a provider's: attestation (ADR-036) still distinguishes confirmed, and ROADMAP's re-read of held deliveries would write confirmed, not settled. Rejected: Amending ADR-046 to describe the two tables as different (#90's option 2) — it keeps two vocabularies for one question. Reconstructing the columns from reason strings — reasons are operator prose and change with ADR-058's plain words. A new gtme deliveries verb for settling — the held set belongs to a run, --resume already carries the release, and a verb would add a surface to help for one action. Marking a settled row confirmed or accepted — both claim what gtme or the provider observed, and this is the operator's check. Spec impact: AMEND §3 (step_events.event gains gated and settled; deliveries.status gains settled; no migration), §8 (the run line; record accounting names detail.outcome; the terminal receipt paragraph defines the mirror; deliver idempotency's in-flight paragraph gains settling and selective release; the run-lock subsection gains the unanswered count and settling on a done run), §11 (M36 queued), the Recover and Report stories, Changelog (v0.58).

ADR-065: One stop signal for both adapter tiers — fail_run stops dispatch, and an error rule can read the body

Status: Accepted (2026-09-30 — from issue #202 and the first live run of instantly/add-to-campaign as a process entry; human-approved by merging this packet; build queued as M37) Context: A deliver step ran 314 records into an Instantly workspace that filled up after 151. Every remaining record still opened a session, sent its request, got 403 "Lead limit reached. Remaining uploads: 0", and failed: 163 requests after the destination had said no request could succeed. #206 fixed the two spec-invisible parts (the 403 is no longer reported as bad credentials, and the adapter's last stderr line reaches the failed event's reason). What remains is that nothing can stop the step. A process adapter has one way to fail, exiting, and the runner treats every exit as a crash of that session and keeps dispatching (the #82 decision drains the pool on sessionCrash). The binding tier looks as if it can: errors: accepts fail_run, and the engine comment and the #162 implementation decision say it "stops the run". An offline probe (a deliver binding mapping 403 to fail_run, a target that always answers 403, 40 records at concurrency 1) shows it does not: 40 requests, 40 failed records, exit 3. The engine returns the error from Run (internal/binding/engine.go:508), the runner turns it into chunkFailed (internal/runner/step.go:1074), which returns a sessionCrash (step.go:1646), and the worker loop keeps draining on a sessionCrash (step.go:760). So fail_run behaves exactly as an unmapped status. The same probe with fail_record or skip is worse: the engine only logs a warning, the session ends without answering the record, and the runner advances an unanswered deliver record as a send, so all 40 refused records got a deliveries row, the receipt said 40 out, and the run ended done. A later run would never retry them. SPEC does not define what the four verdicts do: §10a names "error→verdict mapping" and spec/binding-schema.json lists the enum. Finally, a binding cannot write the Instantly rule at all, because errors: keys on status alone, and the quota and a revoked key are both 403. Decision: (1) One signal, the binding vocabulary on the wire. §5 gains an adapter→runner message {"type":"ERROR","key":{...}|null, "verdict":"fail_record|fail_run|skip|retry","reason":"..."}. The binding engine emits it for every errors: verdict it applies, so both tiers reach the runner the same way. fail_record: the keyed record fails (failed, outcome failed, the reason) and the session continues. skip: the keyed record is counted skipped with the reason and continues past the step without output, as on_missing: skip does; a deliver writes no deliveries row. retry: the adapter's own attempts ran out (retrying is always the adapter's job; the runner never re-dispatches), so the record fails as for fail_record and the reason says the attempts were exhausted. fail_run: the key is optional; see (2). A record named by ERROR is never advanced as a send. (2) What fail_run does. On a fail_run from either tier the runner fails the keyed record (and any record of that session left unanswered) with the reason, dispatches no further session for the step, lets sessions already open finish and records what they say, starts no later step, and ends the run failed. Records never dispatched get no event: they stay where they were, so --resume and once: pick them up. The step's line counts them as N not sent, a non-terminal remainder like in flight, and names the reason; the step-level failed event carries detail.stopped = {"verdict":"fail_run","reason":…,"not_sent":N}, so gtme runs RUN_ID rebuilds the line. The exit code is the stopping error's class (§8): the process adapter's exit code, or the binding's classified error. (3) An auth failure stops the step too. A session that ends with exit 3, or a binding error that classifies as auth with no errors: rule for it, is treated as fail_run with the error as the reason: every later session presents the same credential. Exit 4 (rate-limited, after the adapter's retries), 5 (network) and 1 (other) keep today's behavior: that session's records fail and dispatch continues. (4) Compatibility. A runner that implements ERROR lists it in OPEN: "accepts":["ERROR"]. An adapter whose OPEN does not list it MUST NOT rely on fail_record, skip or retry being honoured and fails the session by exiting non-zero, as today. For fail_run it SHOULD send ERROR and then exit with its error class either way, so a runner that predates the message still fails the session. A runner that predates ERROR ignores it (§5's forward-compatibility rule). (5) errors: can read the body. An errors: entry's value may be a list of rules as well as one rule. Each rule may carry match, a literal substring; rules are tried in order, and the first whose match occurs in the raw response body, or that has no match, applies. A rule after one with no match is unreachable and fails validation. No regular expressions, JSONPath or operators: match is text the vendor prints, frozen at authoring time, as §10a requires. A rule that applied because of its match reclassifies the error as provider (exit 1), since the binding has said this status does not carry its generic meaning here, unless the rule names a class (auth, rate_limit, network, provider). The class exists for the opposite case: a vendor that reports a bad credential under a status that does not say so (a 400 or 422 reading "invalid API key"); without it, match could only ever move an error away from auth, never to it, and the run's exit code would misname the fix. The object form stays valid, so every binding already written is unchanged. (6) Instantly sends ERROR fail_run on the lead-limit 403, then exits 1. Consequences: At concurrency 1 the reported run would send 152 requests, not 314 (at concurrency N, up to N−1 more sessions already open finish), and say send: 314 in, 151 out, 1 failed, 162 not sent — stopped: the workspace's lead limit is reached …, with the resume command; a resume once the plan has room sends the 162 and nothing twice. fail_record and skip on a deliver binding stop writing refused records as sent, which fixes a silent loss the probe found. A bad key costs one request per step instead of one per record. Enrich bindings mapping a status to skip move those records from empty to skipped, which is what the operator wrote. A binding's retry that runs out now fails the record instead of "stopping the run", which it never did in practice; this reverses the wording of the #162 implementation decision. One more message type in §5, one OPEN field, one schema shape. Rejected: A new exit code for "stop" (the #206 proposal's first option) — exit codes describe the process, a runner that predates it would read it as "other" and keep going, and it carries no key, so fail_record and skip would still have no way onto the wire. Stopping on every adapter exit — a 500 or a timeout on one record says nothing about the next, and #82 kept the pool draining for that reason. A regular expression or a JSONPath predicate in match — the binding tier stays declarative; anything that needs parsing graduates to a process adapter. Keeping fail_run as "fail the session" — the name promises a stop, and the only binding written with it wanted one. Spec impact: AMEND §5 (ERROR; OPEN accepts), §8 (record accounting gains not sent; the terminal receipt and gtme runs RUN_ID print a stopped step; a new "A step that stops" paragraph under deliver idempotency's neighbour, covering the four verdicts, exit 3 and the exit code), §10 item 6 (Instantly's lead-limit stop), §10a (the verdicts defined; errors: rule lists and match), §11 (M37 queued), the Recover story, Changelog (v0.61). spec/schemas/ gains error.json and OPEN's accepts; spec/binding-schema.json's errors gains the list form and match — both at build, machine-compared as always.

ADR-066: Instantly's lead body takes every documented lead field, in either spelling, and preflight checks the tags the sequence uses

Status: Accepted (2026-09-30 — from issues #204 and #205, reported from the first live run of instantly/add-to-campaign as a process entry; human-approved by merging this packet; build queued as M38) Context: §10 item 6 sends a variables: target into Instantly's lead body only when it is spelled exactly first_name, last_name, company_name or personalization; any other name becomes a custom variable. Instantly's sequence editor writes those fields as {{firstName}}, {{lastName}}, {{companyName}}. An operator who copies the tag names from the sequence into variables: (firstName: first_name) gets a custom variable called firstName and a lead whose first name is blank, and the setup instructions given for the first live run made the same mistake (company: company_name). Preflight cannot see it: it checks that every custom-variable target appears in the sequence, skips the lead-body targets, and never checks the other direction — that a lead-field tag the sequence uses has anything filling it. Separately, the four fields are fewer than Instantly documents. Instantly's OpenAPI description (https://api.instantly.ai/openapi/api_v2.json, POST /api/v2/leads, read 2026-09-30) lists the lead body as email, first_name, last_name, company_name, job_title, personalization, website and phone, plus custom_variables and settings; so a person's title, website and phone, all in the ledger, could only reach Instantly as custom variables and never its lead columns. Decision: (1) The lead body takes every documented lead field. The targets that map into the lead body are first_name, last_name, company_name, job_title, personalization, website and phone, the set the create-lead API documents; any other target stays a custom variable of that name. (2) Either spelling. Each may also be written in the camelCase Instantly's sequence tags use: firstName, lastName, companyName, jobTitle (personalization, website and phone are one word either way). Both spellings fill the same lead field, so copying a tag name from the sequence does the right thing. (3) One field, one target. Two targets naming the same lead field (first_name and firstName) is a contract error: the adapter refuses the session with exit 2 before any request, since which value would win is not something to guess. (4) Preflight checks the other direction. For each lead field whose tag the sequence uses in any step's subject or body — {{firstName}}, {{lastName}}, {{companyName}}, {{personalization}}, {{website}}, {{phone}} — some target must fill that field, or preflight is blocked naming the tag and the fix ("the sequence uses {{firstName}} but no variables: target fills first_name: add first_name: <field>"). The existing check, that every custom-variable target appears in the sequence, is unchanged. {{jobTitle}} is not checked: the API documents job_title as a lead field, but no source seen confirms {{jobTitle}} as the tag the sequence editor inserts (the live campaigns' core_variables list the other six), and preflight blocks only on a readable fact. (5) Attest compares the new fields exactly as it compares the old: sent, stored, or unreadable. Consequences: The mistake the first live run hit becomes impossible to make silently: the camelCase name now does the right thing, and a sequence tag left unfilled blocks before the send. Title, website and phone reach Instantly's lead columns. A pipeline that today maps website: or phone: sends a custom variable of that name; it will now fill the lead field instead, which is the field the {{website}} and {{phone}} tags name (the live campaigns list both as core variables). A pipeline that maps firstName: today gets a lead-body first name and loses the custom variable of that name, which no sequence tag could reach separately. A sequence that writes a lead field in snake_case ({{first_name}}) is untouched by the new check: whether Instantly renders that spelling from the lead body is not documented, and it is left for a live check rather than guessed. Rejected: Sending each lead field as a custom variable as well — two copies of one value, and a tag could render either. Blocking on {{jobTitle}} — the tag is not confirmed; a block on a guess is what ADR-040 forbids. Making a duplicate a warning — one of the two values would be dropped silently. A plan-time check — variables: is injected into config and the manifest schema cannot express "two keys name one field"; the session refusal happens before any request, and the preflight session hits it first. Spec impact: AMEND §10 item 6 (the lead-field set, spellings, the duplicate refusal, the new preflight check), §11 (M38 queued), Changelog (v0.63). No wire, schema, ledger or exit-code change.

ADR-054: traverse — a run is a sequence of typed segments, and a type is a file

Status: Accepted (2026-09-05 — design session; answers ADR-008's parked question and ROADMAP.md's "Entity types" (until this packet, "Object ontology"); human-approved 2026-09-05 by merging the packet) Context: §4 derives identity for exactly person and company, as a closed switch. entity_type is an open string everywhere else, and since issue #27 plan and verify refuse what has no derivation — an extension point that is real, enforced, and empty. ADR-008 named expand (one record in, N records out, possibly of another type, writing relations) and parked one question: what run membership means when the type changes mid-run. ADR-037 retired that question by composition — fan-out at the pipeline boundary through a {query:} source, membership fresh per run — and kept single-file fan-out as a convenience item, judging that it would remove a review gate.

Three things have changed since. The runner already crosses types inside a run: a person record carrying company_domain mints a company, writes its fields and a works_at edge, and never adds the company to the run (relateCompany, internal/runner/runner.go) — one hardcoded instance of a general move. once: (ADR-052) made a group-sourced consumer autonomous under cron, so a chain of typed pipelines drains itself and the "new run" reading no longer costs a human per stage. And human/* steps (ADR-049) put a gate inside one run, so single-file no longer means gateless.

The plays that need a third type are signal-shaped — a post whose engagers resolve into people, a job posting whose company becomes a target — and each is one record of type A becoming N related records of type B. ADR-037's composition works (three files chained by groups) but hides the flow: the chain has no expression, the type crossing lives inside a SQL string, and the relation write is a side effect nothing in the YAML mentions. Candidate types were tried against a three-leg test — a durable key that dedupes across sources, a vocabulary that crosses adapter boundaries, and being the subject of a run's records — and most failed a leg. An account is a company in a group (ADR-037's test built the account shape on relations alone). A deal is a CRM's commitment, and keying it would rebuild the stage machine ADR-032 refused. A campaign is the namespace of a judgment (ADR-033's scope rule) plus a group. Replies, opens and meetings correlate to an identity and belong to listen. Personas, offers and value propositions are operator-authored content a compose reads, not rows a person joins to. What passed is one kind: signals. Decision: (1) Two kinds of type, and a type is a file. A subject (person, company) is what a pipeline delivers to. A signal (post seeded; job_posting the expected second, brought by the first binding that needs it) is what a pipeline finds and traverses from: keyed on a platform-public identifier, never a delivery target, related to a subject. The registry file (spec/fields/<type>.json, §4a) becomes the type's whole definition: it gains kind: subject | signal and an ordered identity list of key tiers, so §4's derivation reads the file instead of a switch. Person and company are expressed in their own files, behavior unchanged. A key tier names a field whose normalization is a public-identifier rule — email, domain, linkedin_url, handle, and a new url rule — or a hash of fields (the nh: fallback, declared where wanted and absent for signals). A vendor's record id is never a tier: keying on one forks the identity the moment a second vendor arrives, the failure ADR-020 spent a packet avoiding. (2) Types are discovered like adapters; gtme ships the floor, not the catalog. Three sources, no copying, no verb. Embedded: person, company, post — the registry's own rule, grow by demand, seeds only the signal a reference binding is about to emit. In place: a binding MAY ship types/<name>.json beside its manifest, and the planner reads it where the binding was installed — the type travels with the binding that emits it, under the binding's pin, and leaves with it. By hand: an operator MAY place a file in ~/.gtme/types/. gtme adapters add adds nothing but the binding; there is no types add, since a type no adapter emits is a type nothing can use. Embedded names are reserved: a binding shipping person.json, company.json or post.json is refused by verify and never installs, so no binding can redefine how people are keyed on an operator's machine. Same name, different content across any two sources is a plan error naming both paths. The rule of two promotes: a type two verified bindings ship moves into the binary and the argument is settled once. There is no custom-object verb, schema editor or UI: a type is a file, and that is what keeps it from becoming one. (3) The contract between an adapter and a type is three plan-time checks. For any manifest naming an entity_type: (a) the name resolves to exactly one type file; (b) every static provides property is canonical for that type or vendor-namespaced (§4a's rule, unchanged); (c) for a source or a traverse, provides covers at least one of the type's key tiers, so every emitted record can be keyed — an adapter emitting posts without a URL fails gtme plan and gtme adapters verify, not the run, and nothing is billed first. This is what #27 asked for, generalized; conformance fixtures prove the same thing offline. (4) A registry field MAY declare a reference, and the runner writes the relation. reference: {type: <type>, relation: <name>, fields: [<name>, …]} on a field says: a record carrying this field also names an identity of <type>, keyed and populated from the listed fields carried under the same names; the runner resolves-or-mints it and writes <name> from the record to it. company_domain on person declares {type: company, relation: works_at, fields: [company_domain, company_name]}, and relateCompany becomes the one generic path. The referenced identity is a ledger fact, never a run member. (5) traverse is the eighth role. Records of one type in, records of a type out — another type, or the same one (people to their coworkers) — each related to the record that produced it — ADR-008's expand, renamed because it also contracts (people to their companies is the same step, coalescing) and a name that needs a caveat is the wrong name. A traverse manifest declares from: <type> (the input type — the planner requires it to equal the pipeline's current type), entity_type: <type> (the output type), needs (validated against from), provides (validated against entity_type, check (3c) included), and relation: {name: <name>, from: record | parent} — which end the edge starts at (authored_by runs from the post to its author; works_at from the person to the company it was traversed from). Neither from nor entity_type may be *. The runner dispatches a traverse per input record (or per batch, as an enrich), accepts RECORDs whose key names the output type — the wire protocol needs nothing, a RECORD already carries entity_type — mints or resolves them, writes the relation to the dispatching parent, and opens the next segment. limit: is engine-owned on a traverse as on a source (ADR-047) and bounds the plan estimate; spend at a traverse is spend as at a source, and --dry-run runs it. (6) A run is a sequence of typed segments. The pipeline's type is the source's until a traverse changes it; every step is validated against the type of its segment; after a traverse only the new type moves forward. The records of the segment before it are finished at the traverse — terminal in ADR-052's sense, whether they yielded children or none — and a parent that yielded none counts empty. Run membership needs no new column: run_records keys on identity, and an identity carries its type. An identity a later segment reaches that is already in the run is a coalesce by ADR-053 (3) — one row, its state advancing to the later step. The terminus adds the last segment's completers. A deliver MAY sit in any segment. when: MAY name only a step in the current segment: a verdict is a fact about the parent, not the child, so a cross-segment reference is a plan error naming the traverse to gate at instead (when: judge.passed on the traverse means only children of passing parents are ever minted). The traverse's receipt line reads like a source's — posts: 10 in, 84 traversed, 3 coalesced — where in counts parents and reconciles as for any step (empty beside it when a parent yielded nothing), and traversed and coalesced count children. (7) sql/traverse is the runner-owned floor. Following relations the ledger already holds — the companies of these people — needs no vendor. A sql/traverse step declares entity_type: (the output type) and a query yielding identity_id (of that type) and parent_id; it runs once per step, read-only, timeboxed, plan-EXPLAINed and annotated cross-record like every sql/* step, and writes no relation (it follows one). A typed via: works_at atom waits for receipts showing that query recurring — ADR-037's floor→ceiling rule applied to itself. (8) A group has a type. groups gains entity_type (nullable, §3). It is set when the group is created — by a terminus or group/deliver from the run's current type, by gtme groups add from an unambiguous key match, or by --type (required when the key is ambiguous or no key is given) — and adding a member of another type is refused. A group source takes its group's type, so the plan after a group source is no longer entity-blind; plan checks a terminus or group/deliver against its group's type and fails on mismatch naming both pipelines, which is what makes a chain checkable at both ends. A group created before this decision has no type: it stays entity-blind, plan says so, and gtme groups add --type sets it once. This is homogeneity, not the typed-groups item ROADMAP.md refused — no rule rides on the type. (9) The chain is visible without a workflow file. gtme plan prints, for a traverse, person → post via harvest/posts (authored_by); for a step whose provides carry a reference field, writes works_at → company; for a group source, the group's type; for a {query:} reading group_membership, the group it reads. gtme groups lists each group's type; gtme groups show prints the pipelines that wrote to it and sourced from it — derived from group_events.run_id and runs, never stored. (10) Not types, by name: account, deal, campaign, segment, event (reply, open, bounce, meeting), persona, offer, value proposition. The last three are operator content — a versioned file a pipeline includes into its prompts and hashes into the run — parked on ROADMAP.md as packs, a pipeline item, not an ontology one. Consequences: People to posts to engagers is one file, and its plan shows every type change and every relation it writes. The two-pipeline form is unchanged and remains the way to put a human or a cron boundary between segments; a file MAY traverse and then end in a group. Every existing pipeline plans and runs unchanged: a run with no traverse is one segment. person and company stop being a special case in Go and become two files the test suite loads. An adapter author writes a traverse binding as a source binding with from:, needs, a {{record.<field>}} in the request and a relation:; --simulate runs the crossing from fixtures. The registry becomes an identity authority — the cost of (1) — which (1)'s public-identifier rule and (3c) bound. Two association mechanisms coexist and the rule for choosing is stated: group membership is a decision (gated, reversible, with events); a relation is a fact (per record, free, joinable). Relations still cannot end — works_at cannot say someone left — recorded on ROADMAP.md, not solved. A related record's field is readable from any SQL step but not projectable into uses: or variables:; the to-one path this makes plan-validatable (a relation name resolves to a typed file) is named on ROADMAP.md as "Relation paths in projection", held for receipts per ADR-037. In prose, the identities and relations the ledger holds are the entity graph; the files that define them are the types; "ontology" retires. Roles are eight; ADR-051's diagram gains a silhouette. Rejected: A third dimension on run_records — the identity already carries its type. A source manifest with non-empty needs instead of a role — the reviewer should see the role in the id, the reasoning that kept sql/filter its name. Keeping the name expand — it misdescribes the contraction case. A via: relation-hop key on a group source — a special case of what sql/traverse does generally; wait for receipts (ADR-037). Reference objects as ledger identities (persona, offer) — they need no cross-source dedupe and are never a run's subject; what they need is versioning, which is a file. Campaign as a type — a campaign-scoped fact is a pipeline-namespaced field and enrolment is a group; a campaign row would make one column hold two campaigns' answers. Vendor-declared types — a type must be vendor-independent or two adapters fork one identity. Vendor ids as key tiers — the same failure. Groups as identities with member_of relations — uniform querying at the price of the event log, which is what makes membership a decision. A multi-type run with per-type accounting — moves the same complexity into the runner, where in, the dry-run receipt and the arming gate all lose their answer. Spec impact: AMEND (this packet's second commit) — §3 (groups.entity_type; the identities and groups comments), §4 (derivation reads the type file; the url rule), §4a (type files: kind, identity, reference; discovery; the three checks), §5 (a traverse's RECORDs name the output type), §6 (traverse role; from; relation), §7 (segment typing; the checks; plan annotations; group type checks), §8 (the traverse receipt line; gtme groups type and --type; gtme groups show producers and consumers), §9 (a traverse step and limit: on it; sql/traverse config; a group source's type), §10a (binding role traverse; sql/traverse), §11 (M28 queued), §13 (the fan-out non-goal retires). spec/schemas/manifest.schema.json, spec/binding-schema.json, spec/schemas/field-registry.schema.json, spec/fields/*.json, spec/ledger.sql and migration 0013 ride the build. ROADMAP.md: expand and "Object ontology" promoted; typed groups noted under option C; new entries for packs, the via: hop, and relations that end.

ADR-053: A receipt may not assert more than the run can substantiate

Status: Accepted (2026-09-04 — from issues #30, #44 and #46, all reported from real runs; human-approved 2026-09-04 by merging the packet) Context: Three reports, one shape. (#44) http/enrich responses over the byte cap warn "nothing stored", and the step still reports 10 in, 10 out; only two of ten identities held the field, and a downstream compose declaring it in uses: wrote copy for all ten. (#46) A CSV source read 940 rows and reported sourced 940 records, while the next step received 931 — nine coalesced into existing identities and nothing said which, or that it happened. (#30) A dropped source record left no step_events row, so a run that spent money and produced nothing could not be explained afterward; the code half was fixed (PR #35), and two spec-visible calls were left open.

Each is the receipt claiming throughput the run cannot substantiate, and it is the same defect ADR-046 fixed one column over: a total that could not distinguish a measured dollar from an assumed one. The money column was taught to say which it was. The record columns have not been.

Verified rather than assumed, because it changes the diagnosis: this is not an http/enrich quirk. Out++ fires on advance (internal/runner/step.go), so a sql/transform whose query matches nothing also reports 2 in, 2 out having written nothing for anyone. It is one runner-level accounting rule, which is why one ADR answers three issues. Decision: (1) out means the step contributed something; empty names the rest. For a step whose output is fields — enrich, verify, compose, review, sql/transform — a record that advanced without the step writing any field is counted empty, not out, and the receipt prints it: website: 10 in, 2 out, 8 empty. out + empty is what advanced, so nothing is lost and the record still moves. A filter's output is a verdict and a deliver's is a send, so neither counts empty; their existing columns already say what happened. (2) A declared field absent at run time is visible, and on_missing: governs it. uses: (and of:) are validated at plan time for availability (§7, ADR-004), which does not make them present for every record — an enrich may legitimately produce nothing for one. So a participant step MAY carry on_missing: run | skip | fail, the vocabulary deliver steps already use for variables: (§8). The default is run, today's behavior, deliberately: sparse uses: is a real pattern and a shipped example relies on it (uses: [recent_posts, role_history]), so defaulting to skip would break working pipelines to fix a reporting defect. What changes by default is only that the receipt says it — write: 10 in, 10 out (8 missing web.homepage) — so an operator learns the gap exists and reaches for skip if it matters to them. (3) The source line reconciles. A source reports rows read, records sourced, and the difference classified: read 940 rows from acquired-contacts.csv — 931 records (9 coalesced into known identities). Coalescing is recorded per record, so which row merged into which identity is answerable afterward by SQL rather than inferable from a count. (4) A response the run threw away is still retained under ADR-030's purgeable tier, for the window the step already declares — http/enrich's oversized response is precisely the one an operator needs to look at, and keeping it costs nothing gtme vacuum does not already reclaim. (5) A paid run that produced nothing is marked, not re-coded. gtme runs and the receipt say so explicitly — done — 0 records, $4.10 spent (estimated) — and the exit code does not change. §8's exit codes are a scripting contract and a new one would break callers to carry information the receipt now carries plainly. Consequences: Every per-step line can be reconciled against the ledger: in equals out + empty + filtered + failed + gated + skipped + cached, which no reading of the current receipt guarantees. The cost is one column that is usually zero and one reconciliation clause on the source line — both silent when nothing is amiss. empty will surface real defects on first run, some of them in adapters nobody suspected (#44 was reported against http/enrich; sql/transform has the same behavior and no one had noticed). That is the point, and it is also a reason to expect the first run after this to look worse than the last run before it while nothing has actually changed. Golden receipt tests move in the same change. Rejected: Redefining out silently — the count changes meaning with no new column to explain it, and every historical receipt becomes unreadable against the new rule. Defaulting on_missing: to skip — correct for #44's reporter, wrong for sparse uses:, and it would fix a reporting defect by breaking pipelines. A new exit code for a paid zero-record run — §8's codes are a scripting contract; the information belongs in the receipt. Failing a record whose uses: field is absent — that is on_missing: fail, which the operator chooses; making it the rule would mean a compose could never work from whatever the ledger happens to hold. Three separate fixes — the shared question ("what may a receipt claim?") would then be answered three times, differently. Spec impact: AMEND — §10 (http/enrich: an oversized response is retained, and its record counts empty), §7 (on_missing: on a participant step), §8 (the empty column, the source reconciliation line, the missing-field note, the paid-zero-record mark), §9 (on_missing: grammar beyond deliver steps), §11 milestone M27. spec/schemas/pipeline.schema.json rides the build. No §3 change: the coalescing record is a step_events row, which §3 already carries.

ADR-052: once: — a bounded group source advances past what it finished

Status: Accepted (2026-09-04 — from issue #43, reported from a real outreach run; human-approved 2026-09-04 by merging the packet) Context: A group used as a durable work queue with limit: N selects the oldest N current members, and nothing in the run marks those members as worked. The next run selects the same N, cache-skips their downstream work, and never reaches member N+1. Reported against a 481-member candidate group with limit: 10: ten people processed on the first run, the same ten sourced on the second, 471 permanently unreachable. The documented qualify → group → bounded-send composition — the same one ADR-049 points cron users toward, to keep a human step out of a scheduled pipeline — therefore stalls after its first batch unless an operator runs gtme groups remove from a script after every run, which makes the pipeline's progress depend on a mutation outside the receipted run.

The obvious composition does not work, which is why this needs a decision rather than a doc fix. exclude: [<group>] on the source plus the existing membership terminus would advance past completers only: assertTerminus adds records that reached the final step and were not stopped, so a record a filter froze never joins the terminus, is never excluded, and occupies a limit slot on every subsequent run. The reporter named exactly this: the mechanism must cover "both passed and filtered records." Decision: (1) A group source MAY carry once: true. With it, the source selects only members the pipeline has not already finished — oldest-added first, at most limit — instead of the oldest N members outright. (2) Finished means terminal, and terminal means two things: the record completed the pipeline's final step, or a filter's fail verdict stopped it. Both are outcomes the pipeline reached on purpose. (3) Everything else stays eligible, deliberately. A record that failed a step is not terminal, so a transient provider error is retried on the next run rather than silently swallowing the record — the invariant the issue asks for ("retries remain safe while a successful scheduled run can eventually reach every member"). A record left pending under a deferred batch or a human/*/agent/* step is not terminal either; collect-first (ADR-038) already resumes a pending run rather than re-sourcing, so the two rules do not interact. (4) The scope is the pipeline name, as record: defaults on a deliver step (ADR-031). Terminality is read from the run records of earlier runs of the same pipeline; nothing new is persisted and no migration is needed. A named scope shared across two pipelines is a real want — a reviewing pipeline and a sending pipeline draining one group — but it needs somewhere to store the scope, so it goes to ROADMAP rather than into this ADR. (5) The group is never mutated. once: changes what a source selects, not what a group contains, so a group stays what ADR-021 made it: a reviewed decision, re-runnable from the top by an operator who wants the whole set again. The audit trail is the ordinary one — run records and step events already say what each run did with each record. (6) gtme plan prints the eligible count, not just the limit: group "candidates" — 481 member(s), 471 not yet worked, sourcing 10 (oldest first). The number that matters before a scheduled run is how much work is left, and it is knowable at plan time without spending anything. (7) A dry or simulated run finishes nothing. Neither writes durable run state, so neither advances the queue — consistent with the dry-runs-assert-nothing-durable rule (§8), and it means a rehearsal can be repeated. Consequences: The composition ADR-049 recommends becomes autonomous under cron: review into a group in one pipeline, drain it from another, with no external script and no mutation outside the receipted run. The cost is one key and one query. A permanently-failing record — malformed data rather than a flaky API — is re-sourced every run and occupies a limit slot, which is the deliberate price of (3); it is visible in every receipt as a failure rather than hidden, and the fix is to fix the record or the step. Operators who want the old behavior write nothing: once: is opt-in, and a source without it is unchanged. limit: without once: keeps its current meaning, which several shipped examples rely on. Rejected: A draining source that removes what it finished — the simplest mental model, and it destroys the thing groups are for: the reviewed set is consumed, a re-run over the same people becomes impossible without re-snapshotting, and membership history survives only in group_events. exclude: on the source composed with the terminus — smallest possible change, reuses two primitives verbatim, and only half-fixes the bug (filtered records clog the window forever), so it would have shipped a partial answer to a complete report. Leases, attempts, or a workflow state machine — the reporter explicitly did not ask for them, and a queue that can express retry limits is a different product. A gtme groups ack verb — §8's verb set is closed by ADR-005, and this is a property of a source, not a new thing an operator does. Spec impact: AMEND — §8 (group-source selection, the plan line, the dry-run rule), §9 (once: in the source grammar), §11 milestone M26. spec/schemas/pipeline.schema.json rides the build. No §3 change: by (4) nothing new is persisted, so there is no migration in this milestone. Build note, 2026-09-04: (7) rested on dry runs writing no run state, and they do — §8 (ADR-019) gives a dry run its own run row, and its records advance to the deliver step's state. Without a marker a rehearsal would have advanced the queue, which (7) forbids. The build added runs.dry (§3, migration 0012 — one appended column, no rebuild) and gtme runs shows it; human-approved in conversation over the two alternatives (a step-level dry_run event, or dropping the rule). (4)'s claim stands for the queue itself: no claim, lease or scope is persisted. See the M26 implementation decision.

ADR-051: gtme plan --viz — the resolved plan as a picture

Status: Accepted (2026-09-03 — drafted in a design session; human-approved 2026-09-04 by merging the packet) Context: gtme plan prints the resolved plan as a linear listing: one block per step, every §7.4 fact spelled out. It is complete and it is the right default. But the two questions an operator actually asks before arming a campaign — where does this spend money and send mail, and how does step N come to have the field it needs — are answered by facts scattered across the whole listing. ADR-031 already conceded half of this by pulling the send surface into a summary block, on the reasoning that with delivers as ordinary steps, plan output is where the send points must be obvious at a glance. The field-availability walk (§7 step 2) has no such summary; it is reconstructed by reading every provides: line in order.

A picture answers both at once, and gtme's pipelines are unusually easy to draw: §13 bars DAG/branching beyond when:, and §7 executes steps strictly in order, so the shape is a single spine. That is a smaller and more tractable thing than "render an arbitrary workflow graph" — the reason this is worth building now and would not be if pipelines branched.

Decision:

  1. Two flags, no new verb. gtme plan p.yaml --viz appends the diagram after the existing listing; --viz-only prints the diagram in place of it. §8's verb set stays closed (ADR-005); this is a rendering option on an existing verb, not a new command. Both go to stderr with everything else plan prints, and neither performs network calls, spends, or changes what plan accepts or exits with.

  2. The default output is unchanged and stays normative. The §7.4 MUST-print items live in the listing. The diagram is a second view of the same Plan and MUST NOT be the only place a §7 fact appears — a fact that exists only in the picture is a spec bug. A second implementation MUST print the listing and MAY render the diagram however it likes, or not at all.

  3. Shape carries role. The seven roles get seven silhouettes, so the vocabulary survives without color:

    RoleShape
    sourcerounded box — terminator, records enter
    filterfunnel: full-width top, vertical sides, outlet inset one column
    enrichlight rectangle
    verifyrectangle with doubled rails — a process that adds no fields
    composerectangle with a wavy floor — document
    reviewbroken rails — the run pauses here
    deliverheavy border + uppercase label — it spends and leaves the machine

    A tapered side steps one column per row, so its diagonals stack into a continuous line; a single-step diagonal kinks against the rule it meets.

    Only the filter tapers, and its taper means what it looks like — fewer records leave than arrived. A review was drawn as a widening trapezoid first, on the flowchart convention where that shape means "manual operation" and the taper is decorative. In a diagram that has given taper a meaning it cannot also be decoration: a review writes fields and cannot reject (when: <review>.passed is refused), so it is strictly 1:1, and a widening shape claimed a cardinality it does not have. Broken rails say the true thing instead — the run pauses there.

  4. The step index sits in a fixed gutter, left of the frame. It is a reference handle — "step 3 is the expensive one" — so it behaves like a line number and holds one column whatever the frame beside it is doing. Inside the box it drifted with the funnel's taper, one column per row, which defeated the point of an index.

  5. The price column distinguishes five states, and none of them lies. $0.0100/rec known and billable; $0.0000/rec known and free (sql, group, a free vendor call); $?/rec a vendor will charge but the amount is unknowable at plan time (an AI step is token-metered); unset the ADR-046 case, a rate templating from config the operator never set; and -- for a participant step, where nothing can bill you at all — it is runner-owned, opens no session, and reaches no vendor (ADR-049). $?/rec on a human/* step would have claimed a vendor charge of unknown size, which is a different and false thing; what a participant actually spent arrives afterwards through gtme answer --cost, which is measurement, not a plan-time gap. For the same reason such steps do not inflate the header's unpriced count, which exists to flag spend you cannot see. $ ? became $?/rec so the unknown keeps the column's shape: the value is unknown, the unit is not. SPEC §7.4 mandates the ?, not how it is set, and the listing keeps printing its bare ?.

  6. The role word is a scannable column. All caps, every role, so the left edge reads vertically as an index of what each step is. It overlaps the shape and the glyph deliberately: a reader who has not learned the silhouettes still has a word, and a reader who has can skip it.

  7. One column, one meaning. The right column is the per-record price on every row — a source has one too, and showing its entity type there instead made one column mean two things. Entity type is a property of the run rather than of a step, so it moved to the header.

  8. Every gate gets a row. when:, require:, exclude:, suppress:, of:, requires and a deliver's idempotency key each print. An earlier draft picked the first match from a priority list, which silently dropped the rest — and a gate decides whether a record reaches a paid step, so hiding one made the diagram assert something untrue.

  9. A two-slot emoji column: executor, then role. The executor slot is the orthogonal dimension the role alone cannot say — a sql/transform and an apollo/enrich are both role enrich, but one is free and offline and the other spends per record.

    Executor: 🌐 vendor · 💻 ai · 💾 sql · 🙋 human · 🤖 agent Role: 📥 source · 🤏 filter · 💎 enrich · 👌 verify · ✍️ compose · 👀 review · 🚀 deliver Structural: 👥 group, either direction · ⏳ deferred (ADR-038)

    A group carries one glyph whichever way records move: it is a single mechanism — the ledger's membership tables, runner-owned, no network — and the role slot beside it already says source or deliver. Two glyphs restated the direction and hid the shared mechanism.

    Review is 👀 rather than a person glyph because the executor slot already distinguishes 🙋 from 🤖. An unrecognised role renders ❔, never the enrich glyph: a default that lies is worse than one that admits ignorance.

  10. Edges carry the available-set delta. Each arrow is labelled with the fields that step added — §7 step 2's walk made visible. Long lists truncate to +N.

  11. Deterministic bytes. Fixed 64-column frame, no color, no TTY detection, no terminal-width autodetection, no animation. The output is golden-testable and pipes into a file or a PR comment unchanged.

  12. Width is measured, not counted. Emoji are two columns wide and len() says one; padding MUST use display width or every box edge on an emoji row shifts. The table is hand-rolled (~20 lines) rather than taken as a dependency, so §2's list is untouched. ✍️ MUST be emitted as U+270D U+FE0F: bare U+270D defaults to text presentation and renders one column wide, which is the exact raggedness this rule prevents.

Consequences: The picture is the fastest way to see the send surface and the money column, and the only way to see the field walk without reading every step. Cost: a second renderer over Plan to keep in step with the first — bounded by (2), since the diagram is never the sole home of a fact, so a fact added to §7 obliges the listing and not the diagram. The 60-column frame truncates long adapter names and field lists in a way the listing does not; truncation is always visible (…, +N). ✍️ is the one variation-selector glyph in the set, so a terminal that draws it one column wide ragged its row's right rail and nothing else. Skin-tone modifiers and ZWJ sequences are excluded from the vocabulary outright: both are multi-codepoint and would blow the frame on any font missing the composed glyph.

Rejected: Replacing the listing (§7.4's MUST-print items would have to fit a width budget; information loss where the spec requires completeness). A new verb — §8's set is closed by ADR-005 and this is a rendering of an existing one. Color — breaks deterministic bytes and dies on redirect. Terminal-width autodetection — makes output environment-dependent and golden tests unwritable. An executor emoji for every adapter namespace — the box already prints apollo/enrich@1 next to $0.0100/rec; one generic 🌐 for all vendor-namespaced adapters says what the picture needs and no more.

Spec impact: AMEND (this packet's second commit) — §7 (a paragraph after step 4), §8 (verb table line + the closing paragraph), §11 (M25), §13 (a parenthetical on the "dashboards or any UI" non-goal), Changelog (v0.36). No schema, no ledger, no wire, no exit-code change.