gtme — SPEC.md (v0)
A CLI for GTM data pipelines. An append-only SQLite ledger as the data bus, schema contracts between steps, and AI transforms as first-class steps. Think: "Singer spec for outbound" with an n8n-style accumulating record model — but the accumulation lives in the database, not the stream.
This document is written to be executed autonomously by a coding agent. Every architectural question raised during design has been decided. Sections marked DECIDED are not open for reinterpretation. Section 12 defines what to do when something genuinely underspecified appears.
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document (in DECIDED sections, and in the acceptance criteria of §0a) are to be interpreted as described in RFC 2119. They are used deliberately, not sprinkled: a plain-prose sentence in a DECIDED section that contains none of these words is still normative (it is DECIDED), it just had no need for a strength qualifier. §0, §1, and ROADMAP.md are explicitly non-normative and use ordinary prose throughout.
0. Design principles (context, non-normative)
These are not requirements — §2 onward is where requirements live. This section exists so a proposal to change something can be checked against why it is the way it is. A principle here is one that settled a real argument during design, not a restatement of good taste. One page, by design: if it grows, something in it has stopped being load-bearing.
- The ledger is the bus. Steps never pass full records to each other; they read projections from the ledger and write facts back to it. This is not a style preference — cache-aware waterfalls, segmentation over history and treatment, resume, and receipts all fall out of this one structural choice (see §1 bet 1, §3).
- Expressive vocabulary, closed grammar. The pipeline surface (§9) has almost no room to hallucinate structure — a small, enumerable set of keys, not an open-ended DSL. Expressivity comes from composing a small vocabulary, not from the vocabulary itself being large.
- Adapters stay thin so vendors write them. The contribution surface is adapters, not the runner (§1 bet 5). A thin adapter is one an agent can generate correctly from an API doc; keeping adapters thin is what keeps that true.
- Errors are prompts. Every failure — a plan error, a validation
failure, an exit code — names its fix, not just its symptom. "compose
needs
recent_posts, nothing provides it" is the target shape; a bare stack trace is a bug in the error, not just the code. - The human supervises intent and money, the agent operates. The operator states intent (a pipeline) and approves spend (plan, then run); the system executes the mechanical parts. §12's stop conditions are this principle enforced.
- Safe-to-retry is what permits autonomy. Idempotent delivery (§8), cache-skip (§7), and resumable runs (§11) exist so that re-running something is never a risk decision — which is what makes it acceptable for an agent to be the one re-running it.
- Plan is the turn-taking point.
gtme planis where contract validation, missing-credential checks, and cost estimation happen — before any record moves and before any money is spent. It is the single place a human or an agent checks work before committing to it. - Every question has a deterministic answer path. "What do we know
about this record" is
gtme show. "What happened in this run" is the receipt orgtme runs. "Who's in this segment" isgtme query. No question about system state requires reading logs or guessing. - Destructive edges are gated, everything else is replayable. Sending money or email is a human-gated, real-world action (§12). Nearly everything else — a run, a step, a query — can be repeated without consequence, because the ledger is append-only and idempotency keys exist precisely where replay would otherwise double-act.
- Spec every agreement, spec no mechanism. SPEC.md fixes observable behavior — what a second clean-room implementation would need to interoperate (DECISIONS.md ADR-010's litmus). It does not fix package layout, concurrency strategy, or naming; those are Claude Code's to decide and record in DECISIONS.md.
1. Philosophy (context, not tasks)
Principle: maximum expressivity, minimal interactive overhead. The operator only ever states intent. No field-mapping flags, no credential flags, no batching flags. Contracts, manifests, and the ledger carry everything else.
Core bets:
- The ledger is the bus. Steps do not pass full records to each other. They read projections from the ledger and write fields back. The logical record gets wider; the physical storage never does (append-only field rows).
- Cache and run are separate layers. What we know about an identity is durable and cross-run. What happened in a run (membership, step states, cost) is per-run. Facts belong to identities; runs only touch identities.
- Contracts make AI steps safe. Every step declares
needsandprovidesas JSON Schema. The runner validates the plan before spending money, projects only declared fields into each step, and validates output before it reaches the ledger. Nondeterministic steps are contained by the same gate as deterministic ones. - YAML is the one pipeline surface.
gtme run pipeline.yamlis how a pipeline executes, full stop — see DECISIONS.md ADR-005, which cut the pipe-mode authoring surface (gtme source ... | gtme enrich ...) from v0 entirely: it tested none of v0's real hypotheses and was the recurring source of spec inconsistencies.gtme freeze RUN_IDstill exists, doing a smaller but still useful job: it reconstructs the exactpipeline.yamlthat produced a given run from the run's stored config snapshot — a reproducibility and audit tool, not a mode-conversion tool. See ROADMAP.md for pipes potentially returning post-v0 as a transport under this same YAML-defined pipeline object, not as a second grammar. - Adapters are processes speaking NDJSON. Language-agnostic. v0 ships built-in adapters compiled into the main binary, but they are invoked through the same protocol boundary as external ones, and one external adapter (in a different language) proves the boundary is real.
Long-term vision this v0 must not foreclose (but must NOT build):
multi-entity account-based plays via the relations table, SQL segments as
pipeline sources, waterfall combinators over interchangeable providers,
cost dashboards, shared ledgers. See ROADMAP.md for the
currently-named (still undecided) extensions: the expand role, pipes as a
transport, the listen verb, a REPL, and MCP as a control-plane doorway.
2. Stack — DECIDED
- Language: Go (>= 1.22). The binary MUST be a single static executable
named
gtme. Implementation MUST use the standard library plus, at most, these dependencies:modernc.org/sqlite(pure-Go SQLite, no cgo),santhosh-tekuri/jsonschema/v5(JSON Schema validation),gopkg.in/ yaml.v3. Any additional dependency MUST be recorded as a Decision in DECISIONS.md before use (per §12; see the existing dependency-addition entries for the ones already justified: the Anthropic SDK,x/term,x/net/publicsuffix, andgithub.com/osteele/liquid— thetemplate:dialect, ADR-057, with its transitiveosteele/tuesdayandgopkg.in/yaml.v2). - Ledger: SQLite, single file, default
~/.gtme/ledger.db, overridable viaGTME_LEDGERenv var. MUST run in WAL mode. - Wire format: NDJSON (newline-delimited JSON) on stdin/stdout.
- External adapter proof: one adapter MUST be written in Python
(
adapters/mock-enrich-py/), a plain script speaking the protocol. - AI engine: the Anthropic Messages API (model
claude-sonnet-4-6, key fromANTHROPIC_API_KEY) is the only model engine; the fixture engine is test-only, selected by environment (GTME_AI_ENGINE=fixture) or--simulate, never in YAML. There is noengine:key (ADR-050):engine:in a step is a plan error, andengine: claude-codenames the replacement — anagent/*step answered throughgtme answer(§8, ADR-049); the formerclaude -psubprocess is retired. Output MUST validate against the step'sprovidesschema; on validation failure the runner MUST retry once with the validation error appended to the prompt, then fail the batch. - No DuckDB. Query features MUST use plain SQLite SQL.
3. Ledger schema — DECIDED
Append-only for facts. Migrations via numbered SQL files embedded in the
binary (internal/ledger/migrations/000N_*.sql), applied at open.
-- Layer 1: identity (durable, cross-run, the cache)
CREATE TABLE identities (
id TEXT PRIMARY KEY, -- ULID
entity_type TEXT NOT NULL, -- a type file's name (§4a, ADR-054): person | company | post embedded; more arrive with bindings
identity_key TEXT NOT NULL, -- canonical key, see §4
created_at TEXT NOT NULL, -- RFC3339
UNIQUE(entity_type, identity_key)
);
CREATE TABLE field_values (
id TEXT PRIMARY KEY, -- ULID
identity_id TEXT NOT NULL REFERENCES identities(id),
field TEXT NOT NULL, -- e.g. 'email', 'linkedin_url'
value TEXT NOT NULL, -- JSON-encoded value
source TEXT NOT NULL, -- adapter id, e.g. 'harvest/profile@1'
confidence REAL NOT NULL DEFAULT 1.0, -- 0.0–1.0
run_id TEXT, -- provenance; nullable for imports
referent TEXT, -- was-about: field_values.id of the value a review or edit concerned (ADR-048); null unless the step declared of:
created_at TEXT NOT NULL
);
CREATE INDEX ix_fv_lookup ON field_values(identity_id, field, created_at DESC);
CREATE TABLE relations (
from_id TEXT NOT NULL REFERENCES identities(id),
relation TEXT NOT NULL, -- e.g. 'works_at'
to_id TEXT NOT NULL REFERENCES identities(id),
created_at TEXT NOT NULL,
PRIMARY KEY (from_id, relation, to_id)
);
-- Layer 2: runs (per-execution working set)
CREATE TABLE runs (
id TEXT PRIMARY KEY, -- ULID
pipeline TEXT NOT NULL, -- name or '(adhoc)'
config_json TEXT NOT NULL, -- resolved pipeline config snapshot
started_at TEXT NOT NULL,
finished_at TEXT,
status TEXT NOT NULL DEFAULT 'running', -- running|done|failed|pending (ADR-038: ended with a step in flight)
dry INTEGER NOT NULL DEFAULT 0, -- 1 for a --dry-run rehearsal (ADR-052 (7)): finishes nothing a once: source counts
pid INTEGER, -- ADR-061: the executing process, set at create and at resume; display only
host TEXT -- ADR-061: its hostname; liveness is the run lock (§8), never these
);
CREATE TABLE run_records (
run_id TEXT NOT NULL REFERENCES runs(id),
identity_id TEXT NOT NULL REFERENCES identities(id),
state TEXT NOT NULL, -- last completed step id, or 'sourced'
verdicts TEXT NOT NULL DEFAULT '{}', -- JSON: {step_id: pass|fail}
PRIMARY KEY (run_id, identity_id)
);
CREATE TABLE step_events (
id TEXT PRIMARY KEY, -- ULID
run_id TEXT NOT NULL,
step_id TEXT NOT NULL,
identity_id TEXT, -- null for step-level events
event TEXT NOT NULL, -- claimed|dispatched (ADR-060: a deliver's send is about to leave)|done|failed|skipped_cache|gated (ADR-064: held back by when: or a membership gate)|settled (ADR-064: a held delivery the operator found at the target)|pending|collected (ADR-038)|answered (ADR-049: a participant's answer awaiting collection)
detail TEXT, -- JSON
created_at TEXT NOT NULL
);
CREATE TABLE costs (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
step_id TEXT NOT NULL,
identity_id TEXT,
provider TEXT NOT NULL,
amount_usd REAL NOT NULL, -- 0 allowed (unknown/free)
basis TEXT NOT NULL DEFAULT 'estimated', -- measured|estimated (ADR-046)
detail TEXT, -- JSON (credits, tokens, etc.)
created_at TEXT NOT NULL
);
CREATE TABLE deliveries (
id TEXT PRIMARY KEY,
identity_id TEXT NOT NULL,
target TEXT NOT NULL, -- adapter id, or group:<name> for a handoff (ADR-032)
scope TEXT NOT NULL DEFAULT '', -- the destination's stable identifier: the idempotency_scope config value (ADR-044, ADR-062); '' = unscoped
idempotency TEXT NOT NULL, -- computed key, see §8 deliver
run_id TEXT NOT NULL,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'accepted', -- accepted|confirmed|contradicted|sent (ADR-036)|unconfirmed (ADR-060: in flight at a crash; held)|settled (ADR-064: held, then found at the target by the operator)
sent_at TEXT, -- set only by attestation (ADR-036)
variables_hash TEXT NOT NULL DEFAULT '', -- resolved variables at delivery (ADR-045); drives redeliver: on_change
UNIQUE(target, scope, idempotency)
);
-- Layer 3: groups — named associations between identities and a context
-- (ADR-021). A group carries the entity type of its members (ADR-054) and
-- no executable logic: its character (campaign-like, DNC-like, pool-like)
-- is derived from its events and the pipelines that reference it. Members
-- are identities of one type per group; a group created before ADR-054
-- has no type and is entity-blind until `gtme groups add --type` sets it.
CREATE TABLE groups (
id TEXT PRIMARY KEY, -- ULID
name TEXT NOT NULL UNIQUE,
note TEXT,
created_at TEXT NOT NULL,
entity_type TEXT -- ADR-054: the members' type; null = created before it (entity-blind)
);
CREATE TABLE group_events (
id TEXT PRIMARY KEY, -- ULID
group_id TEXT NOT NULL REFERENCES groups(id),
identity_id TEXT NOT NULL REFERENCES identities(id),
event TEXT NOT NULL, -- 'added' | 'removed' | 'touched'
detail TEXT, -- JSON provenance
run_id TEXT, -- nullable: hand edits have no run
created_at TEXT NOT NULL
);
CREATE INDEX ix_ge_lookup ON group_events(group_id, identity_id, event, created_at DESC);
-- Current membership: the newest added/removed event wins per (group,
-- identity) — the ADR-003 append-then-derive pattern. touched events never
-- affect membership; they are the delivery-history trail suppression windows
-- read (§8).
CREATE VIEW group_members AS
SELECT group_id, identity_id
FROM (
SELECT group_id, identity_id, event,
ROW_NUMBER() OVER (
PARTITION BY group_id, identity_id
ORDER BY created_at DESC, id DESC
) AS rn
FROM group_events
WHERE event IN ('added', 'removed')
)
WHERE rn = 1 AND event = 'added';
-- Vocabulary views (ADR-037): the read surface user-authored SQL (§10a) and
-- config queries (§9) are written against, so a query reads as vocabulary
-- rather than as table internals. current_values is current_fields with the
-- JSON encoding unwrapped; group_membership is group_members keyed by name.
CREATE VIEW current_values AS
SELECT identity_id, field, json_extract(value, '$') AS value, source, confidence, run_id, created_at
FROM current_fields;
CREATE VIEW group_membership AS
SELECT g.name AS group_name, m.group_id, m.identity_id
FROM group_members m
JOIN groups g ON g.id = m.group_id;
-- Payloads: raw vendor responses as CACHE, not facts (ADR-030). Extracted =
-- fact (append-only, above); unextracted = cache (this table, purgeable).
-- Never projected into any step and absent from `gtme show`'s default output;
-- the only paths out are extraction (which writes facts) and deliberate
-- promotion into a content field. expires_at drives eviction: opportunistic
-- at run start plus `gtme vacuum` (§8). NULL expires_at means keep until an
-- operator vacuums explicitly.
CREATE TABLE payloads (
id TEXT PRIMARY KEY, -- ULID
identity_id TEXT NOT NULL REFERENCES identities(id),
adapter TEXT NOT NULL, -- adapter id that fetched it
run_id TEXT,
content_type TEXT, -- e.g. application/json, text/markdown
body TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT
);
CREATE INDEX ix_payloads_lookup ON payloads(identity_id, adapter, created_at DESC);
CREATE INDEX ix_payloads_expiry ON payloads(expires_at);
-- The current-value projection (ADR-003) is two views, not one: "highest
-- confidence within the freshness window" cannot be answered by an
-- unparameterized view, because the window is a per-caller argument (§7's
-- per-step `cache:`), not a fact about the row. field_value_ranks is the one
-- definition of the RANKING rule (confidence DESC, ties broken by newest
-- created_at); current_fields is that ranking with no window applied
-- (rank = 1) — the plain `gtme query` answer. The runner's windowed
-- projection reads field_value_ranks directly and takes the first in-window
-- row per field, falling through a stale top-ranked row to the next-best
-- one — the same ranking, just windowed where the window is actually known.
CREATE VIEW field_value_ranks AS
SELECT id, identity_id, field, value, source, confidence, run_id, created_at,
ROW_NUMBER() OVER (
PARTITION BY identity_id, field
ORDER BY confidence DESC, created_at DESC
) AS rank
FROM field_values;
CREATE VIEW current_fields AS
SELECT identity_id, field, value, source, confidence, run_id, created_at
FROM field_value_ranks
WHERE rank = 1;
Current-value resolution (ADR-003): the current value of a field is the
row with the highest confidence among rows within the field's freshness
window; ties broken by newest created_at. The ranking half of this rule
MUST be expressed as exactly one artifact — the field_value_ranks view
defined above, mirrored verbatim in spec/ledger.sql — and both the
runner's per-record projection (internal, internal/ledger/project.go,
which applies the per-step window against ranked rows) and gtme query's
current_fields-referencing examples (§8, the unwindowed rank-1 answer)
MUST resolve through it. No second implementation of the ranking rule may
exist.
M14's schema deltas (ADR-036, ADR-037; migration 0007, mirrored
above): deliveries carries status (accepted | confirmed |
contradicted | sent; default accepted) and sent_at (nullable —
empty until a provider attests). Two views join the public read surface
beside current_fields and group_members: current_values presents
current values pre-unwrapped from their JSON encoding, and
group_membership presents current membership keyed by group name, so
user-authored SQL (§10a) and config queries (§9) read as vocabulary
rather than as table internals.
4. Identity keys — DECIDED
Canonicalization lives in the runner (internal/identity/), never in adapters.
The normalization rules the tiers below depend on are defined once, in the
canonical field registry (§4a), and MUST be implemented exactly once — key
derivation here and ingress normalization (§10.1) share the same
implementations.
The tiers are read from the type file (ADR-054). Every entity type's
key derivation is the ordered identity list its type file declares
(§4a) — a sequence of tiers, each naming a registry field whose
normalization is a public-identifier rule (email, domain,
linkedin_url, handle, url) with an optional key prefix, or a hash
of named fields (the nh: fallback). The runner takes the first tier
that yields a value. There is no switch over type names: person and
company are the two lists below, expressed in spec/fields/person.json
and spec/fields/company.json, and a third type's derivation is its own
file's list. A tier MUST NOT name a vendor's record id: keying on one
forks the identity the moment a second vendor arrives (the failure
ADR-020 avoids for LinkedIn), so vendor ids stay in the vendor namespace
and the identity stays keyed on what the platform exposes publicly. The
url rule: trim, lowercase the scheme and host, drop the fragment and
any trailing slash; the path and query are kept as written. A signal type
(§4a) declares no hash tier — a post without a URL or a platform id is
not a post the ledger can hold, and is dropped at the source with the
reason recorded, as a CSV row with no key is today.
person: first non-empty of- lowercased, trimmed email
- normalized public-form LinkedIn slug (strip protocol, host, trailing
slash, query; lowercase; e.g.
in/jane-doe) — internal-form URLs are excluded; see below - normalized GitHub username, prefixed
gh:(reserved tier — see below) - normalized Twitter/X handle, prefixed
tw:(reserved tier — see below) sha256(lower(full_name) + "|" + lower(company_domain))prefixednh:
company: registrable domain (eTLD+1, lowercased; usegolang.org/x/net/publicsuffix), elsesha256(lower(name))prefixednh:.
If an incoming record matches an existing identity on a stronger key than
it was created with (e.g. name-hash identity later gains an email), the
runner MUST update identity_key in place — it MUST NOT create a
duplicate. It MUST log a step_events entry event='identity_upgraded'.
LinkedIn internal forms (ADR-020). A LinkedIn URL in the wild is one of
two incompatible shapes: the public vanity URL (linkedin.com/in/jane-doe)
or an internal/member-ID form — an opaque member-id token in the path (e.g.
in/ACwAA…), or a Sales-Navigator-style path (sales/lead/…). Applying
the strip-and-lowercase rule to both would silently yield two different
keys for the same real person, a dedup failure within a single tier that
the §4 upgrade mechanism (which only handles weak→strong across tiers)
cannot catch. The registry therefore separates the observable URL shapes
into explicitly distinct fields, so they can never collide under one name —
each stored as the URL it is, never reinterpreted as an extracted
identifier (gtme distinguishes shapes; it does not claim to know LinkedIn's
identifier semantics):
linkedin_urladmits the public vanity URL only. Its normalization rule MUST reject any other shape as invalid — rejected, not silently reshaped — so key derivation only ever sees public-form slugs.linkedin_internal_urlholds an internal-form profile URL (an opaque token in thein/path);linkedin_sales_nav_urlholds a Sales Navigator URL (sales/…path). Both trimmed, case preserved. Neither is key material in v0: v0 never merges identities (DECISIONS.md, identity aliases), so keying on an internal shape would permanently fork a person who later arrives under the public form, whereas falling through to a weaker tier converges via the upgrade path when an enrichment resolves the profile and writeslinkedin_url.
An adapter whose provider hands it a LinkedIn-URL-shaped value MUST
classify it at its own boundary (§4a: vendor dialect → canonical) and emit
the matching field: a sales/… path is linkedin_sales_nav_url; an
in/ (or pub/) path whose slug begins with an opaque member token
(case-insensitive acwaa / acoaa followed by a base64-like tail) — or a
profile/talent first path segment — is linkedin_internal_url;
everything else under in//pub/ is linkedin_url. The heuristic errs
toward the non-key fields (recoverable by enrichment + upgrade) over
keying on a non-public shape (not recoverable).
Reserved handle tiers (ADR-020). github_username (key prefix gh:)
and twitter_handle (key prefix tw:) are person identity tiers ranked
below the LinkedIn slug (LinkedIn stays primary for B2B) and above the
name-hash fallback, in that order. Both are globally unique, public,
low-collision handles. No v0 adapter provides either field; the tiers are
fixed now so the ordering does not get designed around later. Normalization
is the registry's handle rule: trim, strip a leading @, strip a
github.com/ / twitter.com/ / x.com/ URL prefix, lowercase.
4a. Canonical field registry — DECIDED
needs/provides matching is string equality; it is only meaningful if
adapters agree on field names and value shapes (ADR-017). The registry is
that agreement: a canonical field registry per entity type lives in
spec/fields/<entity_type>.json — person.json, company.json,
post.json embedded — machine-checkable artifacts,
loaded directly by the implementation and the test suite, per ADR-010.
spec/schemas/field-registry.schema.json is the schema for the registry
files themselves. Since ADR-054 the registry file is the type's whole
definition — its kind, its key tiers, its fields, its references — so
"type file" and "registry file" name the same artifact.
Each registry entry declares: name, tier, type, optional format,
normalization (a named rule, see below), optional enum (the canonical
value domain, where comparability matters), optional items_type (for
arrays), optional reserved (declared but provided by no v0 adapter),
description, and example.
Scope rule: a field is canonical when it crosses an adapter boundary. Three tiers:
- Identity fields (
tier: identity, mandatory): person =email,linkedin_url,first_name,last_name(plus the reservedgithub_username,twitter_handle— §4); company =company_domain,company_name. These back identity-key derivation (§4); their normalization rules are part of the registry, not implicit in the key-derivation spec. - Canonical core (
tier: core): any field that (a) ≥2 adapters provide, (b) is waterfall/dedupe-relevant, or (c) is commonly consumed by compose/deliver steps. Canonical fields MAY declare a canonical VALUE domain (enum) — without value normalization, waterfalls compare incomparables — but a domain is declared only where real providers demonstrably converge, never guessed at design time: a wrong guess is a breaking change waiting to happen. The v0 seed declares none. The milder, structural form of the same idea does apply from day one: canonical types are exact (company_employeesis an integer, never a range string). - Vendor namespace: everything else as
<vendor>.<field>(e.g.apollo.id). Declared AI outputs (ADR-033) default into this tier under the pipeline's name —<pipeline>.<field>— unless the declaration marks the fieldcanonical: true(§7, §9), in which case it lands on the canonical field of that name: a judgment is a fact about working the entity in one campaign, and two campaigns' judgments about one identity MUST NOT collide infield_values. Stored with provenance, queryable, usable inuses:andvariables:. A canonical name MUST NOT contain a dot; a dot marks a namespaced name. Namespaced fields in a pipeline's needs make vendor coupling visible;gtme planMUST note them.
Type files (ADR-054). Beyond fields, a type file declares:
kind: subject | signal. A subject (person,company) is what a pipeline delivers to. A signal (post;job_postingis the expected second, brought by the first binding that emits it) is what a pipeline finds and traverses from: keyed on a platform-public identifier, related to a subject, never the target of a deliver step (a deliver manifest naming a signal type is a plan error).identity: the ordered key tiers §4 reads —[{field: email}, {field: linkedin_url}, {field: github_username, prefix: "gh:"}, …, {hash: [full_name, company_domain], prefix: "nh:"}]. Eachfieldentry names a registry field in this file whosenormalizationis one ofemail,domain,linkedin_url,handle,url. Ahashentry lists components, the first required: a component is a field name,{any: [component, …]}(the first alternative that yields a value), or{join: [field, …]}(the named fields' values joined with a space, present only when every one is); the components' normalized values, lowercased with whitespace collapsed, are joined with|and sha256-hashed under the tier's prefix, later components contributing their value or an empty string.person's fallback is{hash: [{any: [full_name, {join: [first_name, last_name]}]}, company_domain], prefix: "nh:"}— §4's name-hash, the domain a salt and never a key on its own. The fields the tiers name are the file'stier: identityfields.reference(optional, per field):{type: <type>, relation: <name>, fields: [<name>, …]}— 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 that identity and writes<name>from the record to it (relations, §3).company_domainonpersondeclares{type: company, relation: works_at, fields: [company_domain, company_name]}, which is how theworks_atedge has always been written; it is now the registry's declaration rather than the runner's special case. A referenced identity is a ledger fact, never a run member.
Discovery (ADR-054). Types are discovered like adapters (§6), from
three sources, with nothing copied and no verb: (1) the binary embeds
person, company, post; (2) an operator MAY place a file in
~/.gtme/types/<name>.json; (3) a binding MAY ship types/<name>.json
beside its binding.yaml, and the planner reads it in place under the
binding's install directory — the type travels with the binding that
emits it, under the binding's pin, and leaves with it. gtme adapters add installs the binding and nothing else. Embedded names are reserved:
gtme adapters verify MUST refuse a binding shipping a type file whose
name the binary embeds, so nothing installed can redefine how person,
company or post is keyed. Two files of the same name with different
content across any two sources are a plan error naming both paths. A
type two verified registry bindings ship is promoted into the binary
(the rule of two, below). gtme adapters verify prints the type a
binding ships beside its needs and provides.
The adapter–type contract (ADR-054). For every manifest or binding
naming an entity_type (and, for a traverse, a from), gtme plan and
gtme adapters verify MUST check, with no network and no spend: (a) the
name resolves to exactly one type file; (b) every static provides
property is canonical for that type or vendor-namespaced (layer 1
below); (c) for a source or a traverse, the provides properties cover
at least one of the type's key tiers, so every emitted record can be
keyed — failing this at plan is the point, since a source that emits
unkeyable records is billed by the vendor and yields nothing (#27).
Promotion: namespaced → core when a second adapter provides the same fact (the rule of two). Additive registry changes are non-breaking (one ADR line). Renames are breaking: spec amendment + version bump.
Normalization rules are named, and each name maps to exactly one
implementation (internal/identity and nothing else — the same functions
§4 key derivation uses). v0 rule ids: none, trim, lower (trim +
lowercase), email (§4 tier 1), domain (registrable domain, eTLD+1),
linkedin_url (public vanity URLs canonicalized to
https://www.linkedin.com/<slug>; any other LinkedIn shape is an
invalid value for this rule — it belongs in linkedin_internal_url or
linkedin_sales_nav_url, §4), handle (§4 reserved tiers), url (§4,
ADR-054: a platform-public URL as a key). A stored canonical value MUST be a
fixed point of its field's rule.
Enforcement, three layers:
- Manifest validation: every property name in a manifest's static
needs/providesschemas — and every field named in a step'suses:orvariables:values — MUST exist in the registry for the adapter'sentity_type, or be vendor-namespaced. A bare unknown name is a validation error (gtme planexit 2), which names the field and the nearest registry match when one exists. - Runtime: RECORD output validation (§5) additionally checks canonical
fields against the registry: declared
type,enumdomain, and normalized form. A violating record fails per §5 (the run continues). - Adapter conformance kit: every built-in adapter MUST ship golden vendor-payload fixtures and a conformance test asserting fixtures in → expected canonical records out, registry-valid. This is the machine-checkable finish line adapter authoring (human or generated) targets.
Consequence: adapters map vendor dialect → canonical at their own boundary;
nobody downstream thinks about mappings (see §9 and ADR-018: the only two
mapping sites are the csv/source ingress columns: and the deliver egress
variables:). The registry starts small (seeded from the fields the v0
adapters touch plus the curated cross-provider overlap, ≤60 entries) and
grows by demand, never by design session.
5. Wire protocol — DECIDED
NDJSON messages on stdout (adapter → runner) and stdin (runner → adapter).
Every message: {"type": "...", ...}. Unknown message types MUST be
ignored (forward compatibility). JSON Schemas for every message type below
live in spec/schemas/ and are the normative machine-checkable form of
this section; the examples here MUST match them.
Runner → adapter:
{"type":"OPEN","step_id":"...","run_id":"...","config":{...}}
{"type":"OPEN","step_id":"...","run_id":"...","config":{...},"pending":{"token":"..."}} // collecting (ADR-038)
{"type":"OPEN","step_id":"...","run_id":"...","config":{...},"preflight":true} // preflight session (ADR-040): no records follow
{"type":"OPEN","step_id":"...","run_id":"...","config":{...},"accepts":["ERROR"]} // the optional messages this runner acts on (ADR-065)
{"type":"RECORD","key":{"entity_type":"person","identity_key":"..."},"fields":{...}}
{"type":"END"}
fields contains exactly the projection of the adapter's needs — nothing more.
For a traverse step (§6, ADR-054) the inbound RECORD is the parent, of the
manifest's from type; every outbound RECORD MUST carry the manifest's
entity_type in key.entity_type — the protocol already carries the
type per record, which is why a step that changes it needs no new message.
Adapter → runner:
{"type":"SCHEMA","provides":{...json schema...}} // first message
{"type":"RECORD","key":{...},"fields":{...},"confidence":{"email":0.93}}
{"type":"VERDICT","key":{...},"pass":true,"reason":"..."} // filter steps
{"type":"ATTEST","key":{...},"status":"confirmed|contradicted|inconclusive","reason":"..."} // deliver steps declaring attests (§6)
{"type":"PENDING","token":"...","detail":{...}} // work in flight (ADR-038): collect under token
{"type":"PREFLIGHT","status":"ok|blocked|inconclusive","destination":"...","checks":[{"name":"...","ok":true,"detail":"..."}]} // deliver steps declaring preflights (§6); destination optional (ADR-062)
{"type":"COST","key":{...}|null,"provider":"harvest","amount_usd":0.012,"basis":"estimated","detail":{...}}
{"type":"STATE","cursor":{...}} // resumable sources
{"type":"LOG","level":"info|warn|error","msg":"..."}
{"type":"ERROR","key":{...}|null,"verdict":"fail_record|fail_run|skip|retry","reason":"..."} // an error's verdict (ADR-065, §8 "A step that stops")
{"type":"END"}
An outbound RECORD MAY carry a payload attachment — the raw vendor
response (or its per-record slice, for sources) the record was extracted
from, for ADR-030 retention:
{"type":"RECORD","key":{...},"fields":{...},"payload":{"content_type":"application/json","body":"..."}}
The runner owns whether it is kept: it consults the adapter's retention
declaration (§6) and writes the payloads table (§3) with the declared
TTL; an adapter that emits no payloads simply retains nothing. Old readers
ignore the field, per this section's forward-compatibility rule. (This is
the capture path ADR-030's mechanism requires; its spec-impact list named
§3/§6/§8/§10a and this RECORD extension is the minimal §5 counterpart.)
A source's outbound RECORD carries key OPTIONAL rather than required:
{"type":"RECORD","fields":{"email":"jane@acme.com","full_name":"Jane Doe"}}
The runner canonicalizes the identity key from fields itself (§4) when a
source has no key of its own to offer; every other adapter role receives an
inbound RECORD whose key the runner already resolved, and MUST echo it
back on any outbound RECORD/VERDICT/COST it emits for that record.
Rules:
- Sources MUST NOT receive RECORDs; they emit them (after SCHEMA).
- A filter-role step MAY emit both a VERDICT and a RECORD for the same key (ADR-033): the VERDICT gates advancement as ever; the RECORD's fields are its declared provides (§7), stored like any adapter output, so a judge's reasoning is queryable without a second call.
- A deliver adapter declaring
attests(§6) MAY emit an ATTEST after the RECORD that acknowledges a delivery (ADR-036):confirmedandinconclusivelet the record advance — the latter with a receipt warning, its delivery stillaccepted— andcontradictedfails it, keeping thedeliveriesrow with that status, since the record exists at the target and re-sending would duplicate it. An attesting adapter that says nothing about a record isinconclusive. An adapter that does not declareattestsMUST NOT emit ATTEST; a runner ignores one that does. Runners that predate the message ignore it, per the rule above. - In flight (ADR-038): a session MAY end with work it cannot answer
yet by emitting one PENDING carrying a provider-opaque
token, after any records it could answer and before END. Every dispatched record the session did not answer is then pending under that token: the runner records it, does not advance it, and finishes the runpending(§8). To collect, the runner opens a session whose OPEN carriespending: {token}followed by the same records and END; the adapter MUST NOT dispatch new work in such a session — it answers from the token with the ordinary messages, or emits PENDING again if the work is not ready. A record a collection does not answer fails as in any session. COST for deferred work arrives at collection. A runner that predates PENDING ignores it and fails the unanswered records — the honest degradation. - Preflight (ADR-040): a deliver adapter declaring
preflights(§6) answers a preflight session — OPEN withpreflight: true, then END, no records — with one PREFLIGHT then END:ok;blocked, a readable fact says sends would be meaningless or wrong (the runner fails the step before any record is dispatched, §8);inconclusive, the target could not be read (reported ok with a warning).checkslists what was examined for the receipt.destination(OPTIONAL, ADR-062) is a display label for what the step delivers to, such as a campaign's name and id; the receipt prints it, and nothing keys on it. An adapter that does not declarepreflightsis never asked; one that is asked MUST NOT send anything in that session. - Errors (ADR-065): an adapter MAY report what an error means for a
record, or for the whole step, with ERROR.
verdictis the binding tier's error vocabulary (§10a), and the runner acts on it as §8 "A step that stops" says:fail_recordandretryfail the keyed record (retrysays the adapter's own attempts ran out; the runner never re-dispatches),skipcounts itskipped, andfail_runstops the step.keyis REQUIRED forfail_record,skipandretryand OPTIONAL forfail_run;reasonis REQUIRED and is what the receipt and the ledger print. A record named by ERROR MUST NOT be treated as answered by a later RECORD or ATTEST in the session, and for a deliver step it is never a send. A runner that acts on ERROR MUST list it in OPEN'saccepts. An adapter whose OPEN does not list it MUST NOT rely onfail_record,skiporretryand fails the session by exiting non-zero instead, as before. After an ERROR withfail_runthe adapter SHOULD emit END and exit with its error's class (§8), so a runner that predates the message still fails the session. confidenceis per-field, OPTIONAL, default 1.0.- COST is best-effort but every v0 built-in adapter that spends money or
tokens MUST emit it (estimate token cost from the API usage response).
COST MAY carry
basis: "measured" | "estimated"(ADR-046); absent meansestimated.measuredis reserved for an amount derived from vendor-reported cost metadata in the response — an amount multiplied out from a config or manifest rate isestimatedeven when the unit count is exact. - Output RECORD
fieldsMUST be validated against the manifestprovidesschema before ledger write; on invalid input the record MUST fail (step_events.event='failed'), and the run MUST continue. A RECORD with nofieldsasserts nothing and is not validated: it means "nothing acquired for this record", exactly as no RECORD would, and it MAY carry thepayloadthe adapter could not use — the record advances countedempty(§8, ADR-053). A filter's or an attesting deliverer's RECORD is never read this way; their verdict or attestation decides. - Adapters MUST exit 0 on success, non-zero on fatal error; partial output
before a crash MUST be kept (ledger is append-only), and the run MUST be
resumable from that point. An exit of 3 (auth) stops the step as
fail_rundoes (§8 "A step that stops", ADR-065); any other non-zero exit fails that session's unanswered records and dispatch continues.
6. Adapter manifest — DECIDED
Each adapter has a manifest.json. Built-ins embed theirs; external adapters
ship it next to the executable. Discovery path for external adapters:
~/.gtme/adapters/<name>/ containing manifest.json + executable named run,
or a binding.yaml (§10a) — installed by hand or by gtme adapters add
(§8, ADR-042), which records the source and pin in .source.json beside
it. adapters add installs a registry process entry (ADR-063) as the
same manifest.json + run pair. A use: id that resolves to no built-in and nothing on the
discovery path is a plan error naming the command that would install it,
gtme adapters add <id> (ADR-059); plan does not consult the registry
index to decide whether the id exists there. The canonical schema for this file is
spec/schemas/manifest.schema.json.
{
"id": "harvest/profile",
"version": 1,
"role": "enrich", // source|traverse|filter|enrich|verify|compose|review|deliver (review: ADR-048 — compose-shaped, of: required, never gates; traverse: ADR-054 — records of `from` in, records of `entity_type` out, each related to its parent)
"entity_type": "person",
"needs": {
"type": "object",
"required": ["linkedin_url"],
"properties": { "linkedin_url": {"type": "string"} }
},
"provides": {
"type": "object",
"properties": {
"headline": {"type": "string"},
"recent_posts": {"type": "array", "items": {"type": "string"}},
"role_history": {"type": "array", "items": {"type": "string"}}
}
},
"credentials": ["HARVEST_API_KEY"],
"credentials_optional": ["ANTHROPIC_API_KEY"],
"config_schema": { "type": "object", "properties": {} },
"freshness_days": 30,
"batch": false
}
credentials: env var names the runner MUST inject into the adapter process. The runner MUST source them from the OS env first, then~/.gtme/secrets(aKEY=valuefile, mode 0600, written bygtme secret set KEY). A missing declared credential is a plan-time error.idempotency(deliver adapters, optional; ADR-045, mirroring §10a's binding key):nativedeclares the target upserts (re-delivery cannot duplicate — Attio's assert), which unlocksredeliver:(§8, §9);ledgeror undeclared keeps the hard dedupe floor.idempotency_scope(deliver adapters, optional; ADR-044): the name of a config key whose resolved value scopes this adapter'sdeliveriesrows — see §8 deliver idempotency. Undeclared means unscoped (''). The key MUST hold a stable identifier of the destination — an id, an API slug, a file path, a URL — and never a display name its owner can change (ADR-062). Where the identifier has a shape (a UUID),config_schemaSHOULD constrain the key to it, so a name is a plan error.credentials_optional: env var names injected when present, exactly likecredentials, but a missing one is agtme planwarning, never an error. For an adapter that can genuinely work more than one way — a step that can answer from a local fixture as well as a keyed API — declaring the key ascredentialswould fail plans that are actually fine.- Dynamic needs (ADR-019): a manifest whose step contract is defined by
external user-authored content (an AI prompt, a campaign template) cannot
enumerate its needs statically. Such a manifest MAY declare its
needsas the string"dynamic", or as an object{"dynamic": true, ...}where the rest of the object is an ordinary JSON Schema acting as a static floor (itsrequiredfields are always needed regardless of config — e.g. a deliver adapter that cannot function withoutemail). The step's effective needs are then the floor (if any) plus the config-derived list:uses:forfilter/composesteps (ADR-004, mechanics unchanged), the values ofvariables:fordeliversteps (§9). The planner validates the effective needs exactly as it validatesneeds.required(§7), and the runner projects exactly those fields. A dynamic filter/compose step declaring nouses:projects every field the ledger holds for the record (the needs-all behavior, as before ADR-004); a dynamic deliver step declaring novariables:needs only its floor. - Registry validation (ADR-017, §4a): every property name in a static
needsorprovidesschema MUST be canonical for the manifest'sentity_typeor vendor-namespaced (<vendor>.<field>). Schemas that name no properties (the needs-all wildcard, an open source schema) have nothing to check. - Entity-agnostic manifests (ADR-033): an adapter whose contract does
not depend on the entity type — the AI steps (§10.3, §10.5) — declares
"entity_type": "*". Its steps take the pipeline's entity type (the current segment's, §7 — the source's, a traverse's output type after one, a typed group's after a group source; only an untyped legacy group leaves it unknown, in which case name validation is entity-blind), and a staticneeds/providesschema on such a manifest is validated against that type at plan time. A source or a traverse MUST NOT be entity-agnostic: each names the type it emits, and the planner rejects one that does not. - Traverse (ADR-054): a manifest with
"role": "traverse"declaresfrom(the input type — the planner requires it to equal the current segment's type),entity_type(the output type; it MAY equalfrom— people to their coworkers is a traverse),needs(validated againstfrom),provides(validated againstentity_type, key coverage included, §4a), andrelation: {"name": "authored_by", "from": "record" | "parent"}— the edge written between every emitted record and the parent it was traversed from, and which end it starts at (authored_byruns from the post to its author;works_atfrom a person to the company they were traversed from). The runner dispatches a traverse per parent (or per batch, as an enrich), mints or resolves each emitted record, writes the relation, and opens the next segment (§7).limitis engine-owned on a traverse exactly as on a source (ADR-047). freshness_days: default cache window for fields this adapter provides; overridable per step (cache:in YAML).- Payload retention (ADR-030):
keep_payloads(default true) andpayload_ttl_days(default 90) declare whether raw responses this adapter attaches to its RECORDs (§5) are retained and for how long; a step MAY override withkeep_payloads: falsein its config. Retention only applies where an adapter actually attaches payloads — in this build, the binding engine andhttp/enrich; the Go vendor adapters do not attach them yet (a queued adoption, recorded, not silent). batch: marks an adapter the runner MUST feed inbatch_size-sized invocations (§9, default 25 — one adapter session per batch) rather than dispatching it across the normal per-record worker pool.ai/filterandai/composeset this; it is what makes their one-call-per-batch-of-25 behavior (§10.3, §10.5) a manifest fact the runner reads, not a special case hard-coded to those two adapter ids.- Preflight (ADR-040): a deliver adapter MAY declare
preflights: true, meaning it can check the live target against what a step is about to send — read-only, zero spend, once per run — and answer PREFLIGHT (§5) in a preflight session. The checks derive from the step's own config andvariables:; the operator configures nothing, andpreflight: falsein adapter config skips them. - Attestation (ADR-036): a deliver adapter MAY declare
attests: true, meaning that after a successful create it re-reads the target and reports a three-way verdict per record —confirmed(every non-blank field sent is present),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). An adapter that does not declare it yieldsinconclusive— the honest default. - Sources additionally MAY declare
emits_key_fields(which output fields the runner should build the identity key from).
7. Contract validation & the planner — DECIDED
gtme plan (and implicitly gtme run) MUST:
- Resolve every step's adapter + manifest.
- Walk the pipeline: maintain the set of available fields (source
provides∪ each prior step'sprovides) and the current type (ADR-054: the source's, or the group's for a group source; a traverse replaces it with itsentity_typeand resets the available-field set to the traverse'sprovides). For each step, every property inneeds.requiredMUST be available; failure MUST produce an error naming the step and the missing field. No network calls, no spend. - Verify all
credentialsacross all steps are resolvable. - Print the resolved plan: steps, projections, cache windows, and known
per-record cost estimates (from a static
cost_estimate_usdoptional manifest field; print?when absent). A binding whosecost.amount_usdis a template that resolves to nothing (the operator set no rate) printsest/record: unset, never$0.0000(ADR-046) — the gap is visible before anything is spent.
Plan labels (ADR-058): the projection line is reads:; a step's
of: line prints of: <field> (the value under review); the terminus
prints ends in group "<name>", with as <type> when the run is typed;
and the default plan output carries no ADR- or § citation (§8's
plain-words rule).
Plan rendering — --viz (ADR-051): gtme plan --viz appends a diagram
of the resolved plan to the output above; --viz-only prints the diagram in
place of it. Both are renderings of the same resolved plan: same validation,
same stderr stream, no network calls and no spend, and neither changes what
gtme plan accepts nor what it exits with. The default output — no flag —
is unchanged and remains the normative surface: everything item 4 above
requires MUST appear there, and a fact that appears only in the diagram is a
defect. A second implementation MUST print the default output and MAY render
the diagram in any form, or not at all. The diagram's vocabulary (one
silhouette per role, an executor-and-role glyph pair, and edges labelled
with the fields each step adds to the available set) is recorded in ADR-051,
not here, precisely because it is not something a second implementation must
match to interoperate.
AI-step needs (ADR-004): an AI-role step's config MAY declare
uses: [field, ...] (see §9). When present, the planner MUST treat uses
exactly as it treats needs.required for step 2 above: every field named in
uses MUST be available from the source or a prior step's provides, or
gtme plan MUST fail naming the step and the missing field. At execution
time, the fields object projected into the step MUST be built from uses
rather than from the adapter's static manifest needs (which, for AI
adapters, is open-ended precisely because the real needs vary per-prompt and
can only be declared per-step). A step declaring no uses and whose
manifest needs is the needs-all wildcard (additionalProperties: true, no
properties) projects every field the ledger holds for the record, as
before ADR-004 — uses is how an AI step narrows that to what it actually
needs, gaining plan-time validation in exchange.
A declared field absent at run time (ADR-053). uses: and of: are
validated at plan time for availability (above), which does not make
them present on every record: an enrich step may legitimately produce
nothing for one. A participant step MAY therefore carry on_missing: run | skip | fail — the vocabulary deliver steps already use for variables:
(§8). run (the DEFAULT) dispatches the record with the field absent, as
before; skip advances the record untouched without dispatching it;
fail fails the record naming the missing fields. Whatever the setting,
the receipt MUST report how many records were dispatched with a declared
field absent, so the gap is visible without the operator having chosen
anything. The default is run because a sparse uses: is a legitimate
pattern — a compose working from whatever the ledger holds — and changing
it would alter the meaning of pipelines already written.
Referents and human surfaces (ADR-048, ADR-049): a compose or review
step MAY declare of: <field> — the value it is about (required on a
review); the planner MUST validate it exactly as one more uses: entry,
the runtime includes its current value in the record's input hash
(ADR-039) and its field_values.id in the provenance of everything the
step writes (field_values.referent, §3). A human/* step's render.fields,
and every per-record template: reference (ADR-057), are validated the
same way. A human/* or agent/* step may sit
at any position; when a deliver step follows one, gtme plan prints one
note — under cron this pipeline waits for a person — and names the
pattern (§8: review into a group, send from the group).
Dynamic needs generalized (ADR-019): uses: is one instance of a
general mechanism. For any step whose manifest declares dynamic needs (§6),
the planner MUST derive the step's effective needs from config — uses:
for filter/compose, the values of variables: for deliver — union the
manifest's static floor, and validate every derived field against the
available set exactly as step 2 above: a referenced field nothing provides
is a plan error naming the step and the field. Effective needs referencing
vendor-namespaced fields (§4a) are valid, but gtme plan MUST note the
vendor coupling in its output.
One-of needs (ADR-020 corollary): a static needs schema whose top
level is anyOf of object schemas declares alternative ways to satisfy
the step — "at least one of these field sets." The planner MUST accept the
step when at least one branch's required fields are all available, and a
failure MUST name every branch and what each branch is missing. The
projection is built from the union of all branches' declared properties
(fields with no value are simply absent, as ever). At runtime, needs
validation validates against the schema itself, where anyOf already
carries the same meaning — the planner's walk is the only place needing
the rule stated. The motivating case: harvest/profile needs at least one
kind of LinkedIn URL (§10.4), which no flat required list can say.
Dynamic provides (ADR-024; decided 2026-08-16, build queued — see
§11): the mirror of dynamic needs. A step whose output field names are
defined by user config rather than a static manifest — http/enrich's
configured content field, a sql/transform step's declared provides:
(§10a) — MAY declare its manifest provides as dynamic; its effective
provides are then derived from config. The planner MUST add the derived
names to the available-field set for downstream validation exactly as it
adds static provides, and downstream uses:/needs referencing them
validate normally. A derived name MUST be canonical or vendor-namespaced
per §4a — an ad-hoc name is namespaced unless the config maps it to a
canonical field — and the runtime MUST validate the step's output against
the derived provides exactly as it validates static provides (§5).
Declared AI provides (ADR-033): an AI-role step MAY carry a step-level
provides: — a list of field names, or a JSON-Schema object whose
property names are the fields and whose values MAY declare type,
enum, and canonical. The planner treats it as dynamic provides above.
Every bare name defaults into <pipeline>.<field> (§4a) — a name that
happens to coincide with a canonical field included, which gtme plan
MUST note — and a name written with a dot is kept as written. A field
marked canonical: true lands on the canonical field of that name
instead (global, not per-campaign): the name MUST be canonical for the
pipeline's entity type, and a declared type or enum MUST agree with
the registry entry, or the plan fails naming the step and field. The
runtime validates the model's output against the derived schema with the
existing retry (§10.3), and an enum violation is a validation failure,
never stored. A filter with provides: emits VERDICT and RECORD (§5). A
step declaring nothing keeps its manifest's static shape.
Ingress mapping checks (ADR-018): for a source with a columns:
mapping (§10.1), the planner MUST additionally verify, still with no
network calls and no spend: (a) every mapped header exists in the source's
probed schema (a mapping to a column the file does not have is a plan
error); (b) headers that already match canonical names auto-map with zero
config, and a near-miss (an unmapped header a small edit away from a
canonical name) is SUGGESTED in plan output, never silently guessed;
(c) the mapped-plus-auto-mapped field set yields at least one identity-key
tier (§4) — none at all is a plan error, and only the name-hash fallback
tier is a plan warning.
Types, legs and traverses (ADR-054; leg per ADR-058): a run is a sequence of
typed legs, and the planner walks them. Every step is validated
against the type of its leg — its manifest's entity_type MUST equal
it or be *. A traverse step's from MUST equal the current type, and
after it only records of its entity_type continue: the records before
it are finished at the traverse (terminal in ADR-052's sense, whether
they yielded children or none), and a deliver step MAY sit in any
leg. when: MAY name only a step in the current leg — a verdict
is a fact about the parent, not the child — and a reference across a
traverse is a plan error naming the traverse to gate at instead (when: judge.passed on the traverse step means only children of passing
parents are minted). For every manifest naming a type, plan runs the adapter–type
contract (§4a): the type resolves to one file, provides is canonical
for it, and a source or traverse can key what it emits. Plan output MUST
print each traverse as a type change with its relation — person → post via harvest/posts (authored_by) — and, for any step whose provides
carry a reference field (§4a), the relation it will write — writes works_at → company — so a reviewer sees the graph a run builds without
reading a type file. A {query:} value that reads group_membership
prints the group it reads. The terminus and every group/deliver are
checked against their group's type (below); a mismatch — a company run
ending in a group of people — fails the plan naming the group, its type,
and the pipeline's final type.
Group checks (ADR-021): group references are resolved against the
local ledger — read-only, still zero network calls and zero spend. A
group named by require:, exclude:, suppress:, or a group source
(§9) MUST exist, or the plan fails naming the group and the fix
(gtme groups add <name> …). A record: target and a group terminus are
created on demand at run time (they record outcomes; requiring them to
pre-exist would make every new pipeline a two-step dance), so plan only
checks their names are non-empty — and, when the group already exists,
that its entity_type (§3, ADR-054) matches the pipeline's final type; a
group created on demand takes that type. A group source takes its
group's type as the pipeline's (§9); a group with no type (created before
ADR-054) leaves the pipeline entity-blind, and plan MUST say so and name
the fix (gtme groups add <name> --type <type>). A suppress.within window MUST parse
as Nd (§9's cache grammar). The plan output lists each step's
membership gates, and MUST call out each deliver step — target adapter
and touch scope — so the pipeline's full send surface is reviewable in
one place (ADR-031: with delivers as ordinary steps, plan output is
where the send points are obvious at a glance, not YAML position).
Config values from the ledger (ADR-037): any value inside a step's
with: MAY be {query: <SQL>} or {segment: <name>} (a saved query,
§8). The planner resolves it read-only against the local ledger — no
network, no spend — before validating the step's config against the
adapter's config_schema, substitutes the result (one column → a list;
one row and one column → a scalar), prints the resolved rows in plan
output, and fails the plan on zero rows (an empty list handed to a vendor
search is the shape that searches everything). The run resolves again at
start and records the resolved config in runs.config_json. A missing
segment is a plan error naming the fix (gtme query --save).
SQL steps at plan (ADR-037): a sql/* step's query is run through
EXPLAIN QUERY PLAN against the local ledger at plan time, so an unknown
table or column fails the plan rather than the run. Plan output annotates
a SQL step whose query references relations or group_members as
cross-record, so a reviewer sees its character without reading the
join.
Respend (ADR-038, narrowed by ADR-039): plan MUST warn when a paid
enrich/verify step would pay for the same records again on a re-run with
nothing to remember the answer — its adapter declares credentials or a
cost estimate and it has no freshness window (no manifest
freshness_days, no cache:). respend: true on the step (§9) silences
the warning; it is the operator saying so in the reviewable file. AI
steps do not warn: the judgment cache (above) remembers by default.
One commit point (ADR-032): plan MUST warn when a pipeline carries
both a group/deliver step and a network-side deliver step — ADR-031
arms every deliver in a run at once, so approving the handoff would
approve the send.
At execution time, per step, per record:
- Membership gates (ADR-021): a step with
require:processes only records currently members of EVERY listed group; a step withexclude:skips records currently members of ANY listed group. Gated records do not advance past the step and are counted likewhen:gates. Exclusion is the judgment-memory mechanism: a qualify pipeline that excludes its own output groups sends only never-judged records to its filter, so an identity is judged once per scope — determinism first, cost second. - Cache check (enrich/verify): if every field in the step's
providesalready has a current value within the freshness window, the runner MUST skip the record (step_events.event='skipped_cache', no adapter call, no cost). - Judgment cache (AI roles; ADR-039): before dispatching a record to
an AI step the runner MUST compute the step's judgment signature — a
hash over the adapter id, the model identifier, the
template:source after loading plus theconfig.*values it references (ADR-057), the output shape (declared or default provides) and theuses:list — and the record's input hash — a hash, as canonical sorted JSON, over the fields the judgment reads: theuses:fields when declared, else the projection minus the step's own provides and minus every field namespaced by this pipeline (§4a), so a step never sees its own last answer as a changed input. If adoneevent for this identity carries the same signature and input hash (any run), the runner MUST skip the record (skipped_cache, reasonsame_judgment): a filter re-applies the stored verdict (pass advances, fail freezes), a compose writes nothing (its fields are current with that provenance). There is no time window by default — the same question about the same facts has the same answer;cache: Ndbounds reuse to N days (what a prompt that reads the clock itself needs);respend: trueorcache: 0ddisables it. A deferred step (ADR-038) cache-checks before it submits. - Template scope (ADR-057): the planner MUST parse every participant
step's
template:(§9) and reject a parse error, an unknown tag or filter (the dialect is §10 item 10), arecord.*reference on anai/*step, arecord.*reference on atext/*/human/*/agent/*step naming a field outsideuses:andof:, and aconfig.*reference naming a key absent from the step'swith:. A{file:}that does not resolve relative to the pipeline file is a plan error. - Projection: the runner MUST build
fieldsstrictly fromneedsproperties (or fromuses, for AI steps that declare it). - Filter verdicts MUST be stored in
run_records.verdicts; records withpass=falsestop advancing (state freezes at the filter step) but MUST remain in the ledger.
8. CLI surface — DECIDED
gtme init # create ledger + ~/.gtme
gtme secret set KEY [VALUE] # VALUE omitted → prompt, no echo
gtme plan pipeline.yaml [--viz|--viz-only] # validate + print plan, no execution; --viz appends the diagram, --viz-only prints it alone
gtme run pipeline.yaml [--resume RUN_ID [--resend-unconfirmed[=KEYS]] [--settle-unconfirmed[=KEYS]]] [--dry-run] [--simulate]
gtme query "SQL" # read-only SQL against the ledger
gtme query --save NAME "SQL" # saved segment
gtme show <identity-key> # read-only projection inspector
gtme show --run last [--fields ...] [--provenance] [--limit N]
gtme runs [RUN_ID] # list runs / show one run's receipt
gtme answer [RUN_ID|last|PIPELINE] [STEP] [IDENTITY_KEY] [--set f=v ...] [--as NAME] [--cost USD [--measured]] [--note TEXT] # ADR-049: a participant's answer for a pending human/agent step; no key + TTY walks the records
gtme show --run RUN_ID --pending [STEP] # the pending records with their rendered surface (ADR-049)
gtme freeze [RUN_ID|last] [--bundle DIR] # bare: YAML to stdout; --bundle: campaign bundle
gtme groups [show NAME | add NAME ... | remove NAME ...] # ADR-021, see below
gtme vacuum # evict expired payloads (ADR-030), nothing else
gtme help --agent # machine-readable full CLI + adapter surface
gtme help --bindings # the binding contract: schema, discovery path, a reference binding (ADR-041)
gtme adapters # installed adapters with source and pin (ADR-042)
gtme adapters search TEXT # search the registry index
gtme adapters add REF... # install bindings by github.com/<owner>/<repo>/<path>[@ref] or registry id, verified first
gtme adapters verify ID # schema + fixtures offline; prints hosts and credentials it will use
gtme adapters update ID [@ref] # re-fetch at a newer ref, explicitly
This is the entire v0 verb set (ADR-005, extended by ADR-021's gtme groups in M9, ADR-030's gtme vacuum in M11, ADR-042's gtme adapters
in M19, and ADR-049's gtme answer). ADR-051 adds no verb: --viz and
--viz-only are rendering options on gtme plan.
Plain words on the default surface (ADR-058): everything gtme
prints for an operator by default — plan, receipts, progress lines,
errors — names the thing and the fix in the docs' vocabulary and carries
no ADR- or § citation. The citations live in SPEC.md, DECISIONS.md
and the reference surfaces (help --agent, help --bindings), which
keep them. A typed stretch of a run is a leg (§7); segment means only
a saved query over the ledger.
Payload eviction — gtme vacuum (ADR-030; built in M11)
gtme vacuum deletes payloads whose expires_at has passed — and nothing
else; facts are append-only forever, and payload eviction is the one
legitimate deletion in the system (§3: cache, not knowledge). It reports
how many rows went. The runner also evicts opportunistically at the start
of every armed run, so a busy ledger stays bounded without a daemon
(ADR-009's stance); gtme vacuum exists for quiet ledgers and for
operators who want eviction on their own schedule. There is no gtme x, no
multi-process pipe chaining, and no standalone source/filter/enrich/
compose/deliver subcommands — those existed only to support pipe mode
and are cut along with it. uses:, cache:, when: and every other
per-step option are YAML config (§9), never CLI flags. All human-facing
output (receipts, progress, errors) MUST go to stderr; stdout is reserved
for data (gtme query's result rows, gtme freeze's YAML). Exit codes: 0 ok,
2 validation/contract error, 3 auth/credential, 4 rate-limited, 5 network, 1
other.
Record accounting (ADR-053). A step's out counts records the step
contributed to, not merely records that advanced. For a step whose
output is fields — enrich, verify, compose, review, sql/transform — a
record that advanced without the step writing any field MUST be counted
empty and printed beside out; out + empty is what advanced. A
filter's output is a verdict and a deliver's is a send, so neither counts
empty. Per step, in MUST reconcile: `out + empty + filtered + failed
- gated + skipped + cached + already delivered
. A deliver step counts a record it withheld because the destination already has it (reasonalready_deliveredorunchanged, §8 deliver idempotency) asalready delivered, never ascached: not re-sending is the contract, not a saving (ADR-062).intherefore counts every record eligible at the step, not only those handed to the adapter; a record still in flight, held by a dry run, passed through a simulation gap, held unconfirmed after a crash (ADR-060), or never dispatched because the step stopped (ADR-065) is the non-terminal remainder and the line names it (N in flight,N held (dry run),N simulated,N unconfirmed,N not sent), so the identity holds for a step that has not settled. Every per-recorddone,failed,skipped_cache,simulatedanddry_runevent MUST carrydetail.outcome, naming the one column it counts in (out,empty,filtered,skipped,failed,cached,already_delivered,simulated,held_dry), and a record awhen:or membership gate holds back MUST write agatedevent, so the line can be rebuilt from the ledger alone (ADR-064). A cache skip's detail carriesavoided_usd`, null when the estimate is unknown.
A source MUST reconcile what it read against what it sourced, classifying
the difference — records that coalesced into identities the ledger already
held are the ordinary cause, and the coalescing MUST be recorded per
record (a step_events row, event coalesced) so which row merged into
which identity is answerable afterward, not merely counted; the receipt
line says already in the ledger (ADR-058):
source [info]: read 940 rows from acquired-contacts.csv
source: sourced 931 records (9 already in the ledger)
website: 10 in, 2 out, 8 empty
write: 10 in, 10 out (8 missing web.homepage)
A run that spent money and produced no records MUST say so on the receipt
and in gtme runs — done — 0 records, $4.10 spent (estimated). Its exit
code is unchanged: §8's codes are a scripting contract, and this is
information, not a new outcome.
A traverse step (§6, ADR-054) reports two populations: in counts its
parents and reconciles exactly as any step's does (a parent that yielded
no children counts empty); traversed and already in this run count
the children it minted and the children that resolved to an identity
already in the run (ADR-053 (3); the step_events event stays
coalesced, ADR-058). Spend at a traverse is spend as at a source,
and a dry run executes it:
posts: 10 in, 8 out, 2 empty — 84 traversed (post), 3 already in this run
Terminal receipt (stderr, end of run): records in/out per step, cache
skips, cost per step and total, cost avoided via cache (sum of
cost_estimate_usd for skipped records; ? if unknown). An
already-delivered skip is neither a cache skip nor cost avoided; a
deliver step's line reports it as N already delivered (ADR-062). Totals carry
their basis (ADR-046): a purely measured total prints bare; a purely
estimated one prints total: $X (estimated); a mixed run splits —
total: $X ($Y measured + $Z estimated).
gtme runs RUN_ID is the receipt (ADR-064). It prints the live
receipt's table — the same columns and words, already delivered
included — rebuilt from step_events, costs and runs.config_json
(for adapter), and then the run-level lines: status, started,
finished, records by state, config, and held: and resume: when they
apply. It reports the run's net outcome: each record counts once per
step, by its latest outcome there across every session of the run, so a
resumed run reads as one run. A run recorded before these fields existed
prints ? for what the ledger never recorded (gated records, cost
avoided) and infers the rest from each event and its reason. A step that
stopped (§8 "A step that stops", ADR-065) prints its not sent count
and reason from the step-level event's detail.stopped.
gtme show (ADR-006)
gtme show <identity-key> prints the full current-value projection
(current_fields, §3) for that identity: every field, its current value,
and — with --provenance — the source adapter, confidence, run that
wrote it, and, for a review's or edit's outputs, the referent (ADR-048:
the field_values row it was about) and the participant's --note. gtme show --run last (or a specific RUN_ID) lists the records
touched by that run instead of a single identity. --fields a,b,c narrows
the printed fields; --limit N caps rows for --run mode. The record
object carries identity_key_tier (ADR-058): the type file's identity
field the key was derived from — email, linkedin_url,
company_domain — or name_hash, so a bare nh: key is explained where it prints. gtme show is
strictly read-only: it MUST NOT write to the ledger, and it MUST NOT appear
in gtme freeze output (it is an inspection tool, not a pipeline step).
gtme help --agent (ADR-007)
Emits the full CLI + adapter surface as one compact (~1–2k token)
machine-readable document, regenerated from the live verb table and the
installed adapter registry — never hand-maintained. It MUST include every
verb and flag from this section, and every installed adapter's manifest
(needs/provides, in the shape of §6), plus 3 canonical example
pipelines. Acceptance criterion: the document MUST round-trip — an agent
given only gtme help --agent's output and no other context MUST be able to
author a valid pipeline.yaml that passes gtme plan. It MUST also
include the ledger's public read surface — the tables and views of §3 —
and the canonical query shapes (a cross-type membership gate, a
cross-record aggregate, a config-value query), so an agent can write a
sql/* step or a {query:} value without reading spec/ledger.sql
(ADR-037). It MUST NOT carry the binding contract; it MUST point at the
surface that does: gtme help --bindings (ADR-041) prints, as one
machine-readable document, the binding schema (spec/binding-schema.json,
byte-identical to the artifact), the discovery path (§6, §10a), one
reference binding verbatim as a worked example, the conformance
expectation (fixtures beside the binding, served by --simulate), and the
adapters verbs (ADR-042). Its acceptance criterion mirrors the first:
an agent given only gtme help --bindings and no other context MUST be
able to author a binding that gtme plan resolves and gtme adapters verify passes.
gtme adapters — the bindings registry (ADR-042)
Bindings are URL-addressed and pinned. gtme adapters add github.com/<owner>/<repo>/<path>[@<tag|sha>] fetches the repository over
HTTPS at that ref (no git dependency; a GITHUB_TOKEN stored with gtme secret for private repositories), copies the binding directory —
binding.yaml and fixtures/ — into ~/.gtme/adapters/<id, slashes → dashes>/, and writes .source.json beside it: the ref as given, the
resolved commit, the content sha256, the install time. Nothing installs
unverified: add runs gtme adapters verify first — the binding
validates against the schema, its conformance fixtures run offline, and
the reviewable surface is printed (the hosts its requests will call, the
credentials it will demand, its needs and provides); a binding with no
fixtures, or failing ones, does not install. gtme adapters update <id> [@ref] re-fetches only when asked; nothing moves a pin implicitly. gtme adapters search <text> reads the registry index
(spec/schemas/registry-index.schema.json; GTME_REGISTRY overrides the
URL) and matches id, vendor, description and role; gtme adapters lists
what is installed with its source and pin. The index is published by the
gtme-bindings repository, which holds the verified entries (its CI
runs their fixtures) and points at community entries in their authors'
repositories. add takes several references in one call, and a bare
registry id (gtme adapters add apollo/search apollo/enrich) resolves
through the index to that entry's source, pinned at the index's sha,
so a bare id is exactly as pinned as a full reference (ADR-059). Each
reference is verified and installed on its own; one that fails does not
undo the others, and the exit code reports the failure.
Process entries (ADR-063). An index entry is kind: binding (the
default) or kind: process. A process entry is a prebuilt process
adapter: it carries assets, one per §13 target (darwin/arm64,
darwin/amd64, linux/amd64, linux/arm64), each an archive URL and
its SHA-256, and its source names the code it was built from. add
downloads this platform's archive, refuses a checksum mismatch, unpacks
manifest.json and run into the discovery path (§6), validates the
manifest, writes .source.json (asset URL, SHA-256, release tag,
commit), and prints the same reviewable surface as for a binding. A
platform with no asset is refused, naming the platforms that have one.
update moves the pin, and nothing moves it implicitly: upgrading gtme
does not upgrade an installed adapter. Process entries are verified
only — built by gtme-run CI from a tagged commit whose tests passed — so
for them "nothing installs unverified" means the pinned checksum, a
manifest that validates, and those tests; install does not re-run
fixtures. Community entries are bindings.
The binary carries the floor (csv/*, http/*, sql/*, ai/*,
group/*, the runner-owned human/*, agent/* and text/compose, and
the keyless demo/enrich) and no vendor: every adapter named for a
vendor is a registry entry (ADR-059, ADR-063).
Event-driven pipelines: a scheduled run over a file a receiver writes (ADR-009; the spool adapter deferred, ADR-055)
There is no daemon and no long-running receiver process (§13 non-goals). The
v0 answer to "run a pipeline when an event happens" is: a commodity webhook
receiver you already have (a Cloudflare Worker, a Zapier/Make webhook
action, a GitHub Action) appends each incoming payload as one row to a
file, and a scheduled gtme run (cron, launchd, CI schedule) invokes the
pipeline periodically. Today the file is a CSV and the source is
csv/source (§10.1): rows are re-sourced on every run, and identity
coalescing (§4), the judgment cache (§7) and delivery idempotency make
that cheap and safe — at-least-once redelivery from the receiver is
absorbed structurally by the deliveries table's UNIQUE(target, scope, idempotency) constraint (§3, ADR-044), so replaying the same event
through the pipeline twice produces at most one delivery. The adapter
that would drain an NDJSON spool and mark lines consumed, ADR-009's
webhook/source, is deferred to ROADMAP.md (ADR-055) and returns with
listen. Per-event low latency is explicitly out of scope for v0.
In-flight steps — deferred: true, the pipeline's last step; run collects (ADR-038)
An AI step MAY carry deferred: true in its config (§10.3, §10.5): the
api engine submits the batch to the provider's batch surface (the
Message Batches API, custom_id = identity key) instead of answering in
the request, and the session ends with PENDING (§5). A deferred step
MUST be the pipeline's last step; gtme plan rejects it anywhere else,
naming the fix: land the step's output through what already carries it —
declared provides: fields (§7) and the group: terminus (§8) — and let
a consumer pipeline pull from the group. The consequence is the point: no
deliver step can follow a deferred judgment, so a send is always its own
pipeline with its own dry-run receipt showing the judgments as
collected, and approval never precedes the judgment. One deferred step
per pipeline follows.
The run then finishes with status pending — a run that ended with a
step in flight is not done — and the receipt reports how many records
are in flight, the token, and that the next gtme run collects:
judge: 25 in, 0 out — 25 in flight (msgbatch_01…); the next `gtme run judge.yaml` collects
gtme run collects before it starts. When the most recent run of the
same pipeline is pending, gtme run <pipeline> resumes that run — it
says so on stderr — rather than sourcing anew, so the cron recipe (§8) is
unchanged and nothing is ever submitted twice by habit. gtme run --resume RUN_ID|last is the explicit form of the same thing. Collection
reaches the step, finds the records pending under it, and collects (§5).
If the provider is still processing, the step emits PENDING again and the
run stays pending; run again later — from a shell, a cron, whatever
invokes gtme, since gtme has no daemon and nothing waits (§13). Once every
pending record is answered the terminus asserts and the run finishes
done (or failed). Only after that does a gtme run of the pipeline
source fresh records. gtme runs lists pending runs with their
in-flight count. --simulate runs a deferred step synchronously on the
fixture engine (a rehearsal that ended in flight would rehearse nothing)
and says so; --dry-run on a deferred pipeline is a plan warning — there
is no deliver step to hold back.
Interrupted runs — the run lock (ADR-061)
A process executing a run MUST hold an exclusive advisory lock (flock)
on locks/<run_id>.lock in the ledger file's directory, taken when it
creates or reopens the run and held until the process exits, and MUST
record its pid and host on the run (§3). Liveness is the lock, never
the pid. A running run whose lock can be taken has no living process:
gtme runs shows it as interrupted, derived at read time with no
ledger write (runs.status is not changed), and its receipt ends with
the command that resumes it:
status: interrupted (was pid 4312 on mbp)
resume: gtme run send.yaml --resume 01J…
A running run recorded on another host shows running (on HOST).
--resume MUST take the lock; when the lock is held it refuses with exit
2 — run 01J… is still running (pid 4312 on mbp); wait for it, or stop that process and resume — and touches nothing. --resume of a done
run refuses with exit 2 — run 01J… is done; nothing to resume — unless
the run holds unconfirmed deliveries (§8 deliver idempotency): their
release or settlement, --resume RUN_ID with --resend-unconfirmed or
--settle-unconfirmed (ADR-064), is the one thing a done run can still
do, and without either flag the refusal says so. A
failed, pending or interrupted run resumes. A plain gtme run whose
pipeline's latest run is interrupted says so on stderr with the resume
command and sources anew; it never resumes an interrupted run by itself,
because a crashed deliver step may hold unconfirmed records (§8 deliver
idempotency) that a person should check first. Collect-first for a
pending run (above) is unchanged. Before any run holds them, an
interrupted run's unanswered sends — dispatched events with no later
done or failed — are counted read-only (ADR-064): in gtme runs'
in flight column, and in gtme runs RUN_ID as one line per target:
send: 4 sent to http/deliver with no answer before the run stopped; the resume, or the next run to that target, holds them
People and agents answer — human/*, agent/*, gtme answer (ADR-048, ADR-049)
human/filter, human/compose and human/review (§10) fill the three
roles for a person; agent/* are the same adapters for an agent driving
gtme, which never prompts. Neither opens an adapter session.
At a terminal the run asks. With prompt: tty (the default) and a
TTY, a human/* step walks its records inside gtme run: the rendered
record (template: or render.fields, §9; default the uses: fields
or the of: value),
then the declared outputs as a menu or a field to fill — a filter takes
pass/fail and a reason, a compose the declared fields, a review the
declared labels — validated on the spot; Ctrl-C leaves the rest pending.
Answered records continue to the next stage with the others.
Otherwise it waits in the ledger. With no TTY, prompt: never, or an
agent/* step, every unanswered record ends pending under the
runner-owned token <run-id>/<step-id>, the run finishes pending
exactly as a deferred step does (above), and the receipt says so:
grade: 12 in, 0 out — 12 awaiting human/review; `gtme answer review.yaml` records, the next `gtme run review.yaml` collects
gtme answer is the write path. gtme answer [RUN_ID|last|PIPELINE] [STEP] IDENTITY_KEY --set field=value ... records one participant's
answer for one pending record as an answered step event (§3). 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 makes; STEP MAY
be omitted when one step is pending. The answer is validated against the
step's declared or default outputs (§7): a value outside an enum is
refused naming the allowed values, a filter accepts only
pass=true|false and reason, and a record not pending under that step
is refused. --as NAME names the participant (default the OS user; the
prefix — human/ or agent/ — follows the adapter); --cost USD
records what the participant spent, estimated unless --measured
(ADR-046); --note TEXT is kept in the event and shown by gtme show --provenance, never part of a cache key. With no identity key and a
TTY, gtme answer walks the pending records interactively, exactly as
the in-run walk does. Answers are ledger state — idempotent per (run,
step, identity), the latest before collection wins, readable through
gtme show --run and SQL. gtme answer writes only answered events,
never sends, and MUST NOT appear in gtme freeze output. 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.
gtme run collects answers as it collects batches. When a pending
step is human/* or agent/*, collection reads the answered events
instead of opening a session: each answered record completes the step —
a VERDICT for a filter, fields for a compose or review, provenance
human/<name> or agent/<name> (§10a), the referent when of: was
declared, COST under the run — and continues; unanswered records stay
pending and the run stays pending. Under cron the consequence is the
stage-by-stage model's: a pending run is resumed, not re-sourced, so a
pipeline with a human step waits for its person and sources nothing new
until answered. The documented pattern: the reviewing pipeline is one a
person runs and ends in a group:; the cron pipeline sources from that
group. Under --simulate a human/*/agent/* step is a simulation gap
(below): records pass through untouched and the receipt counts them —
there is no prompt to script and no person to rehearse.
A step that stops (ADR-065)
An adapter reports what an error means with ERROR (§5); the binding
engine reports every errors: verdict it applies the same way (§10a),
so the runner treats both tiers alike:
fail_record: the keyed record fails (failed, outcomefailed, the reason); the session and the step continue.retry: the adapter's attempts for the keyed record ran out; the record fails as forfail_record, with a reason that says so. The runner never re-dispatches a record.skip: the keyed record is countedskippedwith the reason and continues past the step without output, ason_missing: skipdoes (below); a deliver step writes nodeliveriesrow for it.fail_run: the step stops. The runner fails the keyed record, and every record of that session left unanswered, with the reason; opens no further session for the step; lets the sessions already open finish and records what they answer; starts no later step; and ends the runfailed. A record never dispatched gets no event, so it stays where it was:--resumesends it, andonce:(§9) does not count it finished. The step's line counts such recordsN not sentand names the reason, and the step-levelfailedevent (identity_idnull) carriesdetail.stopped = {"verdict": "fail_run", "reason": "...", "not_sent": N}:
send: 314 in, 151 out, 1 failed, 162 not sent — stopped: instantly: the workspace's lead limit is reached …
162 records were not sent. Fix the cause, then: gtme run send.yaml --resume 01J…
A session whose adapter exits 3 (auth), or a binding error that
classifies as auth with no errors: rule for its status, stops the step
exactly as fail_run does, with the error as the reason: every later
session would present the same credential. Any other non-zero exit, or
an unmapped binding error of another class, fails that session's
unanswered records and the step continues, as before. The run's exit
code is the stopping error's class (§8 exit codes): the process
adapter's exit code, or the binding's classified error (§10a, including
a rule's class, or its reclassification by match).
deliver idempotency
Per deliver step: idempotency key = the value of the field named by the
step's idempotency config (default: the identity key). Before calling
the adapter for a record, the runner MUST check deliveries; on hit, it
MUST skip (a skipped_cache event, reason already_delivered; the
receipt counts it already delivered, not cached, ADR-062). On
successful adapter RECORD/END for that record, the runner MUST insert
into deliveries with status = accepted (ADR-036; column lands with
M14). accepted means the provider took the request; sent is written
only when a provider attests it and sent_at carries the provider's own
timestamp — a 2xx is never a delivery. Adapters declaring attests (§6)
refine accepted to confirmed or contradicted per record after a
re-read; inconclusive stays accepted with a receipt warning. Promotion
to sent is the listen verb's job (ROADMAP.md) and MUST be
compare-and-swap on the observed (status, sent_at) pair.
In flight at a crash (ADR-060). An adapter-backed deliver step runs
one record per adapter session, and the runner MUST commit a
dispatched step event for the record before opening its session
(group/deliver has no session and is unaffected). A record with
dispatched and no later done or failed at that step is
unconfirmed: its request may have reached the target. When a run
resumes, or when an interrupted run finishes, the runner MUST NOT send
an unconfirmed record again: it writes the record's deliveries row
with status = unconfirmed, and the check above skips such a row with
reason unconfirmed, in this run and in every later one. The exception
is a target whose manifest declares idempotency: native (§6): the
record is sent again, because the target upserts. Only
--resend-unconfirmed on --resume sends a held record, and the
adapter's answer then replaces the row's status. --settle-unconfirmed
on --resume sends nothing: the operator found the record at the
target, the row's status becomes settled, a settled step event
records it, and the pre-send check treats it as delivered (ADR-064).
Each flag takes an optional comma-separated list of identity keys, as
the receipt names them, and applies to those only; without one it
applies to every record the run holds. A key the run does not hold, or
a key given to both flags, is a validation error (exit 2), and a record
named by neither stays held. The receipt names every held record and
prints the commands:
send: 40 in, 36 out, 4 unconfirmed
4 records may have reached http/deliver before run 01J… stopped and were not sent again.
Check the target, then: gtme run send.yaml --resume 01J… --resend-unconfirmed
(or --settle-unconfirmed for the ones it already has; either takes =KEY,… to name some)
The dedupe key is (target, scope, idempotency) (ADR-044). target is
the adapter id (or group:<name>, ADR-032), so a pipeline delivering to
a campaign and to a CRM dedupes each independently (ADR-031). scope is
the resolved value of the config key the manifest names in
idempotency_scope (§6), '' when it declares none — so the same
record into the same campaign can never double-add, while delivery
into a different campaign is a fresh decision. Because the scope is the
destination's stable identifier (ADR-062), renaming a campaign in the
vendor's UI leaves it the same destination, and a second URL is a new
one. A global "never touch
this address twice through this adapter" is a policy, not a constraint:
declare it as a suppression group (ADR-021), which sees touches across
every adapter.
Re-delivery (ADR-045): a deliver step MAY set redeliver: always | on_change | never (§9). The default is on_change when the manifest
declares idempotency: native (§6) and never otherwise, and always/
on_change are plan errors on a target that did not declare native —
repeat-safety belongs to the adapter, intent to the step. Each delivery
records the hash of its resolved variables: values; under on_change
an already-delivered record re-delivers only when that hash changed
(skip reason unchanged otherwise, distinct from already_delivered in
events and receipts), and a re-delivery updates the row's hash and run
and resets status to accepted for a fresh attestation cycle —
created_at keeps the first delivery.
deliver completeness — on_missing (ADR-019)
Per-record completeness at deliver time is a runtime contract: every
variables: target (§9) MUST resolve to a non-empty value for a record
before that record may deliver — blank merge fields MUST never send. The
policy when one does not resolve is on_missing: skip | fail, declared
per deliver step, default skip:
skip: the record does not deliver at this step (later steps, including later deliver steps, still see it); the runner records a fail verdict for that deliver step inrun_records.verdictswith the missing field names as the reason, and the terminal receipt lists every skipped record with its reason.fail: the record fails (step_events.event='failed', naming the missing fields); the run continues, per §5.
A record missing a floor field (§6 dynamic needs) fails needs validation
as any record would; on_missing governs the variables:-derived fields.
With delivers as ordinary steps (ADR-031), the skip/fail distinction is
also an advancement distinction: skip withholds this step's send but
the record advances (a later deliver step with its own variables: may
still deliver it); fail fails the record (§5 semantics — state
freezes, it does not advance). Deliver-step fail verdicts in
run_records.verdicts record withheld sends; unlike a filter's
pass=false (§7), they do not stop the record.
group/deliver — the handoff is a delivery (ADR-032)
A deliver-role step, runner-owned like the SQL steps (no adapter, no
network), whose target is a group: use: group/deliver, with: {group: <name>}, created on demand. Every deliver-step key applies — variables:
(the receipt renders them per record, which is how a review gate shows a
brief rather than a list of keys), on_missing:, idempotency:,
record:, suppress:, require:/exclude: — and --dry-run withholds
it like any deliver. Committing a record to a downstream stage authorises
downstream spend, which is why it takes the send's gate. A pipeline MAY
carry several, routing different when: outcomes to different groups. A
group with no consumer pipeline is a hold; release is gtme groups add;
review is gtme groups show. It is a write, not a trigger: nothing runs
the consumer, which pulls on its own schedule. One commit point per
pipeline (§7): a group/deliver and a network-side deliver do not share
a pipeline.
The group source (§9) takes limit: N: members served in group_events
insertion order, oldest first. Ranked serve order is deliberately not
provided; an upstream sql/filter narrows, limit: bounds.
Deliver preflight — the target is checked before anything sends (ADR-040)
gtme plan proves gtme's contracts with zero network; it cannot know the
target's state. For a deliver step whose adapter declares preflights
(§6), the runner opens a preflight session (§5) before any record
session — at --dry-run and at the start of an armed run — and reads
the answer: ok proceeds; inconclusive proceeds with a receipt
warning; blocked 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:
send: preflight ok — campaign "Q3 VP Marketing" (0198a0b1-2c3d-4e5f-8a9b-0c1d2e3f4a5b) — 4 checks (campaign active, 3 sequence steps, every variable referenced, no unfilled variants)
send: preflight BLOCKED — sequence step 2 does not reference {{body_step_2}}
When the PREFLIGHT answer carries destination (§5, ADR-062), the line
names it, so a step configured by id still shows the operator which
campaign it is.
This is the class of failure attestation cannot see: every request
succeeds and nothing meaningful sends. plan stays zero-network; under
--simulate a stubbed adapter's preflight is part of the counted gap.
Dry-run and the armed gate (ADR-019)
gtme run --dry-run executes the pipeline normally except deliver
steps: for each record reaching a deliver step, the runner resolves the
step's variables: per record, applies the on_missing policy, and
records step_events.event='dry_run' with the fully RESOLVED variable
values in detail — but MUST NOT invoke the deliver adapter and MUST NOT
write to deliveries. The terminal receipt renders each record's resolved
variables per deliver step: this is the approval artifact a human reviews
before arming. Arming is all-or-nothing — the armed run arms every
deliver step in the pipeline (ADR-031).
Non-deliver steps run normally under --dry-run — delivery is the gated
destructive edge (§0 principle 9); everything upstream is replayable and
cache-covered, and its spend is already visible in gtme plan. Arming is
the same command without the flag; a dry run is an ordinary run in every
other respect (its own run id, receipt, and gtme runs entry), and because
it writes no deliveries, the armed run's idempotency behaves as if the dry
run had never happened. Its run row carries dry = 1 (§3, ADR-052 (7)) so
that anything reading run history for what a pipeline has finished — a
once: group source — leaves rehearsals out; gtme runs marks the entry.
Groups: touch scoping, suppression, terminus, and gtme groups (ADR-021)
Groups are runner-owned semantics: adapters see only projections, never the ledger, and nothing below changes the wire protocol.
Touch scoping — record:. On a successful delivery, the runner MUST
append a touched event to the group named by that deliver step's
record: — defaulting to the pipeline name — creating the group on
demand. Every pipeline is thereby safely scoped by default; sharing a
scope across pipelines is an explicit override. Multiple deliver steps
sharing the default share the scope — the correct reading of "this
pipeline touched them" — and distinct scopes are explicit per-step
record: overrides (ADR-031). The event's detail carries the target
adapter and run id.
Suppression — suppress: {group: G, within: Nd}. Before delivering
a record at a deliver step carrying the key, the runner MUST skip it
when the record has a touched event in G within the window. A
suppressed record records a fail verdict for that deliver step with
reason suppressed (the on_missing pattern — and, like on_missing: skip, the record advances: suppression gates this step's send, not the
record), and the terminal receipt lists every suppressed record with the
group and the age of the blocking touch. Suppression layers above the §8
idempotency floor: idempotency stops the same delivery twice (or, on a
natively idempotent target under redeliver: on_change, the same
delivery with the same values twice, ADR-045);
suppression enforces a chosen contact policy across deliveries.
Terminus — top-level group: <name>. A pipeline MAY end in group
membership instead of (or in addition to) deliver steps: every record
that completes the run's final step is added to the named group
(created on demand), with the pipeline and run id as provenance. The
terminus captures completers, not sends (ADR-031): a record that
delivered at a mid-pipeline deliver step and then failed a later step
has delivered but does not join, and a record whose send was withheld
(on_missing skip, suppression) but that completed the run does join —
record:'s touched events, not the terminus, are what remember actual
sends. 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.
Dry runs assert nothing durable: under --dry-run (and --simulate)
the runner MUST NOT write touched events or terminus added events —
the receipt reports what an armed run would have recorded. A rehearsal
that mutated durable associations would not be a rehearsal.
gtme groups verbs:
gtme groups # list groups with type, derived character
gtme groups show NAME # members (current), recent events, producers and consumers
gtme groups add NAME KEY... # hand-edit membership by identity key
gtme groups add NAME --from-segment SEGMENT | --query "SQL"
gtme groups add NAME --type TYPE # set a new or untyped group's entity type (ADR-054)
gtme groups remove NAME KEY... [--note TEXT]
gtme groups lists each group with its entity type (ADR-054), member
count and event tallies (added/removed/touched) — character is derived,
never stored; the type is stored, set once at creation. gtme groups show additionally prints which pipelines wrote to the group and which
sourced from it, derived from group_events.run_id and runs — the
chain a group sits in, visible without a workflow file. A group's type
is set when it is created: by a terminus or group/deliver from the
run's type, by add from an unambiguous key match or from the query's
identities, or by --type, which is REQUIRED when the key is ambiguous
or no key is given; adding an identity of another type is refused naming
both types. A group created before ADR-054 has no type until --type
sets it, and until then a pipeline sourcing from it is entity-blind. The
snapshot forms evaluate an intensional definition into extensional
membership: --from-segment runs a saved segment, --query a one-off
SELECT; either MUST yield an identity_id column (segments-as-SQL
naturally join identities), and each added event's detail records
what was evaluated and when. A KEY is matched against
identities.identity_key; if it matches more than one entity type, the
command fails asking for --type. All writes are events — membership
edits are append-only like everything else. --note (ADR-032) records
the reason for a removal in the event's detail — the reject-with-reason
a review gate needs.
Simulation gate — gtme run --simulate (ADR-028; built in M8)
gtme run --simulate executes the ENTIRE pipeline offline: every binding
(§10a) is served from its conformance fixtures, and every process/AI step
is either fixture-served or stubbed — an AI step replays a recorded
fixture response when one exists, else emits a synthetic verdict marked
as synthetic. A simulated run MUST perform zero network calls, zero
spend, and zero sends, and MUST be deterministic. Its output is a full
receipt marked SIMULATED (the receipt schema gains a simulated
flag), and a simulated run MUST NOT contribute to the durable identity
layer: its writes are excluded from projection and cache (whether by
ephemerality or by flagging is an implementation decision, recorded in
DECISIONS.md at build time). With this the gate ladder is complete:
simulate → plan → dry-run → armed — behavior offline, then contracts,
then live-reads with delivery withheld, then live. The banner and the
receipt title say recorded responses only (ADR-058); fixture is the
adapter author's word and stays on the adapters verbs and in help --bindings. An agent that authors
a pipeline can fully validate it (structure via plan, behavior via
simulate) before a human reviews anything. A binding without fixtures
MUST surface in the simulated receipt as a simulation gap, not silently
pass. Acceptance criterion: the campaign-zero pipeline simulates
end-to-end with zero network calls.
Campaign bundles — gtme freeze --bundle (ADR-029; built in M10)
gtme freeze --bundle DIR produces a campaign bundle: a directory (or
tarball) containing the pipeline YAML, every referenced binding at its
exact version, every template: {file:} (ADR-057; bare gtme freeze
inlines them instead), saved queries, the relevant registry
slice, and a manifest — bundle format version, content hashes, source run
id — per spec/bundle-manifest.json. (Bare gtme freeze keeps its
existing job: the reconstructed pipeline.yaml on stdout.) Guarantees:
(a) self-contained — gtme run <bundle-path> resolves nothing outside
the bundle except credentials; (b) diffable — text files, stable
ordering; (c) portable — the same bundle runs on any machine/ledger;
membership and cache naturally differ, contracts don't. gtme run MUST
accept a bundle path wherever it accepts a pipeline path, and
--simulate MUST work on a bundle using fixtures included in it, making
a bundle a fully offline-verifiable artifact. Group references in a
bundled pipeline (ADR-021) are names resolved against the target ledger —
membership travels with the ledger, not the bundle — and plan's
referenced-groups-exist check applies, so a bundle moved to a clean
ledger fails loudly at plan rather than running ungated. Acceptance
criterion: freeze campaign zero, move the bundle to a clean ledger,
simulate and dry-run it successfully.
9. pipeline.yaml — DECIDED
name: apollo-to-instantly
version: 1
source:
use: apollo/search
with:
query: "vp marketing, saas, 50-200 employees"
limit: 500
steps:
- id: icp-filter
use: ai/filter
uses: [first_name, title, company_name] # ADR-004; masked fields only — free (ADR-043)
with:
template: > # ADR-057: string or {file: path}
Keep only contacts likely to own outbound tooling decisions.
batch_size: 25
- id: reveal # ADR-043: pay Apollo's per-credit match only past the filter
use: apollo/enrich
when: icp-filter.passed
cache: 30d
- id: linkedin
use: harvest/profile
when: icp-filter.passed
cache: 30d
- id: posts # ADR-059: posts are their own call, asked for by name
use: harvest/recent-posts
when: icp-filter.passed
cache: 30d
- id: personalize
use: ai/compose
uses: [recent_posts, role_history]
with:
template: >
Write first_line and ps_line using recent_posts and role_history.
batch_size: 25
- id: subject # ADR-057: a template renders a field, no model
use: text/compose
uses: [first_name, company_name]
provides: [subject]
with:
template: "{{ record.first_name | default: 'there' }}, a note for {{ record.company_name }}"
- id: send # ADR-031: a deliver adapter is an ordinary step
use: instantly/add-to-campaign # gtme adapters add instantly/add-to-campaign (ADR-063)
with:
campaign: 0198a0b1-2c3d-4e5f-8a9b-0c1d2e3f4a5b # the campaign id (ADR-062); preflight prints its name
variables: # ADR-018/019: egress mapping, and the step's dynamic needs
first_line: first_line
ps_line: ps_line
subject: subject
idempotency: email
Schema rules: deferred: true inside an AI step's with: sends its batch
to the provider's batch surface and ends the run in flight (§8, ADR-038);
it is adapter config, not grammar, and valid only on the last step.
redeliver: always | on_change | never on a deliver step (ADR-045) sets
its repeat policy — defaulting per the adapter's declared idempotency
(§8), and refusing always/on_change on a target that is not natively
idempotent. respend: true on a step (grammar, any paid step) declares that re-running
the pipeline MAY pay for that step's records again — it silences the §7
respend warning and nothing else. when: supports only <step_id>.passed in v0, naming a step in the
same segment (ADR-054, §7). cache: takes
Nd. A traverse step (ADR-054) is an ordinary steps: entry whose
adapter role is traverse (§6): it MAY carry limit: N (engine-owned,
as on a source), when:, require:/exclude:, and cache:; after it,
every later step is validated against its output type (§7), and the
pipeline's terminus group takes that type. use: sql/traverse (§10a) is
the runner-owned form: with: {entity_type: <type>, query: <SQL>}, the
query yielding identity_id and parent_id. uses: (ADR-004) is a list of field names, valid only on steps whose
adapter role is filter/compose/review (the participant roles,
ADR-048: ai/*, human/*, agent/*, and text/*, ADR-057); the
planner validates it exactly as needs.required (§7). provides: (ADR-033) is likewise valid only on
those roles: the step's declared output fields, a list of names or a map
of name → {type, enum, canonical} (§7); the planner rejects it
elsewhere. of: <field> (ADR-048) is valid on compose and review steps
and required on a review — the value the step is about, validated as
uses:, recorded as the referent of everything the step writes. Every
participant step MAY carry template: in with: (ADR-057): a string or
{file: <path>}, the path relative to the pipeline file, in the bounded
Liquid dialect of §10 item 10; config.* names the step's own with:
keys, record.* is valid only on per-record steps (text/*, human/*,
agent/*) and only for uses:/of: fields (§7); with.prompt on an
ai/* step is a plan error naming template:. A human/* step MAY
carry render: {fields: [..]} and prompt: tty | never (default tty)
in with: (§8, ADR-049); an agent/* step never prompts. engine: is not a key (ADR-050): its
presence is a plan error. Deliver adapters are ordinary
steps: entries (ADR-031): a pipeline MAY carry zero, one, or many, at
any position — steps execute strictly in order, so a deliver step sends
exactly the records that survived everything before it. variables:
(ADR-018/019) is valid only on steps whose adapter role is deliver: a
map of target merge-field name → canonical
or namespaced ledger field; its values are the step's dynamic needs (§6,
§7), and the mapping is the egress half of ADR-018 — the only place the
target's foreign vocabulary appears. The runner hands the mapping to the
deliver adapter as variables in OPEN config (§5) — the adapter owns
applying it; the runner owns projecting and completeness-checking the
fields it references (§8). on_missing: skip | fail (default
skip) and idempotency: are likewise valid only on deliver steps
(§8); the planner MUST reject any of these keys elsewhere, exactly as it
rejects uses: outside filter/compose. The ingress half is
columns: inside csv/source's with: (§10.1): a map of canonical field
name → CSV header as written. Both mapping keys read
destination-vocabulary → source-vocabulary. No interior step may carry a
mapping block (ADR-018): declarative mappings at the two edges are
plan-validatable; the interior speaks only canonical names. Steps execute
strictly in order; within
a step, records process with a worker pool (default concurrency 4,
GTME_CONCURRENCY to override), except AI steps which process in batches of
batch_size (default 25) — one adapter invocation per batch. waterfall:
is reserved syntax: the YAML parser MUST accept and reject it with "not
implemented in v0" (so v1 adds it without a format break). This is the only
pipeline authoring surface in v0 (ADR-005); the JSON Schema for this format
is spec/schemas/pipeline.schema.json.
Groups in pipeline YAML (ADR-021). Five keys, all plan-validated (§7), all runner-owned (§8):
name: q3-qualify
source:
group: q3-warm # group as source: members projected from the ledger
steps:
- id: judge
use: ai/filter
exclude: [q3-qualified, q3-rejected] # judgment memory: judge once per scope
uses: [full_name, title]
with: { template: ... }
group: q3-qualified # terminus: records completing the run are added
# …and on a send pipeline's deliver step:
# record: q3-sent # touch scope; defaults to pipeline name
# suppress: { group: q3-sent, within: 30d }
- A group source is
source: {group: <name>}— nouse:, mutually exclusive with it. Members are projected from the ledger like any record, and the pipeline takes the group's entity type (ADR-054, §3), so every step's field names are validated against it; a group with no type (created before ADR-054) leaves the plan entity-blind, and plan says so. A group source declares no static provides: the plan treats the available-field set as open, and each step's needs are enforced per record at run time, exactly like the needs-all wildcard. require: [<group>, …]/exclude: [<group>, …]are valid on any non-source step, deliver steps included: membership gates, checked per record against current membership (§7).record: <name>andsuppress: {group: <name>, within: Nd}are valid only on deliver steps (§8).record:defaults to the pipeline name, per deliver step (ADR-031: steps sharing the default share the scope).- Top-level
group: <name>is the membership terminus (§8), valid with or without deliver steps; a pipeline with neither simply enriches. - A group source MAY carry
limit: N(ADR-032, §8): at most N members, oldest-added first.use: group/deliverwithwith: {group: <name>}is a deliver step targeting a group (§8). - A group source MAY carry
once: true(ADR-052, §8): select only members this pipeline has not already finished, so a bounded consumer advances instead of replaying its first batch. Opt-in; a source without it is unchanged.
Bounded group consumers — once: (ADR-052). A group source with
once: true selects, among the group's current members, only those this
pipeline has not finished — oldest-added first, at most limit when
one is set. A record is finished when it completed the pipeline's final
step, or when a filter's fail verdict stopped it; both are outcomes the
pipeline reached deliberately. A record that failed a step is NOT
finished and MUST be selected again, so a transient provider error is
retried rather than silently dropping the record; a record left pending
is NOT finished either, and collect-first (above) resumes its run rather
than re-sourcing it. The scope is the pipeline's name. once: changes
what a source selects and never what a group contains: no membership is
added, removed or touched by it, so the group remains the reviewed
decision ADR-021 made it and an operator MAY re-run the whole set by
removing the key. gtme plan MUST print the eligible count beside the
selection, since how much work remains is the fact a scheduled run turns
on and it is knowable before anything is spent:
source group "candidates" — 481 member(s), 471 not yet worked, sourcing 10 (oldest first)
A dry or simulated run finishes nothing: a simulated run executes against
a throwaway copy of the ledger, and a dry run's row is marked dry (§3)
and left out of terminality. A rehearsal therefore repeats rather than
advancing the queue.
Config values from the ledger (ADR-037). Any value under with: MAY
be {query: <SQL>} or {segment: <name>}; the planner resolves it
against the local ledger before config validation, shows the rows, and
fails on zero (§7). This is how a pipeline's source is parameterised by
what an earlier pipeline decided — fan-out at the pipeline boundary,
where run membership is fresh:
source:
use: apollo/search
with:
domains: {segment: qualified-domains} # a saved SELECT yielding one column
titles: [CFO, "Head of RevOps"]
Read a segment when the list is a live computed fact that should
drift; read a group (snapshot first with gtme groups add --from-segment) when it is a reviewed decision that should not.
10. v0 adapters — DECIDED
The v0 set. Floor entries are built in: Go process adapters under
internal/adapters/<name>/ (embedded manifest + a fixtures/ dir + unit
tests that run offline against fixtures) or runner-owned steps. Vendor
entries are registry entries (ADR-059): bindings in the gtme-bindings
repository, verified tier, installed with gtme adapters add <id> (§8),
their contracts stated here because the canonical pipeline (§9) uses
them. Item 6 is a registry process entry (ADR-063): a Go process
adapter, prebuilt per platform, installed the same way.
csv/source— reads a CSV path from config; header row → field names;email/linkedin_url/name/company_domaincolumns feed identity keys. (Exists so everything is testable with zero API keys.) Ingress mapping (ADR-018): configcolumns:maps canonical field names → CSV headers as written. Headers already matching canonical names (after header normalization: lowercase, separators → underscores) auto-map with zero config; near-misses are SUGGESTED at plan time, never guessed (§7). Unmapped, non-canonical headers are namespacedcsv.<normalized_header>(§4a tier 3) — kept, queryable, visibly non-canonical. Mapped canonical values are normalized per the registry at ingress; a value that fails its rule never crashes the run and never reaches the ledger — the field is dropped from that record with the reason recorded instep_events, and the record then fails only if what remains cannot satisfy identity derivation or a downstream need.apollo/search(source, person) — POSTapi.apollo.io/api/v1/mixed_people/api_searchwithX-Api-Key(APOLLO_API_KEY); config:query,limit,per_page. Masked by the vendor (ADR-043; Apollo withdrew value fields from API search 2026-08-30): providesapollo.id,first_name,title,company_name,last_name(the vendor's obfuscated form, e.g. "D." — build-found in M20: it keys the §4 name-hash identity tier, and the reveal supersedes it), andapollo.has_email(the pay-signal — a filter can prefer reachable contacts before anything is spent). Pages bypage; termination on empty/short pages (the response carriestotal_entriesand no pagination object). -e per record. The revealed person isapollo/enrich's job, andworks_atemission (runner-owned, keyed on org domain) fires after it — search alone carries no domain. A binding since v0.11 (rewritten against the masked shape in M20); a registry entry since M33 (ADR-059). 2a.apollo/enrich(enrich, person) — POSTapi.apollo.io/api/v1/people/matchwithX-Api-Key(APOLLO_API_KEY);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; retains payloads (ADR-030). The canonical composition (§9): filter on the masked fields first, revealwhen: <filter>.passed— credits are spent only past judgment. A binding since M20; a registry entry since M33 (ADR-059).ai/filter(filter) — batch records into the prompt with a strict JSON-array output schema[{identity_key, pass, reason}]; emit VERDICTs; config supportsuses:(§9, ADR-004); MAY declareprovides:(ADR-033), in which case the required output shape is generated from the declared schema and the step also emits RECORDs (§5). Prompt assembly (ADR-035): records encoded compactly, never pretty-printed; long values wrapped at structural breaks so no line exceeds what the engine's tooling reads intact; fields whose provenance is an external fetch wrapped in a delimiter and labelled in-band as subject-supplied data, the delimiter neutralised inside the body before wrapping (encode → neutralise → wrap) — default on,fence: falsein config opts out; the operator prompt (the step'stemplate:rendered overconfig.*, ADR-057) precedes the records as a stated default, with the shared/payload split exposed so the order is A/B-able and a cache breakpoint can sit between them. AI steps hold no tools. The manifest is entity-agnostic ("entity_type": "*", §6): the step's entity type is the pipeline's.deferred: true(ADR-038) routes the batch to the Message Batches API undercustom_id= identity key and ends the session with PENDING (§5, §8); the fixture engine answers synchronously.of:(ADR-048) names a value the step is about. 3a.ai/review(rolereview, ADR-048) — same adapter code as item 3 under the review role:of:required, the prompt presents that value as the subject and theuses:fields as context, the output is the declared labels (a grade, a yes/no, notes); emits RECORDs only, never a VERDICT — a review does not gate. Prompt assembly and entity-agnosticism as item 3. 3b.human/filter,human/compose,human/reviewand theiragent/*aliases (ADR-049) — runner-owned; no protocol session, no credentials, no cost of their own. Config:template:orrender.fields, andprompt: tty | never(§9, ADR-057); the declared outputs are the menu. Behaviour in §8 ("People and agents answer").agent/*never prompts and records provenance underagent/.harvest/profile(enrich, person; version 2, a registry entry since M33, ADR-059) — HarvestAPI LinkedIn profile lookup (HARVEST_API_KEY), one call per record, by any one LinkedIn URL shape: needs are one-oflinkedin_url|linkedin_internal_url|linkedin_sales_nav_url(§7 one-of needs), preferring the public form when several are present. Providesheadline,about,location,current_role,current_company,follower_count,open_to_work,role_history(one line per position,<role> at <company> (<start>–<end>), rendered by theeach:extraction form, §10a) — and, when the lookup started from a non-public shape, the resolved publiclinkedin_url, which is ADR-020's recovery path (the key upgrade to the slug tier follows automatically, §4). Cost per record from configcost_per_profile_usd(ADR-046 template, default0.012, basisestimated). Version 1, the Go adapter, also fetched posts; that is item 4a now. 4a.harvest/recent-posts(enrich, person; a registry entry, ADR-059) — the person's recent LinkedIn posts from HarvestAPI's profile-posts endpoint, one call per record.needs.required: [linkedin_url]; providesrecent_posts(array of strings, each a post's text, at most configposts_limit, default 3, viaeach:; notlimit, which ADR-047 reserves for sources). A person's field for a compose to read, distinct from theharvest/profile-poststraverse, which mintspostrecords (§4a). Cost per record from config (ADR-046 template), its default set from HarvestAPI's published price when M33 is built.ai/compose(compose) — batch; providesfirst_line,ps_line(strings) by default, or whatever the step'sprovides:declares (ADR-033); output schema enforced; config supportsuses:andof:(a revision of an existing value is a compose with a referent, ADR-048); prompt assembly and entity-agnosticism as item 3.instantly/add-to-campaign(deliver, person; version 2;idempotency_scope: campaign, ADR-044 —campaignis the campaign's id, a lowercase UUID, so a campaign renamed in Instantly stays the same destination, ADR-062; a verified registry process entry built fromcmd/gtme-instantly, ADR-063) — Instantly v2 API,Authorization: Bearer $INSTANTLY_API_KEY: create/attach lead to the campaign by id. Any othercampaignvalue is a plan error that says the adapter takes the id and where to find it; plan does not look a name up.skip_if_in_campaign(defaulttrue) is sent with every lead; Instantly documents it as skipping a lead already in the campaign. Declares dynamic needs (§6, ADR-019) with a static floor ofemail; everything else it sends derives from the step'svariables:mapping (ADR-018) — a target name naming one of Instantly's standard lead fields maps into the lead body, and any other target name becomes a custom variable of that name (ADR-066). The standard lead fields are the ones Instantly's create-lead API documents:first_name,last_name,company_name,job_title,personalization,website,phone. A target may name one in snake_case or in the camelCase Instantly's sequence tags use (firstName,lastName,companyName,jobTitle; the other three are one word either way), and both spellings fill the same lead field. Two targets naming the same lead field is a contract error: the adapter refuses the session (exit 2) before sending anything. No merge field is hard-coded in the adapter. Declarespreflights(ADR-040): before sending, it checks that the campaign exists and is Active, that the sequence has at least as many steps as the copy assumes (the highest_step_Nsuffix among thevariables:targets), that every custom-variable target appears as{{name}}in some step, that no A/B variant lacks one, and, the other way round, that every lead-field tag the sequence uses ({{firstName}},{{lastName}},{{companyName}},{{personalization}},{{website}},{{phone}}) has avariables:target filling that field — a tag nothing fills is blocked, since every lead would send with a hole where it stands (ADR-066). Its PREFLIGHT answer carriesdestinationas the campaign's name and id (§5, ADR-062). When Instantly refuses a lead because the workspace's plan lead limit is reached (a 403 whose body saysLead limit reached), the adapter emits ERROR withfail_runand a reason that says to upgrade the plan or delete leads, then exits 1 (§5, §8 "A step that stops", ADR-065); any other 403 is auth (exit 3).mock-enrich-py(external, Python 3 stdlib only) — reads protocol from stdin, adds fieldsmock.score(derived deterministically from the identity key) andmock.note, emits COST 0. Proves the external adapter path. (Item 8,webhook/source, was specified here from ADR-009 and never built; ADR-055 defers it to ROADMAP.md. The event recipe is in §8.)demo/enrich(enrich, person; ADR-056; built in M29) — the priced, keyless enrichment. Needsemailorfull_name; providesdemo.score(integer 0–100, derived from the identity key's hash) anddemo.note(the fixed stringsynthetic — demo/enrich called no vendor). Deterministic; no network, no credential, no payload; runs armed, and under--simulateruns exactly as armed — never a simulation gap. Configcost_per_record_usd(default0.01, basisestimated);freshness_days30 in the manifest, socache:per step is the override, as for every adapter. Cost rows land underdemo/enrich@1;gtme planprintsest/record: $0.0100. Never in the registry index; thedemo/prefix is reserved. Exists so the zero-key path prints the top-up receipt —cached,avoided— on a persisting ledger (examples/hello.yaml, ADR-058), which--simulatecannot (ADR-028).text/compose(compose, entity-agnostic; ADR-057; built in M30) — runner-owned, the sibling ofhuman/compose: no subprocess, no session, no credential, no cost. Renderstemplate:once per record overconfig.*andrecord.*(theuses:andof:fields) and writes the result as the one fieldprovides:declares —provides:is required and MUST name exactly one field; a render that is empty after trimming writes nothing and the record continues. Emits RECORDs only, never a VERDICT: a template shapes text and gates nothing. Runs identically armed and under--simulate; never a simulation gap. A field it writes counts as externally fetched for item 3's fence when any field ituses:does. The dialect, shared by everytemplate:(ADR-057): Liquid objects{{ … }}; tagsif/elsif/else/unless/case/when,for(withlimit,offset,reversed),comment,raw; filtersdefault,truncate,truncatewords,size,first,last,join,upcase,downcase,capitalize,strip,date. Nothing else — noassign/capture/increment, noinclude/render— and an unknown tag or filter is a plan error (§7).
For Apollo/Harvest/Instantly: implement against their current public docs (fetch docs at build time via web access if available; otherwise implement from the fixture shapes and mark the HTTP layer clearly in one file per adapter so field mappings are trivially correctable). Every HTTP call MUST be behind an interface so fixture tests never touch the network.
10a. The binding tier & universal steps — DECIDED (ADR-022..027)
The binding tier, the conformance-kit extension, the naming rule with its
ai/* provenance format, and the OpenAPI rule below were built in milestone
M8 (§11, changelog v0.6); http/enrich and sql/transform/sql/filter in
milestone M11 (changelog v0.9), together with ADR-030's payload retention.
Two-tier adapters (ADR-022)
The adapter model is two-tier. Tier 1 — bindings: a binding is a YAML
document conforming to spec/binding-schema.json, interpreted
deterministically by one generic HTTP execution engine in the runner. All
judgment is frozen at authoring time, never exercised per call. The
schema is kept to ~8 primitives: auth (type, header/param name, env var
ref); a request template (method, URL, body AND query-param templating
from config + canonical fields); pagination (strategy page|cursor|offset,
termination, max); extraction (records JSONPath plus per-field
response→canonical paths, with a transform: hook restricted to registry
normalization rules — never arbitrary logic — and one list form, each:
(ADR-059): {each: <dotted path to an array>, template: <ADR-057 dialect over item.*>, limit: <n | config reference>} renders the
template once per element, drops a render that is empty after trimming,
stops at limit, and provides an array of strings; unlike a request
leaf it may use the dialect's tags, because it renders text, not a typed
value); error→verdict mapping (errors:, defined below, ADR-065); an
idempotency declaration native | ledger (which party guarantees dedupe:
Attio assert = native; Instantly = ledger via the deliveries table); a
cost declaration (per record / per request / unit — amount_usd a
number, or a template resolved from config for vendors whose price is
plan-dependent, ADR-046; a page-billed endpoint declares per: request,
because per: record counts emitted records and a limit truncates
emission after the vendor has billed the page); and a retry/rate
policy including hourly windows, with an optional session declaration
(a UUID-per-run passed through, for vendors offering
pagination-consistency sessions). Binding roles are source (pagination +
cursor/STATE; limit is a reserved engine key, ADR-047 — config
validation accepts it whether or not the binding's config_schema
declares it, the engine caps emitted records and terminates pagination
at the cap, and a binding that does declare it receives it unchanged),
traverse (ADR-054: a source-shaped binding — pagination, limit,
extraction to records of its entity_type — whose request is templated
per parent from {{record.<field>}} placeholders that are also its
needs, and which declares from and relation as §6 requires; a
binding MAY ship the type file it emits, types/<name>.json, §4a),
enrich (per-record request), and deliver (idempotency + dry-run
receipts). A binding declares the same manifest surface as a
process adapter (needs/provides/config_schema/freshness_days, §6)
so gtme plan treats both tiers identically; named external bindings are
discovered on the §6 path (~/.gtme/adapters/<name>/ containing
binding.yaml instead of an executable), and reach that path by hand or
from the registry (§8 gtme adapters, ADR-042) — the binary ships the
floor and no vendor (ADR-059, ADR-063); vendor adapters are registry
entries, verified before they install. spec/bindings/ keeps one binding
as the worked example gtme help --bindings prints, registered as no
adapter. gtme help --bindings (§8,
ADR-041) is the contract an author works from.
Error verdicts (ADR-065). errors: maps an HTTP status, or a class
such as 4xx, to one rule or to a list of rules. A rule is {verdict, reason?, match?, class?}, with verdict one of fail_record, fail_run,
retry or skip. match is a literal substring of the raw response
body: no pattern syntax, no path, no operator. The rules for a status
are tried in order, and the first whose match occurs in the body, or
that has no match, applies; a rule after one with no match can never
apply and fails validation. A status with no applicable rule keeps the
engine's default classification (§8 exit codes). The engine reports the
verdict it applied as ERROR (§5), and the runner acts on it as §8 "A
step that stops" says, so fail_run stops the step in both tiers.
retry first retries the status under the binding's retry policy, even
a status the engine would not retry by default, and reports retry
only when the attempts run out. skip and fail_record do not retry
beyond the engine's default (429, network failures and 5xx). A rule
MAY name the error's class: auth, rate_limit, network or
provider (§8 exit codes 3, 4, 5, 1). A rule that applied because of
its match and names no class reclassifies the error as provider
(exit 1): the binding has said that this status, with this body, does
not carry its generic meaning. A rule without match or class keeps
the classified error's class. class: auth is how a binding says a
body means a bad credential when the vendor answers with a status that
does not (a 400 or 422 reading "invalid API key"): the run exits 3 and
the step stops as an auth failure does. For example, a destination that answers both
a revoked key and a full plan with 403:
errors:
"403":
- match: "Lead limit reached"
verdict: fail_run
reason: "the workspace's lead limit is reached: upgrade the plan or delete leads, then resume"
- verdict: fail_run
reason: "the API key was rejected"
Tier 2 — process adapters: the §5/§6 NDJSON contract, unchanged. Graduation rule (hard): the moment a binding needs logic — conditionals, expressions, multi-call workflows, OAuth dances, request signing, computation — it graduates to a process adapter. No expression language may ever grow inside binding YAML. Bindings cover anything that sells an API; process adapters cover anything that must be fought for (a managed provider absorbing the fight — HarvestAPI over scraping — is tier 1; only DIY scraping is tier 2). Graduating changes the tier, not the distribution: a verified process adapter can still be a registry entry (§8, ADR-063).
Engine unification: inline http/* steps are the binding engine
invoked anonymously with config carried in the pipeline YAML; 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.
Conformance: the §4a conformance kit extends to bindings: every
binding MUST ship golden fixture payloads and a conformance test
asserting fixtures in → canonical, registry-valid records out. These
fixtures are also what --simulate (§8) executes against.
Security invariant: bindings MUST NOT be able to execute code; a binding's blast radius is exactly what the engine permits. This is what makes third-party bindings reviewable, diffable data (see ROADMAP.md on the marketplace framing).
OpenAPI is codegen input, never runtime input (ADR-025)
A runtime OpenAPI-driven generic adapter MUST NOT be built: an API spec describes endpoints (syntax), while an adapter encodes operation selection, idempotency keys, verdicts, and canonical mapping (semantics no spec contains) — and per-call model judgment would mean per-row cost, nondeterminism where money moves, and records in model context, violating the ledger-as-bus. OpenAPI's place is bind time: the adapter-authoring skill's happy path is paste an OpenAPI URL → a model proposes a binding (operation, mapping, idempotency, pagination) → conformance tests pass → the adapter exists.
Adapter naming — the contract owner names the adapter (ADR-026)
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); the model provider is an interchangeable engine → operation-named,
provider is config. When a provider capability leaks into the contract
(a response format that IS the product), it takes the vendor name — the
same canonical-when-shared, namespaced-when-proprietary logic as fields
(§4a). Provenance carries the engine and the question: for ai/* steps,
field_values.source MUST record the model identifier and the judgment
signature (§7, ADR-039) in the form ai/compose @ <model-id>#<signature>
(e.g. ai/compose @ claude-sonnet-4-6#1a2b3c4d5e6f), so two prompts'
outputs are distinguishable in provenance, and COST attributes spend per
model. A human/* or agent/* step (ADR-049) takes the same form with
the participant in the model's place — human/review @ <participant>#<sig>,
agent/filter @ claude-code#<sig> — the signature over the step
declaration alone (adapter id, template:/render.fields, the declared
outputs, uses:, of:), never the name: the cache is checked at
dispatch, before anyone has answered. A text/compose step (ADR-057)
has nothing in the engine's place: text/compose @ #<sig>. The done step event for an AI judgment carries signature and
input in its detail (§3) — the cache entry the runner reads back.
http/enrich — generic fetch enricher (ADR-024; built in M11)
Per-record HTTP request templated from canonical fields; two modes:
(a) JSON extraction — the binding engine's enrich role invoked inline;
(b) markdown: true — fetch a page, convert to markdown, store it as a
ledger field (e.g. homepage_markdown) whose name the step declares in
config (dynamic provides, §7). Fetched content fields are facts with
provenance; the step MUST declare freshness_days (web content rots — a
missing value is a plan error, no default), and the engine MUST enforce a
response size cap (default an implementation decision recorded at build
time). Division of labor: http/enrich does deterministic acquisition;
ai/* steps judge the stored content via uses: — fetch-once economics
means N AI steps across M runs reuse one fetch, and receipts show exactly
what content was judged. Scope stated honestly: no-JS fetching only;
JS-heavy pages route to a reader-provider binding (see ROADMAP.md).
Contract as built (M11): config carries url (templated from
{{record.<field>}} placeholders, which are also the step's derived
dynamic needs), optional method/query/headers/auth, and exactly
one of markdown: true + field: <name> (the declared content field —
canonical or namespaced per §4a) or extract: {<field>: <path>, …} (the
engine's inline JSON mode). freshness_days is REQUIRED config with no
default and doubles as the step's cache window, so N AI steps across M
runs reuse one fetch. The engine-enforced size cap defaults to 256 KB
(max_bytes overrides); an oversized response is dropped with a warning,
never truncated silently — the record advances counted empty, not out
(ADR-053), and the oversized response is still retained as a payload for
the step's declared window, because a response the run threw away is
precisely the one an operator needs to look at. Fetched responses attach
as payloads (§5) under the ADR-030 declaration. Under --simulate the step is a counted
simulation gap — replaying retained payloads is the ROADMAP
simulate-replay verb, not this build.
ai/* purity invariant: an AI step's inputs are exactly its
projected fields, and its only network access is its model engine's API
(§2). An ai/* step MUST NOT fetch external content — acquisition
belongs to http/enrich and bindings, which are deterministic and
cacheable.
sql/transform and sql/filter — the deterministic transform floor (ADR-027; built in M11; sql/enrich renamed sql/transform by ADR-037)
sql/transform: a single SELECT over the projection view (plus relations),
scoped to the run's records, executed read-only (mode=ro) and
timeboxed, with no side effects. Result columns become field values
appended by the ENGINE like any adapter output — the step never writes
storage directly; append-only, provenance sql/transform @ <query-hash>,
freshness semantics all preserved. Contracts are DECLARED, not parsed
from SQL: the step's config carries uses: and provides:; gtme plan
validates both (§7 dynamic needs and provides), and the engine MUST check
result columns match the declared provides. sql/filter: the same
mechanism producing per-record VERDICTs from a predicate — closing
membership-by-ledger-facts cases ("has replied ever", "3+ known contacts
at company") that where= combinators don't reach. sql/filter derives
verdicts from what the ledger implies, re-evaluated each run; ADR-021's
require:/exclude: gates assert recorded membership — complementary,
not competing. The transform floor is symmetric: sql/* for the
computable, ai/* for the judgeable; both read projections, both write
facts, both free to re-run. (This shrinks ADR-018's code-transform
escape hatch to nearly nothing.)
Contract as built (M11): like the group source, SQL steps are
runner-owned — no adapter, no wire protocol, ledger access the runner
mediates. Config carries query plus declared uses: (fields the plan
validates as available) and, for sql/transform, provides: (the declared
output fields, canonical or namespaced). The query runs ONCE per step —
set-based, not per record — on the read-only connection, timeboxed
(30s), with the run id bound as :run_id for scoping convenience; the
engine guarantees scope regardless by applying result rows only to the
run's eligible records (out-of-run rows are dropped and counted). The
result MUST yield an identity_id column. sql/transform: every declared
provides field must appear as a result column; values append through the
engine with provenance sql/transform @ <query-hash>. sql/filter: a
pass column (with optional reason) judges explicitly, or — with no
pass column — membership-style: returned records pass, absent records
fail with the predicate named. SQL steps run normally under --simulate
(they are offline by construction).
sql/traverse (ADR-054): the runner-owned traverse — following relations
the ledger already holds needs no vendor. Config carries entity_type
(the output type, a type file §4a) and query; the result MUST yield
identity_id (identities of that type) and parent_id (identities of
the current segment's type, the run's records); rows whose parent is not
in the run are dropped and counted, as for any SQL step. It runs once
per step on the read-only connection, timeboxed, plan-EXPLAINed and
annotated cross-record; it mints nothing and writes no relation — the
edge it follows already exists. The companies of this run's people is
one line over the vocabulary views:
- id: to-company
use: sql/traverse
with:
entity_type: company
query: >
SELECT r.to_id AS identity_id, r.from_id AS parent_id
FROM relations r WHERE r.relation = 'works_at'
Two semantics were true from M11 and are normative from ADR-037. A SQL
step's query MAY read any identity in the ledger — only its results are
scoped to the run — so a cross-record aggregate (a company's related
people via works_at, a fan-in) is in-contract, not a trick. SQL steps
never cache-skip: they recompute on every run, which is what makes a
cross-record value safe — it cannot go stale when related records
change. "Transform" is the honest name for both a per-record derivation
and a cross-record aggregate, since the output is always this record plus
fields; sql/filter keeps its name because a verdict is a different
output and the reviewer should see the role in the id. Plan-time
EXPLAIN, the cross-record annotation, and the two vocabulary views are
in §7 and §3.
The universal floor (ADR-023; Out half built in M12)
The smallest adapter set with near-total reach docks 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. The set:
In — csv/source (§10.1) and group-as-source (§9, built in M9) — the
spool-draining webhook/source is deferred (ADR-055, ROADMAP.md);
transform — ai/* (pure, above) and sql/*; out —
http/deliver and csv/deliver. Receipts showing the same http/*
target recurring across runs are the cue to mint a named binding.
http/deliver (built in M12): POST the resolved variables: per
record to any URL — the binding engine's deliver role invoked
anonymously. Config: url (templatable), optional
method/query/headers/auth/body (a template; its default is the
resolved variables object). It declares idempotency_scope: url
(ADR-062): the scope is the url as configured, before rendering, so a
URL templating {{record.*}} keeps one scope per step, and any change to
the URL's text is a new destination. A destination told apart only by
query, headers or body is not told apart; give it its own URL or a
named binding. Every request carries Idempotency-Key: the
hex SHA-256 of the delivery's target, scope and idempotency key
joined by NUL (§8), identical on every run and resume, so a target that
honors the header can dedupe what the ledger cannot see (ADR-060); a
headers.Idempotency-Key in config overrides it. The step-level
idempotency: key is
REQUIRED — even the trivial case cannot infer delivery semantics, it
must be told (ADR-023) — and a missing one is a plan error, not a
defaulted identity key.
csv/deliver (built in M12; idempotency_scope: path, ADR-044): write delivered records to a CSV —
universal output to anything with an import button, and the natural
human-review artifact. Config: path; the columns are the variables:
targets (sorted, stable), plus a leading identity_key. The header is
written once on file creation; rows append across runs, and §8
idempotency is what keeps a re-run from appending duplicates.
11. Milestones & acceptance criteria — build in this order
Each milestone ends with make check green (fmt, vet, unit tests) plus the
listed acceptance test. Acceptance tests live in test/e2e/ as Go tests
shelling out to the built binary, using fixture adapters — no network, no
real keys — and MUST load their expected shapes from spec/ (schemas,
ledger.sql, golden wire transcripts, acceptance scripts) rather than
re-encoding them, per ADR-010. Milestones M1–M5 must be completable fully
offline.
- M1 — ledger + identity.
gtme init; internal APIs for upsert identity, write field, project record; migrations, including thecurrent_fieldsview (ADR-003). ✅ Unit tests: identity canonicalization table-driven tests; projection picks highest-confidence-in-window via the view; identity upgrade path; a schema-conformance test asserting the migrated schema matchesspec/ledger.sql. - M2 — protocol + runner core. Message types, manifest loading, schema
validation, projection, cache-skip, worker pool, run/step_events/costs
writing. ✅ E2E:
csv/source → mock-enrich-pyviagtme run, ledger contains fields with provenance; second run reports 100% cache skips and $ avoided; protocol messages round-trip againstspec/schemas/and the golden wire transcript inspec/wire/. - M3 — plan + receipts + resume + inspection.
gtme planoutput; missing-field and missing-credential plan errors; AI-stepuses:plan-time validation (ADR-004);--resume; terminal receipt;gtme show(ADR-006);gtme help --agent(ADR-007). ✅ E2E: a pipeline with an unsatisfiableneeds(oruses) fails at plan with the right step named; kill a run mid-step (fixture adapter with induced failure),--resumecompletes without re-processing done records;gtme showon a known identity and on--run lastmatches the ledger;gtme help --agent's output round-trips per its acceptance criterion in §8. - M4 — AI steps + query + deliver semantics.
ai/filter,ai/composewith output-schema validation + one retry +uses:projection;gtme query(+--save); deliveries idempotency with amock/deliverfixture adapter. ✅ E2E: filter verdicts gate downstream steps; malformed AI output (fixture engine returning garbage once) retries then succeeds; doublegtme runproduces zero duplicate deliveries; saved query returns expected rows. (AI engine behind an interface; tests use a fake engine.) - M5 — real adapters. Apollo, Harvest, Instantly against live APIs,
each with fixture-based unit tests plus a
--livebuild tag for manual smoke tests. ✅ The pipeline in §9 runs end-to-end with real keys (manual gate — see §12). Thewebhook/sourceclauses this milestone originally carried were never met — no package, manifest or fixture ever existed (AUDIT.md) — and are struck by ADR-055, which defers the adapter to ROADMAP.md. - M6 — polish.
gtme runs, README with 60-second quickstart,brew-style install script,gtme secret set. - M7 — canonical vocabulary & edge contracts (ADR-017/018/019/020).
The field registry (
spec/fields/, §4a) with all three enforcement layers; identity-tier amendments (§4: internal-form LinkedIn rule, reserved handle tiers);columns:ingress mapping with auto-map, suggestions, and the identity-path plan check (§7, §10.1);needs: dynamicwithvariables:egress mapping (§6, §9, §10.6);on_missingand--dry-run(§8). ✅ Unit: registry files validate against their schema and every built-in manifest passes registry validation; internal- form LinkedIn URLs fall through to the next tier (table-driven, with the public/internal shapes §4 names); each rule id normalizes per §4a; a one-of needs step plans when any one branch is available and fails naming every branch otherwise (§7). ✅ E2E, offline: acolumns:-mapped CSV plans and runs with values normalized at ingress and unmapped headers namespaced; a plan against a CSV with no identity-key path fails naming the rule, and a name-hash-only CSV warns; a deliver step withvariables:referencing an unprovided field fails plan naming step and field;--dry-runagainst a fixture deliver adapter renders resolved variables in the receipt, writes zerodeliveriesrows, and applieson_missing: skipverdicts; the same pipeline armed delivers, and re-run delivers nothing twice.
The following milestones were sequenced by the human on 2026-08-16
(bindings before groups: receipt-diff acceptance is strongest while
campaign-zero data and the Go twins are current, and --simulate then
de-risks the groups build). Until each builds, its sections above are
decided contract, not shipped behavior.
- M8 — binding engine + simulation gate (ADR-022, ADR-028; §10a, §8).
Built 2026-08-16 (changelog v0.6).
The generic HTTP engine interpreting
spec/binding-schema.json;apollo/search,harvest/profile, andinstantly/add-to-campaignported to bindings; the conformance kit extended to bindings;gtme run --simulate. ✅ Acceptance: receipt diff against each Go twin on campaign-zero data matches (dry runs where delivery is involved); first net-new integration is a pure-YAML Attio binding (assert endpoint, idempotency: native) passing conformance; the campaign-zero pipeline simulates end-to-end with zero network calls. - M9 — groups (ADR-021). Built 2026-08-16. Spec impact applied (changelog v0.7: §3
DDL, §7 gates and plan checks, §8 touch/suppress/terminus and
gtme groups, §9 surface), then built. ✅ Unit: the membership view derives current membership from added/removed sequences (last event wins;touchednever affects membership); suppression window arithmetic. ✅ E2E, offline: a qualify pipeline (csv → ai/filter ⇒ group terminus) adds exactly the passers; re-running it withexclude:on its own output groups judges nothing a second time (zero AI-engine calls — judgment memory); a send pipeline's deliver writestouchedunder its default (pipeline-name) scope, and a second pipeline sharing the scope viarecord:suppresses within the window, receipted with reasons; a group source feeds a pipeline the members a qualify run added;gtme planfails naming arequire:/exclude:/suppress:/source group that does not exist; a--dry-runwrites notouchedand no terminusaddedevents;gtme groupslist/show/ add/remove round-trip, including--querysnapshot with provenance. - M10 — campaign bundles (ADR-029; §8). Built 2026-08-16.
gtme freeze --bundle,gtme runon a bundle path, simulate-on-bundle. ✅ Acceptance: freeze campaign zero, move the bundle to a clean ledger, simulate and dry-run it successfully. Sequenced after groups so bundled pipelines carry group references against built semantics from day one. - M11 — the transform floor + payload retention (ADR-024, ADR-027,
ADR-030; §10a, §3, §5, §6, §8).
http/enrich(markdown + inline JSON modes, mandatory freshness, size cap),sql/transform/sql/filter(runner-owned, declared contracts, query-hash provenance), thepayloadscache tier with RECORD payload attachments, declared retention, opportunistic eviction, andgtme vacuum. ✅ Unit: payload eviction removes exactly the expired; the SQL contract (identity_id required, provides columns checked, run-scoped application). ✅ E2E, offline:http/enrichfetches a local page to markdown into a declared field with a payload retained, and a re-run within the freshness window cache-skips the fetch;sql/transformwrites a derived field withsql/enrich @ <hash>provenance;sql/filtergates records by predicate, both explicit-passand membership-style; arequire:d plan names missinguses:fields;gtme vacuumreports evictions and touches nothing else;--simulatecountshttp/enrichas a gap while SQL steps run. - M12 — the universal Out floor (ADR-023; §10a).
http/deliverandcsv/deliver. ✅ E2E, offline:http/deliverdry-runs to a resolved- variables receipt with zero calls, delivers the mapped variables to a local URL when armed, and re-delivers nothing on a re-run; a plan without itsidempotency:key fails naming the rule;csv/deliverwrites header + rows, appends nothing on a re-run, and the file is reviewable as written. - M13 — delivers as steps (ADR-031; §7, §8, §9). Built 2026-08-17
(changelog v0.14). Remove the top-level
deliver:block frominternal/pipelineand the schema; accept deliver-role adapters as ordinarysteps:entries, any number, any position; role-gatevariables:/on_missing:/idempotency:/record:/suppress:at plan time; dry-run withholding, resolved-variables receipts,deliveriesidempotency, touch scoping, and suppression per deliver step, withskip/suppression advancing the record andfailfreezing it (§8);gtme plancalls out every deliver step; migrateexamples/, README.md's quickstart, VALIDATION.md's pipelines, and e2e fixtures off the block (they stay on the old shape until this milestone so every committed YAML runs against the shipped binary). ✅ E2E, offline: a pipeline with a mid-list deliver step and a final deliver step dry-runs to resolved variables for both with zerodeliverieswrites; armed, it delivers to both, and a re-run delivers nothing twice on either; a record failing between the two delivers to the first only and is absent from the terminus group, while a record suppressed at the final deliver step completes and joins it;variables:on a non-deliver step fails plan naming step and key; a document with a top-leveldeliver:block fails validation. - M14 — the composition pass (ADR-032, ADR-033, ADR-035, ADR-036,
ADR-037; §3, §5, §6, §7, §8, §9, §10, §10a). Built 2026-08-28
(changelog v0.16).
Build in this order, each step
make checkgreen: (1) ADR-033 — step-levelprovides:on AI roles, schema-generated output shape,enum, VERDICT+RECORD from filters, entity-agnostic AI manifests,<pipeline>.<field>default; (2) ADR-035 — compact encoding, wrapping, default-on fencing withfence: false, stated order with the split exposed; (3) ADR-032 —group/deliver, group-sourcelimit:,groups remove --note, the one-commit-point plan warning; (4) ADR-037 — renamesql/enrich→sql/transform(examples, e2e fixtures, ADAPTERS.md, README follow), the two views (migration0007, mirrored tospec/ledger.sql), plan-timeEXPLAINand the cross-record annotation,{query:}/{segment:}config resolution ahead of config-schema validation, ledger read surface inhelp --agent; (5) ADR-036 —deliveries.status/sent_at(same migration),attestsmanifest capability, receipt andgtme showwording, Instantly as the first attesting adapter. ✅ E2E, offline: anai/filterdeclaringprovides: {state: {enum: [a, b]}, rationale: {}}stores<pipeline>.stateand rejects a value outside the enum; a company-entity pipeline plans an AI step against the company registry; a pipeline with twogroup/deliversteps dry-runs to two rendered receipts with zero group events and, armed, routes passers and failers to different groups; a group source withlimit: 2sources the two oldest members; a sourcewith:carrying{query: …}plans with the rows shown and fails on zero;sql/enrichfails plan namingsql/transform; asql/transformjoiningrelationsis annotated cross-record; a query naming an unknown column fails plan; a delivery landsacceptedand a fixture adapter declaringattestsyieldsconfirmed,contradicted, andinconclusivein turn with the documented receipt behavior; the AI fixture engine receives compact, fenced records andfence: falseremoves the fence. The account pattern (four pipelines chained through groups) simulates end to end with zero network calls. - M15 — asynchronous steps (ADR-038; §3, §5, §7, §8, §9, §10). Built
2026-08-29 (changelog v0.18). PENDING and OPEN
pendingininternal/protocoland the schemas; thepending/collectedstep events and thependingrun status; the last-step rule and the respend warning in the planner; collect-firstgtme runand collection in the runner's dispatch; the API engine's batch submit/collect path (custom_id= identity key);deferredin the AI manifests'config_schemaandrespend:in the pipeline schema; receipt andgtme runswording; the fixture engine's scripted PENDING for tests. ✅ E2E, offline: a deferredai/filteras the last step ends the runpendingwith zero verdicts, zero cost, and a receipt naming the token; a plaingtme runof the same pipeline while it is pending collects rather than re-submitting — zero new batch submits, asserted — and, while the provider is still processing, leaves the runpending; the nextgtme runcollects — verdicts land, COST lands under the same run, the terminus asserts, the run finishesdone; the followinggtme runsources fresh records; a deferred step anywhere but last fails plan naming the fix;--dry-runon a deferred pipeline warns;--simulateruns the step synchronously and says so; a judgment step with noexclude:plans with the respend warning andrespend: truesilences it; a credentialed enrich with no window warns the same way. The API engine's batch path is unit-tested against a stubbed Batches endpoint (submit, poll, results keyed bycustom_id). - M16 — the judgment cache (ADR-039; §3, §7, §10a). Built 2026-08-29
(changelog v0.20). Signature and input hash computed in the runner's prepare
from the step config, the adapter's shape and the projection; lookup
over
doneevents; verdict re-application for filters; the provenance suffix; the AI respend warning retired; receipt wording. ✅ E2E, offline: a re-run of a judgment pipeline with unchanged prompt and inputs dispatches nothing (every recordskipped_cachewith reasonsame_judgment, filter verdicts re-applied so downstream gating and the terminus behave as in the first run, zero AI calls asserted via the fixture log); changing the prompt re-judges every record; changing one record's input field re-judges only that record;cache: 1dwith a ledger clock past the window re-judges;respend: truere-judges; a compose's provenance carries the signature andgtme show --provenanceshows it; the AI respend warning no longer appears while the paid-enrich one still does;--simulateof a judged pipeline cache-skips; a deferred step cache-checks before submitting. - M24 — participants (ADR-048, ADR-049, ADR-050; §2, §3, §7, §8, §9,
§10, §10a). Built 2026-09-02 (changelog v0.35). A migration adds
field_values.referent;step_events.eventgainsanswered; the pipeline schema gainsof:,render:,prompt:and losesengine:(a plan error naming the fix); theclaude-codeengine is deleted;ai/review(manifest + prompt shape);human/filter|compose|reviewandagent/*as runner-owned adapters with the in-run TTY walk; the planner validatesof:andrender:asuses:, includes the referent's value in the input hash, and prints the cron note; the runner ends such a runpendingunder a runner-owned token and collects fromansweredevents, writing facts withhuman//agent/provenance, the referent, and the ADR-039 signature;gtme answer(all three addressing forms,--set,--as,--cost,--note, the interactive walk, refusals);gtme show --run --pending; receipt andgtme runswording;gtme show --provenanceprints the referent and note;help --agentgains the answer rhythm and the routing-as-pattern example. ✅ E2E, offline: a review pipeline (human/reviewwithof: <pipeline>.first_line,provides: gradeenum) run with no TTY endspendingwith the receipt naming the verb;gtme answer review.yaml jane --set grade=Zis refused naming A–F,--set grade=Brecords, and the nextgtme runcollects —gradelands withhuman/<user>provenance and areferentpointing at the reviewedfirst_linerow; re-running with the same draft cache-skips (same_judgment), a rewritten draft re-pends; ahuman/filteransweredpass=falsefreezes the record andpass=trueadvances it; anagent/reviewstep under--as claude-code --cost 0.01 --measuredlandsagent/claude-codeprovenance and a measured cost row; the in-run walk asks, validates and stops on interruption with the rest pending (proved at the walk and at the runner's ask/do-not-ask decision — the terminal itself isterm.IsTerminalon stdin, and a pty is a dependency beyond §2);engine: claude-codefails plan namingagent/*; a deliver step after ahuman/*step plans with the cron note;when: <review>.passedfails plan;--simulatecounts the step as a simulation gap. - M29 —
demo/enrich(ADR-056; §10 item 9). Built 2026-09-06 (changelog v0.46). A built-in enrich adapter that needs no key, calls no network, retains no payload, derivesdemo.scorefrom the identity key's hash and setsdemo.noteto a fixed synthetic marker, and emits anestimatedCOST ofcost_per_record_usd(default $0.01) per record under providerdemo;freshness_days30. Runs identically armed and under--simulate. Installed bindings may not take thedemo/prefix.examples/hello.yaml(ADR-058) +examples/contacts.csvare the zero-key top-up demo. Acceptance, offline: the example planned with no environment printsest/record: $0.0100for the step; run armed twice against an empty ledger, the first receipt shows 3 in, 3 out,$0.0300on the step, threedemo.scorevalues withdemo/enrich@1provenance and threedemocost rows; the second showscached 3,avoided $0.0300, no adapter call, and0out on the deliver step; a binding installed underdemo/anythingis refused by name. - M30 —
template:andtext/compose(ADR-057; §2, §7, §8, §9, §10 items 3, 3b, 5, 10, §10a). Built 2026-09-13 (changelog v0.48). One loader (string | {file:}), the bounded Liquid dialect behindgithub.com/osteele/liquid's basic engine with exactly the listed tags and filters registered, the plan-time scope check, the signature over the loaded source plus referenced config,text/composeas a runner-owned compose adapter, transitive fencing, barefreezeinlining andfreeze --bundlepacking template files. The renameprompt:→template:onai/*steps acrossspec/schemas/pipeline.schema.json, the AI adapter config, the manifests, README, ADAPTERS.md, VALIDATION.md, the examples, the bundles and the plugin skill. Acceptance, offline: anai/filtercarryingtemplate: {file: judge.md}plans, and the same bytes inline yield the same signature; editing the file re-judges;{{ record.x }}in that file fails plan naming the rule;with.prompton anai/*step fails plan namingtemplate:; atext/composeoveruses: [first_name, recent_posts]with{% for p in record.recent_posts limit:1 %}and| default:renders the expected field for three fixture records, writes nothing for the one that renders empty, runs identically under--simulate, and a second run reportscached 3; a template naming a field outsideuses:fails plan;{% include %}and an unlisted filter fail plan;freeze --bundlepacks the file by hash and the bundle simulates on a clean ledger; barefreezeprints the template inline. - M31 — one dialect (ADR-057). Built 2026-09-13 (changelog v0.49). The binding request
templates (§10a),
human/*render:, and the ADR-046 cost template move to the M30 parser: the{{a|b}}fallback becomes{{ a | default: b }}in the five built-in bindings, the typed-leaf rule (a leaf that is exactly one placeholder substitutes the typed value) is preserved, and the in-house substitution engine is deleted. Acceptance, offline: every built-in binding's conformance fixtures produce byte-identical requests before and after; a binding still using the bare|fallback failsadapters verifynaming the rewrite. - M32 — plain words (ADR-058; §7, §8, §10, §11). Built 2026-09-26 (changelog v0.52). The
default surface drops its citations and takes the docs' words: plan
prints
reads:,of: <field> (the value under review)andends in group "<name>" as <type>; source and traverse receipt lines sayalready in the ledgerandalready in this run; the simulate banner and receipt title sayrecorded responses only; every error names the fix with noADR-or§reference; plan errors say "leg" where they said "typed segment";gtme showgainsidentity_key_tier;examples/cache.yamlbecomesexamples/hello.yaml(pipelinehello) in README, START.md, ADAPTERS.md, the e2e tests and the docs.help --agentandhelp --bindingskeep their citations. Acceptance, offline:gtme plan examples/hello.yamlprintsreads:and noADR-; a review step's plan block printsof: title (the value under review); a traverse receipt line ends1 already in this runand a source line(1 already in the ledger);gtme run --simulateprintsrecorded responses only;gtme show jane.doe@acme.comprints"identity_key_tier": "email"and a name-hashed record"name_hash"; no printed string outsidehelp_agent.goandhelp_bindings.gocontainsADR-or§; every docs page that prints plan or receipt output is re-run and lints clean. - M33 — vendors leave the binary (ADR-059; §6, §8, §9, §10, §10a,
§11). Built 2026-09-27 (changelog v0.53). The binding engine gains the
each:extraction form (§10a;spec/binding-schema.jsongainseach).gtme-bindingsgains five verified entries:apollo-search,apollo-enrichandattio-assertmoved unchanged,harvest-profileat version 2, andharvest-recent-posts;index.jsonis regenerated and the registry's CI runs their fixtures. The binary drops the three embedded bindings from its built-ins, deletes the Harvest Go adapter and the binding twin tests, and keeps one unregistered binding underspec/bindings/forhelp --bindings.adapters addtakes several references and bare ids; an uninstalleduse:id is a plan error naminggtme adapters add <id>.examples/, README, START.md, ADAPTERS.md, the plugin skills' command blocks and the docs pages that run a vendor step gain the install line;docs/_adapters.jsonis regenerated.instantly/add-to-campaignis untouched. Acceptance, offline:gtme help --agentfrom a clean HOME lists no adapter whose id names a vendor other thaninstantly/;harvest/profile@2andharvest/recent-posts, run over the Go adapter's recorded fixtures, producerole_historyandrecent_postsidentical to what the Go adapter produced from them;gtme planon a pipeline naming an uninstalledapollo/searchexits non-zero and printsgtme adapters add apollo/search;gtme adapters add apollo/search apollo/enrichagainst a local index (GTME_REGISTRY) installs both, each pinned to the index'ssha; aharvest/profilestep withposts_limitfails plan namingharvest/recent-posts;make checkand the plugin e2e pass with the entries installed from a local path. - M34 — crash and resume (ADR-060, ADR-061; §3, §8, §10a, §11).
Built 2026-09-29 (changelog v0.57). Migration
0014addsruns.pidandruns.host, mirrored inspec/ledger.sql. An adapter-backed deliver step runs one record per session and commitsdispatchedbefore each; resume and an interrupted finish hold unconfirmed records asdeliveriesrows withstatus = unconfirmed, except at a target declaringidempotency: native; the pre-send check skips them with reasonunconfirmed;--resend-unconfirmed(valid only with--resume) releases them; the receipt andgtme runs RUN_IDcountN unconfirmedand print the release command.http/deliversendsIdempotency-Key. Every executing run holds itsflockonlocks/<run_id>.lock;gtme runsderivesinterrupted;--resumerefuses a live run and adonerun with exit 2; a plaingtme runafter an interrupted one prints the resume command. Acceptance, offline, against a scratchGTME_LEDGERand a built binary: a 40-recordhttp/deliverstep at--concurrency 4to a local target that blocks each request, killed withkill -9after 12 arrivals, then resumed, delivers everyIdempotency-Keyat most once, leaves 4unconfirmedrows and prints them with the command; the command sends exactly those 4 and their rows becomeaccepted; the same crash by Ctrl-C writes the 4 rows at finish without a resume; a test binding declaringidempotency: nativeis re-sent the 4 instead; a fresh run of the same pipeline skips them with reasonunconfirmed; while a run executes,gtme runsshowsrunningand--resumeof it exits 2; afterkill -9,gtme runsshowsinterrupted, leaves the ledger file unchanged, and the resume reachesdone;--resumeof adonerun exits 2 (TestResumeLastAndUnknownRun's no-op assertion becomes this refusal);make checkpasses. - M35 — destinations and Instantly's move (ADR-062, ADR-063; §3, §5,
§6, §8, §9, §10, §10a, §11). Built 2026-09-29 (changelog v0.59). Migration
0015backfillsscopeforhttp/deliverrows from the run'sruns.config_jsonwhen the run had exactly onehttp/deliverstep, mirrored inspec/ledger.sql's comments.http/deliverdeclaresidempotency_scope: url. The receipt andgtme runs RUN_IDcountalready_deliveredandunchangedskips asN already delivered, outsidecachedand cost avoided. PREFLIGHT gains the optionaldestination(spec/schemas/, the Go protocol package), and the preflight line prints it.instantly/add-to-campaignbecomes version 2:campaignis a lowercase UUID inconfig_schema, the name lookup and its cache are deleted, and preflight fillsdestination. The adapter moves tocmd/gtme-instantlyand leavesinternal/adapters/all.release.ymlbuildsgtme-instantly_<tag>_<os>_<arch>.tar.gzfor the four targets into the samechecksums.txt.spec/schemas/registry-index.schema.jsongainskindandassets, andgtme adapters add/update/verifyhandle process entries.gtme-bindings' index gains the Instantly entry after the first release that carries the archives, and its contribution rules andspec/binding-schema.json'sidempotency_scopedescription state ADR-062's rule.examples/, §9-derived docs, README, START.md, ADAPTERS.md, the plugin skills and the docs pages that configure a campaign by name change to an id and gain the install line;docs/_adapters.jsonis regenerated. Acceptance, offline:TestHTTPDeliverScopesToTheURLpasses (webhook B receives both records after webhook A did); inTestInstantlyRenameIsTheSameCampaign, configured by id, a rename in the fake adds no lead and the second run's preflight line prints the new name; a campaign given as a name failsgtme plannaming the id; a second run to the same URL reports2 already delivered,0 cached, and no cost avoided; a ledger migrated from a pre-0015copy with onehttp/deliverstep per run has every such row's scope equal to what a new run computes, and a run with two such steps keeps'';gtme help --agentfrom a clean HOME lists no vendor adapter;gtme adapters addinstalls a process entry from a local index and archive, refuses a checksum mismatch and a platform with no asset, and the installed adapter passes the Instantly e2e tests against the fake;make checkpasses. The first live run of the installed adapter, against a shell campaign with no sending accounts, is recorded in VALIDATION.md by hand. - M36 — the receipt from the ledger; crashed sends; settling held
deliveries (ADR-064; §3, §8, §11). Built 2026-09-29 (changelog v0.60).
Per-record step events carry
detail.outcome; gates writegated; cache skips carryavoided_usd;spec/ledger.sql's comments mirror §3.gtme runs RUN_IDprints the live receipt's table, net across sessions, then its run-level lines. An interrupted run's unanswered sends are counted ingtme runsand named per target ingtme runs RUN_ID.--resumetakes--settle-unconfirmed[=KEYS]and--resend-unconfirmed[=KEYS];deliveries.statusgainssettled. The docs that printgtme runs RUN_ID(the report and recover guides, runs and receipts) are re-run against the build. Acceptance, offline: for a pipeline with an enrich, a filter, awhen:-gated step, anon_missinghold, a cache skip and a deliver,gtme runs RUN_ID's table equals the live receipt's row for row; the same pipeline killed once and resumed prints one table whose columns reconcile toinand count each record once; a run recorded by M35's binary prints?for gated and avoided and no error; afterkill -9during a 40-record deliver at concurrency 4,gtme runsshows the unanswered count inin flightandgtme runs RUN_IDprints the per-target line, with the ledger's rows unchanged; after the resume holds 4,--settle-unconfirmed=K1,K2settles those two, sends nothing, and the other two stay held;--resend-unconfirmed=K3sends only K3;--settle-unconfirmednaming a key the run does not hold, or a key also given to--resend-unconfirmed, exits 2 and changes nothing; a later run to the target counts settled records asalready delivered; theheld:line is gone once nothing isunconfirmed;make checkpasses. - M37 — a step that stops (ADR-065; §5, §8, §10, §10a, §11). Built 2026-09-30 (changelog v0.62).
§5's ERROR message and OPEN's
acceptsland inspec/schemas/and the Go protocol package; the runner listsERRORin every OPEN. The runner acts on the four verdicts as §8 "A step that stops" says: onfail_run, and on a session that exits 3 or a binding auth error with no rule, the dispatcher sends no further chunk (the #82 stop path), sessions already open finish, no later step starts, the run endsfailed, the step line printsN not sentand the reason, and the step-levelfailedevent carriesdetail.stopped;gtme runs RUN_IDrebuilds the line. The binding engine emits ERROR for everyerrors:verdict instead of a warning or a returned error, and aretrythat runs out isretry, not a stop.spec/binding-schema.json'serrorsaccepts a rule or a list of rules withmatchandclass, rejects a rule after a matchless one, andgtme adapters verifyandgtme help --bindingsfollow.instantly/add-to-campaignemitsfail_runon the lead-limit 403 and exits 1. The docs that describeerrors:and the recover guide are updated. Acceptance, offline: a deliver binding mapping 403 tofail_run, against a target that always answers 403, sends one request for 40 records at concurrency 1 (at most 4 at concurrency 4), prints39 not sent(fewer at concurrency 4, and the line reconciles), exits with the error's class, and--resumeafter the target answers 200 sends the rest once each; the same binding withfail_recordsends 40, fails 40, writes nodeliveriesrow and endsdone(record failures do not fail the run, §5); withskipit sends 40, counts 40skipped, writes nodeliveriesrow and endsdone; amatchrule applies only when its text is in the body, the next rule applies otherwise, and a matched rule's run exits 1, or 3 when the rule saysclass: auth; a binding whose target answers 401 with no rule sends one request and exits 3; the Instantly fake that fills after N leads adds N, fails one, and reports the restnot sent, and a resume after it has room adds them without adding any lead twice; a fixture process adapter that sends ERRORfail_recordfor one record of a multi-record enrich session fails that record while the others advance;make checkpasses. - M38 — Instantly's lead fields (ADR-066; §10, §11). Built 2026-09-30 (changelog v0.64).
instantly/add-to-campaignmaps all seven documented lead fields into the lead body, accepts the camelCase spelling of each, refuses a session whosevariables:names one lead field twice (exit 2, nothing sent), attests the new fields like the old, and its preflight blocks a lead-field tag the sequence uses and no target fills. The manifest'svariablesdescription and the docs that show an Instantly step say which names reach the lead body. Acceptance, offline, against the Instantly fake:firstName: first_nameputs the value in the lead body'sfirst_nameand sends nofirstNamecustom variable;website,phoneandjob_title(andjobTitle) reach the lead body;first_nameandfirstNameboth mapped fails the session with exit 2 and no request to the leads endpoint; a sequence using{{firstName}}with no target filling it isblockednaming the tag, and the same sequence withfirst_namemapped isok; a sequence using{{jobTitle}}is not checked (ADR-066); a custom-variable target missing from the sequence is still blocked as before;make checkpasses. - M28 — types and traverse (ADR-054; §3, §4, §4a, §5, §6, §7, §8, §9,
§10a, §13). Built 2026-09-05 (changelog v0.43). A type is a file:
spec/fields/*.jsongainkind,identityand per-fieldreference, §4 derivation reads theidentitylist (person and company unchanged in behavior,postseeded), theurlrule exists, and types are discovered from the binary,~/.gtme/types/, and each installed binding'stypes/in place;verifyrefuses a binding shipping an embedded type's name. The adapter–type contract runs inplanandadapters verify. Theworks_atspecial case becomescompany_domain's declared reference.traverseis a manifest and binding role withfromandrelation; the runner opens a new segment after one, relates children to parents, coalesces by ADR-053 (3), finishes parents foronce:, and reportstraversed/coalesced.sql/traverseis the runner-owned floor.groups.entity_type(migration0013) is set at creation and enforced on add;gtme groups add --type; the group source takes the group's type; plan checks terminus andgroup/deliveragainst it and prints type changes, relation writes, and the groups a{query:}reads;gtme groups showprints producers and consumers. §13's fan-out non-goal retires. Schemas,spec/ledger.sql, and the type files ride the build. Acceptance, offline: a person pipeline with a fixturetraversetopostplans printingperson → post … (authored_by), runs from fixtures, writesauthored_byper post, coalesces a post two parents share, counts the parentsin/emptyand the childrentraversed/coalesced, and its terminus group is typedpost; a second traverse back toperson(engagers) reaches a person already in the run and coalesces; asql/traverseoverworks_atyields the companies of the run's people; a traverse binding emitting posts with no key failsplanandverifynaming the missing tier; a binding shippingtypes/post.jsonis refused byverifyas a reserved name; two installed bindings shippingtypes/job_posting.jsonwith different hashes fail plan naming both paths; a group source over apostgroup validates field names againstpost; a company run ending in a person group fails plan; an existing person pipeline plans and runs byte-identically;once:treats a parent that reached a traverse as finished. - M27 — record accounting (ADR-053; §5, §7, §8, §9, §10). Built
2026-09-04 (changelog v0.41). A
field-writing step counts
emptyfor a record it advanced without writing anything, andinreconciles against the classified columns; the source line reconciles rows read against records sourced with coalescing recorded per record; a participant step takeson_missing: run | skip | fail(defaultrun), and the receipt reports records dispatched with a declared field absent regardless; a dropped record keeps its payload under ADR-030's tier; a paid run that produced no records is marked on the receipt and ingtme runs, exit code unchanged.spec/schemas/pipeline.schema.jsonrides the build. Acceptance, offline: asql/transformwhose query matches nothing reports2 in, 0 out, 2 emptyand the records still advance; anhttp/enrichover its byte cap does the same rather than reporting full throughput; a compose declaringuses: [a, b]wherebis absent for some records runs on all of them by default and the receipt names the count,on_missing: skipadvances those records untouched, andon_missing: failfails them namingb; a CSV whose rows coalesce reports the reconciliation and a SQL query answers which row merged into which identity; a run that spent and sourced nothing is marked; every per-step line reconcilesinagainst its classified columns. - M26 — bounded group consumers (ADR-052; §3, §8, §9). Built 2026-09-04
(changelog v0.40). A group
source gains
once: true: selection skips members this pipeline has finished — completed the final step, or stopped by a filter verdict — oldest-added first withinlimit, with failed and pending records deliberately still eligible. Scope is the pipeline name; terminality is read from earlier runs' records, so the only new persisted fact isruns.dry(migration 0012), which the build found ADR-052 (7) needed.gtme planprints the eligible count beside the selection; a dry or simulated run advances nothing.spec/schemas/pipeline.schema.jsonrides the build. Acceptance, offline: a three-member group withlimit: 2andonce: truesources members 1–2, then 3 on the next run, then nothing; the same pipeline withoutonce:still replays 1–2 forever (the reported behavior, unchanged when the key is absent); a record a filter froze counts as finished and is not re-offered, while a record whose step failed IS re-offered on the next run; a group whose members were all finished sources zero records and the run ends without a step; the group's membership is byte-identical before and after (no added, removed or touched events fromonce:);gtme plannames the eligible count; a--dry-runfollowed by an armed run sources the same members. - M25 — plan visualization (ADR-051; §7, §8, §13). Built 2026-09-04
(changelog v0.37).
gtme plan --vizappends a diagram of the resolved plan to the default output;--viz-onlyprints it instead. One renderer over the existing resolved plan — no planner change, no network, no spend, stderr like the rest of plan's human-facing output. Shape carries role (rounded source, funnel filter, light-rect enrich, doubled-rail verify, wavy-floor compose, trapezoid review, heavy deliver); a two-slot glyph pair carries executor then role, so a free offlinesql/transformand a paidapollo/enrichare distinguishable at a glance though both are roleenrich; each edge is labelled with the fields its step added to the available set, making §7 step 2's walk visible. Fixed-width frame, no colour, no TTY or terminal-width detection, so the bytes are deterministic and golden-testable; padding measures display width rather than counting runes (emoji are two columns wide) via a hand-rolled table, leaving §2's dependency list untouched. Acceptance: a pipeline exercising every role renders under--vizand--viz-only, with every box row the same display width and no line wider than a conventional terminal; both shipped examples plan and render; the default output is byte-identical with no flag; every §7 fact still appears in the listing;--vizwith--viz-onlyis a validation error. - M23 — honest costs + engine-owned limit (ADR-046, ADR-047; §3, §5,
§7, §8, §10a). Built 2026-09-01 (changelog v0.33). Migration 0010
rebuilds
costswithbasis(backfillestimated); the COST message carriesbasisand every built-in that spends labels its emissions per the reserved-measuredrule (the claude-code engine's reportedtotal_cost_usdis the one measured source; every rate-multiplied amount, the binding engine's included, is estimated); receipts andgtme runsprint totals with their basis (bare /(estimated)/ split when mixed);binding-schema.jsonletsamount_usdtemplate from config andgtme planprintsest/record: unsetfor an unresolved rate; the engine acceptslimiton any source binding, declared or not;gtme help --bindingsand CONTRIBUTING carry theper: requestpage-billing guidance and thelimitreservation. ✅ E2E, offline: a fixture binding with a templated rate runs at the operator's figure and its cost rows sayestimated; the same binding with no rate set plans asunsetand runs at $0(estimated); a fixture adapter emitting vendor-reported cost landsmeasuredand a mixed run prints the split total on both the live receipt andgtme runs; a strict binding that does not declarelimitacceptslimit: 1and stops paginating after one record (one request), while an unknown key is still refused. - M22 — on-change re-delivery (ADR-045; §3, §6, §8, §9). Built 2026-08-31
(changelog v0.31). Migration 0009 adds
variables_hash; manifestidempotency: native|ledger(bindings bridge theirs);redeliver:grammar + plan validation + per-adapter defaults; the runner's deliver path resolves variables before the dedupe decision, hashes them, and upserts the row on re-delivery (status back to accepted). ✅ E2E, offline: an armed attio/assert against a local server delivers once; re-run unchanged skips with reasonunchanged; a changed value re-delivers (the server sees a second assert, the row's hash moves, rows stay 1);redeliver: alwaysre-asserts regardless;redeliver: on_changeon csv/deliver fails plan naming the native requirement; dry receipts distinguishunchangedfromalready_delivered. - M21 — scoped delivery dedupe (ADR-044; §3, §6, §8, §10). Built 2026-08-31
(changelog v0.29). Migration 0008 rebuilds
deliverieswithscopeand UNIQUE(target, scope, idempotency);idempotency_scopein the manifest and binding schemas and the three declarations (instantly: campaign, attio: object, csv/deliver: path); the runner resolves the scope from step config for the check, the insert, and attestation updates;spec/ledger.sqlmatches the migrated shape. ✅ E2E, offline: the same record delivered to two campaigns through one adapter lands twodeliveriesrows; re-running either campaign adds nothing; group handoffs unchanged (scope=''); a pre-0008 ledger migrates withscope=''backfilled; the attestation update touches only its own scope's row. - M20 — the Apollo split (ADR-043; §9, §10). Built 2026-08-30
(changelog v0.27).
spec/bindings/apollo-search/rewritten againstmixed_people/api_search(masked provides,pagepagination, empty/short-page termination); newspec/bindings/apollo-enrich/againstpeople/match(needsapollo.id, revealed provides, per-credit cost, payloads retained); fixtures re-recorded from live probes, sanitized; §9's andhelp --agent's examples move to filter-then-reveal. ✅ Offline: the conformance kit passes both bindings' new fixtures; the §9 example and everyhelp --agentexample passgtme plan(TestHelpAgentExamplesPassPlan); a pipeline needingemailoffapollo/searchalone fails plan namingapollo/enrich. Live (human-gated, §12): Campaign 1 story 2 sources masked and reveals only past the filter, credits visible per step in the receipt. - M18 —
help --bindings(ADR-041; §8). Built 2026-08-30 (changelog v0.24). The second agent surface ininternal/cli, regenerated from the embedded schema and bindings;help --agentgains its pointer. ✅ E2E, offline:gtme help --bindingsis JSON whoseschemaequalsspec/binding-schema.jsonbyte for byte, whose reference binding validates against that schema and resolves throughgtme planwhen installed on the discovery path it names, and whose text names the path and the fixtures expectation;help --agentcarries the pointer; the unknown-adapter error namesbinding.yamland the verb. - M19 — the bindings registry (ADR-042; §6, §8, §10a, §13). Built 2026-08-30
(changelog v0.25). Tarball fetch and extract,
.source.json,adapters add / search / verify / update / listininternal/cli, the index schema;verifyreuses the binding conformance runner; fixture minting from retained payloads (ADR-030) for the first registry entry; thegtme-bindingsrepository seeded with the round-trip's CRM source binding as the first verified entry. ✅ E2E, offline: against a local tarball server and a local index,adapters searchfinds an entry by vendor;adapters addinstalls it under the dashed id with.source.jsoncarrying the resolved commit and content hash, runs its fixtures first and prints the hosts and credentials it will use; an entry with failing fixtures, or none, does not install; a content-hash mismatch against the index refuses; the installed binding resolves throughgtme planand serves its fixtures under--simulate;adapters updatemoves the pin only when asked;adapterslists source and pin; a bundle (ADR-029) records the pin. - M17 — deliver preflight (ADR-040; §5, §6, §8, §10). Built 2026-08-29
(changelog v0.22). PREFLIGHT and OPEN
preflightininternal/protocoland the schemas;preflightsin the manifest; the preflight session in the runner ahead of a deliver step's record sessions at--dry-runand arm; receipt wording; Instantly's four checks in its HTTP file; a fixture adapter for the acceptance. ✅ E2E, offline: a fixture deliver adapter declaringpreflightsanswersok,blocked, andinconclusivein turn —okdelivers with the checks on the receipt;blockedunder--dry-runreports the check and writes nothing, and armed fails the step with zero deliveries, zero adapter record sessions, records at the previous state, runfailed, and--resumeafter flipping the fixture delivers;inconclusivedelivers with a warning; a non-preflighting adapter is never asked; a pipeline with two deliver steps preflights each. Instantly's checks are unit-tested against stubbed campaign and sequence endpoints: active, paused, too few steps, an unreferenced variable, an unfilled variant, and an unreadable target.
Repo layout:
cmd/gtme/ # main
internal/{ledger,identity,protocol,runner,planner,cli,adapters/...}
adapters/mock-enrich-py/
spec/ # machine-checkable artifacts: schemas, fields/, ledger.sql, wire/, acceptance/
test/e2e/
SPEC.md CLAUDE.md DECISIONS.md PROCESS.md ROADMAP.md VALIDATION.md AUDIT.md Makefile
CLAUDE.md is the one-page constitution; see that file for its exact
contents (this section does not duplicate it, to avoid the two drifting).
12. Autonomy protocol
- Don't ask when: the spec decides it; or it's an internal detail (naming, file layout within the layout above, test structure).
- Decide-and-record when: something small is underspecified. Append to
DECISIONS.md: date, question, choice, why. Keep building. - Stop and ask when: (a) an external API's real shape contradicts the spec in a way that changes a contract or the ledger schema; (b) anything requires spending real money or sending real email — M5 (real adapters) live runs and the VALIDATION.md campaign are always a human gate; (c) a DECIDED section appears internally inconsistent.
- Never: send to a real Instantly campaign, call paid provider endpoints, or commit secrets, without an explicit human go.
13. Non-goals for v0 (do not build)
Scheduler/daemon (answered instead by a scheduled gtme run over a file
a receiver writes, §8; the spool-draining adapter is deferred, ADR-055),
dashboards or any UI (a one-shot static render such as
gtme plan --viz is not one: no process, no interaction, no retained
state — see ADR-051), DAG/branching beyond
when:, waterfall: execution (parse-and-reject only), email waterfall
providers, relations that end (works_at cannot say someone left — see
ROADMAP.md; fan-out itself is traverse, ADR-054, no longer a non-goal),
teams/auth, MCP server mode (see ROADMAP.md), a hosted adapter
marketplace — accounts, payments, a service (the bindings registry, an
index and a fetch verb, is in scope: §8, ADR-042),
migrations of the YAML format, Windows support (build for darwin/linux).
Pipe syntax is deferred (ADR-005): all v0 pipeline surfaces compile to the
same pipeline object, authored only as YAML — there is no second, informal
surface to keep in sync with it.
Operator stories — acceptance criteria (ADR-012, normative)
Each story below states its invariant and Given/When/Then acceptance criteria against the DECIDED mechanics above. These are the normative counterpart of VALIDATION.md's enactment scripts, which run the same eight stories as a living, non-normative campaign against real data. A story "passing" here means: the mechanism it depends on behaves as this section says, verifiable offline against fixtures.
Launch
Invariant: starting a new pipeline against fresh data produces a
receipted run and a ledger that now knows those identities.
Given a valid pipeline.yaml and a source with records the ledger has
never seen, when the operator runs gtme plan then gtme run pipeline.yaml, then the run completes with status done, every
sourced identity exists in identities with at least one field_values
row, and the terminal receipt reports non-zero records processed and a
total cost (or ? where unpriced).
Top-up
Invariant: re-running a pipeline against overlapping data costs less
and delivers nothing twice.
Given a completed run whose identities are still within their
adapters' freshness windows, when the operator runs the same
pipeline.yaml again (e.g. against an updated source with some overlap),
then every overlapping identity's enrich/verify steps are skipped via
step_events.event='skipped_cache' (§7), the receipt reports cost avoided
0, and no identity that was already in
deliveriesfor this target produces a seconddeliveriesrow (§8 idempotency), and no record heldunconfirmedafter a crash (ADR-060) is sent. The records withheld because this destination already has them are reported asalready delivered, not as cached or as cost avoided (ADR-062).
Interrogate
Invariant: what the system knows about one record is always one
command away.
Given an identity with field values from more than one adapter,
when the operator runs gtme show <identity-key> --provenance,
then the output lists every current field value (per the
current_fields view, §3) together with its source adapter, confidence,
and the run that wrote it, and the command performs no ledger write.
Iterate
Invariant: a pipeline change is checked before it is trusted, cheaply.
Given a pipeline.yaml edited to add or change a step, when the
operator runs gtme plan before gtme run, then any unsatisfiable
needs/uses or missing credential is reported as a plan error naming
the step and the missing field (§7) before any adapter is invoked; and,
once gtme plan succeeds, a gtme run scoped to a small source (e.g. a
low limit in source.with) exercises the full step chain end-to-end at
minimal cost before a full-size run.
Segment
Invariant: a slice of accumulated knowledge is a SQL statement away,
and the same slice can seed a new pipeline.
Given a ledger with records from one or more prior runs, when the
operator runs gtme query --save NAME "SELECT ..." with a single
SELECT/WITH/EXPLAIN statement, then the statement is stored and
re-executable by name, it runs against a read-only connection (mode=ro)
so it cannot mutate the ledger, and its result set can be used to scope
which identities a subsequent pipeline or gtme show inspects.
Guard
Invariant: a pipeline that would fail or overspend is caught before
it starts, not partway through.
Given a pipeline.yaml with a step whose needs.required (or uses,
for AI steps) is not satisfied by any upstream provides, when the
operator runs gtme plan, then the command exits non-zero with an
error naming the specific step and field (§7), performs zero network
calls, and writes zero cost rows — the same guarantee holds for an
unresolvable declared credential (§6).
Recover
Invariant: a killed run picks up where it left off, never redoing
completed, paid-for work.
Given a run killed or crashed partway through (some records past a
step, some not), when the operator runs gtme run pipeline.yaml --resume RUN_ID, then every record whose run_records.state already
reflects completion of a step does not re-invoke that step's adapter or
incur that step's cost again, and the run reaches status='done' covering
the records that had not yet completed. Given a run killed during a
deliver step whose target does not declare idempotency: native,
when it is resumed, then no record whose dispatched event has
no done or failed after it is sent again: each has a deliveries
row with status='unconfirmed', the receipt names it with the
--resend-unconfirmed command, and only that flag sends it (ADR-060);
and --settle-unconfirmed=KEY settles exactly that record, sending
nothing, while the rest stay held (ADR-064).
Given a run whose process died without finishing, when the
operator runs gtme runs, then the run shows interrupted with no
ledger write and gtme runs RUN_ID prints the --resume command;
and a --resume of a run whose process is still alive, or of a run
that is done, exits 2 without touching the run (ADR-061). Before the
resume, gtme runs RUN_ID of the interrupted run counts its unanswered
sends (ADR-064).
Given a deliver step whose destination refuses a record with a
fail_run verdict, or whose adapter reports an auth failure, partway
through, when the run ends, then no further record was sent
after the refusal except by sessions already open, the receipt names
the reason and counts the rest not sent, and gtme run pipeline.yaml --resume RUN_ID after the cause is fixed sends each of them once
(ADR-065).
Report
Invariant: what happened in a run, and what it cost, is always
reconstructable after the fact.
Given one or more completed runs, when the operator runs gtme runs (to list) or gtme runs RUN_ID (for one run's receipt), then the
output reports, per step: records in/out, cache skips, and cost — matching
the sums in step_events and costs for that run_id exactly, with
no reconstruction required from raw table scans — and gtme runs RUN_ID
prints the live receipt's columns and words, so a run that finished in
one session reads the same both ways (ADR-064).
Changelog
Format: Keep a Changelog. This project does not yet have numbered releases; entries are keyed by the reconciliation pass that produced them.
v0.64 — 2026-09-30 (M38 build: Instantly's lead fields, built)
Changed: §11 M38 marked built; no normative text changed.
Behavioural notes from the build: the duplicate refusal's message names
both targets and the field; attest reads job_title, website and
phone from the re-read lead under those names, and a re-read that does
not carry one is inconclusive, as for the other fields. The registry
entry for instantly/add-to-campaign pins release tarballs, so the
change reaches an install when the entry is repinned to a release that
carries it.
v0.63 — 2026-09-30 (ADR-066 reconciliation: Instantly's lead fields; build queued as M38)
Changed: §10 item 6: the lead body takes all seven lead fields
Instantly's create-lead API documents (job_title, website and
phone join the four), in snake_case or camelCase; two targets naming
one field refuse the session with exit 2; preflight also blocks a
lead-field tag the sequence uses that no target fills. Added: §11
M38 queued. No wire, ledger or exit-code change.
v0.62 — 2026-09-30 (M37 build: a step that stops, built)
Changed: §11 M37 marked built. Its acceptance clause for a
fail_record binding said the run ends failed; §5 and §8 say a
record failure fails that record and the run continues, and a run with
record failures ends done, so the clause now says done (human-approved
2026-09-30). Behavioural notes from the build: the ERROR message schema
is spec/schemas/msg-error.schema.json, named like the other message
schemas; a rule with class: auth on a record verdict (fail_record,
skip, retry) reports that verdict for the record and then stops the
step as an auth failure does. No ledger DDL change and no new exit code.
v0.61 — 2026-09-30 (ADR-065 reconciliation: a step that stops; build queued as M37)
Added: §5 ERROR (key, verdict, reason) and OPEN's optional
accepts; §8 "A step that stops", defining the four error verdicts in
the runner, the stop on fail_run and on exit 3, the not sent
remainder, detail.stopped on the step-level failed event, and the
exit code of a stopped run; §10a "Error verdicts", with rule lists,
match and class; §11 M37 queued; the Recover story's clause.
Changed: §5's exit rule says exit 3 stops the step; §8 record
accounting names not sent, and gtme runs RUN_ID prints it; §10
item 6: Instantly's lead-limit 403 is fail_run and exit 1. No ledger
DDL change and no new exit code.
v0.60 — 2026-09-29 (M36 build: the receipt from the ledger; crashed sends; settling held deliveries, built)
Changed: §11 M36 marked built; no normative text changed. Behavioural
notes from the build: the live receipt and gtme runs RUN_ID print the
table through one renderer, and gtme runs RUN_ID's total line now reads
total: $X spent as the receipt's does; the source row's out is the
count the source recorded when it ran, not the run's current membership;
a run recorded before M36 prints N+? in in for a step with a when:
or a membership gate, ? in avoided, and a note that in+? is a
floor; a gated event's detail names the gate (when or membership);
the unanswered count leaves out sends a later run has already held, so a
record is never both unanswered and held:; when both
--resend-unconfirmed and --settle-unconfirmed are given, a flag
without a list takes the records the other did not name (settle some,
send the rest), and both without lists refuse; the settled step event is
written at the deliver step that targets the row; spec/ledger.sql's
step_events.event and deliveries.status comments now mirror §3,
which M34 had left behind.
v0.59 — 2026-09-29 (M35 build: destinations and Instantly's move, built)
Changed: §11 M35 marked built. Behavioural notes from the build: a
group/deliver handoff to a group that already holds the record counts
already delivered as well, since it is the same skip; both receipts
print STEP: N already delivered under their table, and gtme runs RUN_ID's cached column excludes those skips; migration 0015 also
rescopes ADR-060's dispatched events, each by its own step's url, so a
crash from before the upgrade is still held; the live preflight line
names the destination as the receipt does; a config value failing its
property's pattern is a plan error quoting the property's description;
the Instantly campaign id is accepted in lowercase only; a process
archive holds exactly manifest.json and run, and gtme adapters update moves a process entry to the release the index lists and refuses
an @ref. The gtme-bindings index entry for Instantly follows the
first release that carries its archives.
v0.58 — 2026-09-29 (ADR-064 reconciliation: gtme runs RUN_ID is the receipt; crashed sends and held deliveries; build queued as M36)
Changed: §3 step_events.event gains gated and settled, and
deliveries.status gains settled (TEXT; no migration); §8's run
line gains --settle-unconfirmed[=KEYS] and a key list on
--resend-unconfirmed; record accounting requires detail.outcome on
per-record events, a gated event, and avoided_usd on cache skips;
the terminal receipt's "gtme runs <id> mirrors the live receipt"
becomes a paragraph defining the mirror; deliver idempotency gains
settling and selective release; the run-lock subsection gains the
read-only count of an interrupted run's unanswered sends and settling
on a done run; §11 M36 queued; the Recover and Report stories gain the
clauses. Exit codes unchanged: the new refusals use exit 2.
v0.57 — 2026-09-29 (M34 build: crash and resume, built)
Changed: §11 M34 marked built. §8's run-lock subsection gains one
clause the build found: a resumed run that holds unconfirmed deliveries
finishes done, and its receipt prints --resume RUN_ID --resend-unconfirmed, so the done refusal does not apply to that
release; without the flag the refusal names it. Behavioural notes from
the build: any armed run's deliver step holds what a dead run left
dispatched to the same target and scope, so a plain gtme run after a
crash holds them too, under the dead run's id; a held record is not
counted as cached, skipped or failed —
the step line counts it N unconfirmed and it stays at the previous
step's state; an interrupt during a deliver step prints the step line
and ends gtme: runner: <step>: interrupted; gtme runs RUN_ID prints
one held: line per target with the release command; http/deliver
receives its key from the runner as a reserved record field the engine
removes before templates see the record; gtme runs derives
interrupted by probing the lock with a shared, non-blocking flock and
never creates a lock file; the resume command gtme runs prints names
the file <pipeline>.yaml, since the ledger records the pipeline's name,
not its path.
v0.56 — 2026-09-29 (ADR-062/063 reconciliation: a delivery's scope is the destination's stable identifier; process entries in the registry, Instantly leaves the binary; build queued as M35)
Changed: §6 idempotency_scope MUST name a stable identifier of the
destination, never a display name; §3's deliveries.scope comment says
so, and migration 0015 backfills http/deliver scopes from
runs.config_json; §10a http/deliver declares idempotency_scope: url, the URL as configured; §8 record accounting and the terminal
receipt count an already-delivered skip as already delivered, not as
cached or cost avoided; §10 item 6 takes the campaign id only (version
2), and §9's canonical pipeline configures one; §5 PREFLIGHT gains the
optional destination, which §8's preflight line prints; §6 and §8
gtme adapters gain registry process entries (kind, assets,
checksum-pinned, verified only); §10's intro, item 6 and §10a say the
binary carries no vendor, and §10a's graduation rule says a graduated
adapter can still be a registry entry; §11 M35 queued; the Top-up story
gains the clause. The one wire change is additive (PREFLIGHT
destination); no exit code changes.
v0.55 — 2026-09-29 (ADR-060/061 reconciliation: deliveries in flight at a crash, interrupted runs; build queued as M34)
Changed: §3 runs gains pid and host (migration 0014),
step_events.event gains dispatched, and deliveries.status gains
unconfirmed; §8's run line gains --resend-unconfirmed, deliver
idempotency gains the in-flight rule (one record per session,
dispatched before the send, unconfirmed records held unless the target
is natively idempotent), record accounting names N unconfirmed, and a
new subsection defines the run lock, the derived interrupted status and
the two --resume refusals (a live run, a done run); §10a
http/deliver sends Idempotency-Key; §11 M34 queued; the Top-up and
Recover stories gain the clauses. No wire change; the exit codes are
unchanged, and both refusals use exit 2.
v0.54 — 2026-09-28 (issue #171: mock-enrich-py's field names)
Fixed: §10 item 7 and the golden transcript spec/wire/basic-run.ndjson
still named mock-enrich-py's fields mock_score/mock_note. The adapter
has emitted the namespaced mock.score/mock.note since M7 (DECISIONS.md,
2026-08-15, "M7 internals": renamed under §4a's namespacing rule), so both
now say so; the transcript's enrich half was re-recorded by piping its
recorded runner lines into adapters/mock-enrich-py/run, and only the field
names changed. No behaviour changed.
v0.53 — 2026-09-27 (M33 build: vendors leave the binary, built)
Changed: §11 M33 marked built; no normative text changed — v0.51's
contract is shipped behaviour, covered by M33's acceptance. Behavioural
notes from the build: an each: limit is a positive whole number or
exactly {{ config.<key> }}, and renders kept (not elements read) count
toward it; its path takes | alternatives like any extraction path, and
harvest/recent-posts reads elements|element; a config key an entry
retires is declared not: {} with a description, and plan prints
config <key> is not accepted: <description> — that is how
harvest/profile@2 names harvest/recent-posts for posts_limit;
harvest/recent-posts takes its cost from cost_per_call_usd, default
0.012 because HarvestAPI's docs publish no price for the posts endpoint
(checked 2026-09-27); add with several references reports each failure
as gtme: <ref>: <error>, installs the rest, and exits with the first
failure's code; the unknown-adapter error carries the gtme adapters add <id> line for any id with a slash, wherever an id resolves, not only in
plan; spec/bindings/ keeps apollo-search, byte-identical to its
registry entry, and help --bindings says which entry it mirrors;
attio-assert's fixtures gained an input member so adapters verify
can drive it in the registry's CI.
v0.52 — 2026-09-26 (M32 build: plain words, built)
Changed: §11 M32 marked built; no normative text changed — v0.50's
contract is shipped behaviour, covered by M32's acceptance. Behavioural
notes from the build: the citation strip covers every printed string
outside help_agent.go and help_bindings.go, including built-in
adapters' config-schema descriptions (which docs/_adapters.json
regenerates from); the ends in group line keeps a parenthetical,
(records that complete the run are added), so the terminus still says
what it does; identity_key_tier reports the type file's field name, so a
company keyed by domain prints company_domain and a hashed key
name_hash, derived by re-running each tier's rule over the stored key in
tier order; the simulation-gap lines say recorded responses with the
banner; examples/cache.yaml is removed, not aliased, and the bundles'
committed receipt.txt files carry the new wording (their tables are
unchanged). (v0.51 is ADR-059's reconciliation, on its own packet.)
v0.51 — 2026-09-26 (ADR-059 reconciliation: vendors leave the binary; build queued as M33)
Changed: §6 names the plan error for an uninstalled use: id; §8
adapters add takes several references and bare registry ids, and the
binary carries the floor and no vendor; §9's canonical pipeline gains a
harvest/recent-posts step; §10's intro says vendor entries are registry
entries, items 2 and 2a note the move, item 4 is harvest/profile
version 2 (one call, role_history via each:, cost from config), new
item 4a harvest/recent-posts, item 6 is the one vendor still built in;
§10a gains the each: extraction form and drops the reference-twin
carve-out; §11 M33 queued. No wire, DDL or exit-code change; the
manifest surface changes only for harvest/profile, whose version
bumps.
v0.50 — 2026-09-26 (ADR-058 reconciliation: plain words on the operator surface; build queued as M32)
Changed: §7 "typed segments" become typed legs, and the plan labels
(reads:, of: … (the value under review), ends in group …); §8 gains
the plain-words rule (no ADR-/§ citations on the default surface),
the receipt words for coalescing, the simulate banner wording, and
identity_key_tier on gtme show; §10 item 9 and §11 M29 name
examples/hello.yaml; §11 M32 queued. No wire, DDL, manifest or
exit-code change.
v0.49 — 2026-09-13 (M31 build: one dialect, built)
Changed: §11 M31 marked built; no normative text changed — v0.47's
contract is shipped behaviour, covered by M31's acceptance. Behavioural
notes from the build: a binding leaf is a Liquid object in the shared
dialect ({{ … }} with the filter allowlist) and never a block, so
{% if %} in a request template fails verify as much as an unlisted
filter does; the typed-leaf and omitempty rules are unchanged (a leaf
that is exactly one placeholder substitutes the typed value, a leaf any
of whose placeholders is empty is omitted); the retired {{a|b}}
alternatives are caught before the parser (which would only say "syntax
error") and refused naming the | default: rewrite, at Parse, so
adapters verify, adapters add and every load see it; the five
built-in bindings' conformance fixtures produce byte-identical requests
(the harvest URL was the one rewrite); render.template was already
refused in M30, so this milestone's render: half was the parser move;
cost.amount_usd evaluates through the same object evaluator.
spec/binding-schema.json's templating description follows.
v0.48 — 2026-09-13 (M30 build: template: and text/compose, built)
Changed: §11 M30 marked built; §10 item 10 marked built; no normative
text changed — v0.47's contract is shipped behaviour, covered by M30's
acceptance. Behavioural notes from the build: a with: key a template
references (config.<key>) is set aside before the adapter's closed
config_schema validates the rest, so a persona can live beside the
template without the manifest declaring it, while an unreferenced stray
key is still refused as a typo; the pipeline loader reads {file:}
relative to the pipeline file into the step's config, so the planner,
the runner and runs.config_json all see the source and only freeze --bundle needs the reference (kept beside the step, packed under
templates/); an ai/* template renders once at runner start and the
adapter receives text under the same key; human/*'s surface and
text/compose share one renderer, lenient on an absent field (| default: fills it, as on_missing: run dispatches it); a text/*
output counts as fetched for the fence when any field the step read did,
traced through the signature in its provenance. M31 (bindings, render:
and the cost template on the same parser) stays queued.
v0.47 — 2026-09-13 (ADR-057 reconciliation: template: and text/compose; build queued as M30, M31)
Added: §7 the template-scope check and the signature over the loaded
source plus referenced config; §9 template: (string | {file:}) on
every participant step, with.prompt retired as an ai/* text key; §10
item 10, text/compose and the bounded Liquid dialect; §10a text/compose @ #<sig>; §11 M30 and M31; §2 github.com/osteele/liquid. Changed:
§8 bundle wording (template files travel; bare freeze inlines) and the
participants' surface; §9 the example (template:, a text/compose
step) and the uses: roles; §10 items 3 and 3b say template:; §10a the
participant signature names template:/render.fields. Not
changed: nothing built — M30 and M31 are queued;
spec/schemas/pipeline.schema.json, the manifests, examples and bundles
ride the build.
v0.46 — 2026-09-06 (ADR-056, demo/enrich; built as M29)
Added: §10 item 9, demo/enrich — a built-in, priced, keyless,
deterministic enrichment that runs armed, so the zero-key onboarding
path prints the top-up receipt on a persisting ledger; §11 M29. The
item's freshness_days is the manifest's, overridden per step by
cache: like every adapter's (the proposed text had it as config).
README.md, ADAPTERS.md, START.md and examples/cache.yaml ride the
build.
v0.45 — 2026-09-06 (ADR-055: webhook/source deferred; accepted by merge, no build)
Removed: §10 item 8, webhook/source — specified from ADR-009's
reconciliation, never built (AUDIT.md), now on ROADMAP.md with listen
as the design pass it returns with.
Changed: §8 the event recipe states what ships (a scheduled run over
a CSV a receiver writes, through csv/source) and what is deferred;
§10a the universal floor's In set; §11 M5's unmet webhook/source
acceptance clauses struck with a note; §13 the no-daemon answer cites the
scheduled run, not the adapter. README.md and ADAPTERS.md drop it. No
code changes.
v0.44 — 2026-09-06 (bookkeeping after M28)
Changed: §4a describes the hash-tier component forms the type-file
schema already admits ({any: […]}, {join: […]}, first required),
which v0.43 named only in the changelog. The 2026-09-05 M28 internals
decision v0.43 pointed at is now actually recorded in DECISIONS.md.
spec/ledger.sql's comment on identities.entity_type matches §3. No
behavior changes.
v0.43 — 2026-09-05 (M28 build: types and traverse, built)
Changed: §11 M28 marked built. No normative text changed beyond that.
Implementation choices — how a hash tier expresses §4's name fallback
({any: […]}/{join: […]} components, first required), how the segment
after a traverse is derived from the traverse's own events rather than a
column (the same-type case ADR-054 (6) allows needs it), one parent per
session as the wire's parent attribution, a legacy group staying untyped
on its first write, references written wherever their field is, SQL
steps taking their segment's type, and the plan's writes: line — are
recorded as the 2026-09-05 M28 decision. The schema for type files admits
the hash-component forms; spec/schemas/pipeline.schema.json admits
limit: on a traverse step; migration 0013 adds groups.entity_type.
v0.42 — 2026-09-05 (ADR-054: types and traverse; build queued as M28)
Added: §4a type files — kind: subject | signal, an ordered
identity tier list §4 now reads, per-field reference (the runner
writes the relation; works_at becomes company_domain's declaration),
discovery from the binary, ~/.gtme/types/, and each installed
binding's types/ in place (embedded names reserved), and the
three-check adapter–type contract run by plan and adapters verify;
post seeded. §4 the url rule. §6 the traverse
role with from and relation; §10a the traverse binding role and
sql/traverse. §7 segment typing, the contract checks, plan's type-change
and relation-write annotations, and group type checks. §8 the traverse
receipt line, gtme groups add --type, the type column, and gtme groups show's producers and consumers. §9 the traverse step and a typed group
source. §11 M28 queued.
Changed: §3 groups.entity_type (nullable; a pre-existing group is
entity-blind until set), the identities and groups comments. §5 a
traverse's RECORDs name the output type. §13 the fan-out non-goal
retires; relations that end is named in its place. Entity types decided:
two kinds of type; account, deal, campaign, segment, event, persona,
offer, value proposition are not types (ADR-054 (10); packs on
ROADMAP.md).
Status: accepted 2026-09-05 by merging the packet; built as M28.
v0.41 — 2026-09-04 (M27 build: record accounting, built)
Changed: §11 M27 marked built. Two clarifications the build needed,
both inside ADR-053's decision: §8 says what in now counts — every
record eligible at the step, since the reconciliation in = out + empty + filtered + failed + gated + skipped + cached cannot hold otherwise (before
this, in was records handed to the adapter, so a fully cached step read
0 in, 0 out, 3 cached; it now reads 3 in, 0 out, 3 cached) — and that
a record in flight, held by a dry run, or passed through a simulation gap
is the named non-terminal remainder. §5 says a RECORD with no fields
asserts nothing and is not validated: it is how http/enrich hands the
runner an oversized response to retain (ADR-053 (4)) without claiming a
field. on_missing: gains run in §9's vocabulary and
spec/schemas/pipeline.schema.json; run is refused on a deliver step.
The receipt table gains an empty column; the step line prints empty
beside out when non-zero, and skipped, simulated and held (dry run) when non-zero; a participant step's absent declared fields print as
(N missing f1, f2) and a receipt line names the policy. The source line
reconciles (sourced 931 records (9 coalesced into known identities)) and
each coalesce is a coalesced step event on the identity that won, with
the row's own keys in detail. A paid zero-record run titles its receipt
and its gtme runs entry done — 0 records, $X spent (estimated).
Implementation choices — what "coalesced" means, what "empty" means, and
the remainder columns — are recorded as the 2026-09-04 M27 decision.
v0.40 — 2026-09-04 (M26 build: bounded group consumers, built)
Changed: §11 M26 marked built. §3 runs gains dry INTEGER NOT NULL DEFAULT 0 (migration 0012, appended last, no rebuild): the build found
that ADR-052 (7) — a dry run finishes nothing — rested on dry runs writing
no run state, which §8 (ADR-019) contradicts: a dry run has its own run
row, and its records advance to the deliver step's state. Without a
marker a rehearsal would have advanced the queue; the e2e that proves the
rule failed before the column and passes after it. §8's dry-run paragraph
says the row is marked and gtme runs shows it (done (dry)); the
once: paragraph names the actual mechanism instead of the premise.
Human-approved in conversation on 2026-09-04. Terminality is judged
against each run's own snapshot (the final step it declared when it ran),
so a pipeline that later grows a step does not re-offer everyone it had
already worked — recorded as an M26 implementation decision. gtme help --agent's human-review-then-cron recipe carries once: true on the
scheduled half, since that composition is what the key exists for.
v0.39 — 2026-09-04 (ADR-053: record accounting; build queued as M27)
Changed: §10 (http/enrich: an oversized response is retained and
its record counts empty); §7 (on_missing: on a participant step, default run, and the
receipt's missing-field report); §8 (the empty column and the per-step
reconciliation, the source's rows-read/records-sourced line with
coalescing recorded per record, the paid-zero-record mark); §9
(on_missing: beyond deliver steps); §11 gains M27.
spec/schemas/pipeline.schema.json rides the build.
Not changed: §3 — the coalescing record is a step_events row, which
§3 already carries, so M27 has no migration. Exit codes are untouched by
design (ADR-053 (5)).
Numbering: assumes ADR-052 / M26 (the once: packet) lands first. If
that packet is rejected, this becomes ADR-052 / M26 at merge.
Status: accepted 2026-09-04 by merging the packet; built as M27.
v0.38 — 2026-09-04 (ADR-052: bounded group consumers; build queued as M26)
Changed: §8 gains the once: selection rule (finished = completed or
filter-stopped; failed and pending stay eligible), the plan line carrying
the eligible count, and the dry-run rule; §9 gains once: in the group
source grammar; §11 gains M26. spec/schemas/pipeline.schema.json rides
the build.
Not changed: §3 — by ADR-052 (4) the scope is the pipeline name and
nothing new is persisted, so this milestone carries no migration. (The
build overturned this: see v0.40 — runs.dry was needed for (7).) A group
source without once: behaves exactly as before, so no shipped pipeline
changes meaning.
Status: accepted 2026-09-04 by merging the packet; built as M26 (v0.40).
v0.37 — 2026-09-04 (M25 build: plan visualization, built)
Changed: §11 M25 marked built. No normative text changed beyond that
mark and one acceptance criterion: v0.36 had already written the surface,
and this pass built it. internal/planner/viz.go renders the resolved plan
as a diagram — a pure function over the existing plan, so the planner is
untouched and the default listing is byte-identical with no flag; --viz
appends it, --viz-only replaces the listing, and both together are a
validation error. gtme help --agent carries the flags, since §8 requires
it to be the full surface. M25's acceptance criterion was rewritten to name
a role-complete pipeline rather than "both shipped examples":
examples/demo.yaml does not pass gtme plan today, a divergence from
README.md that predates this work and is recorded in AUDIT.md.
v0.36 — 2026-09-03 (ADR-051: plan visualization)
Added: §7 — gtme plan --viz appends a diagram of the resolved plan to
the default output, --viz-only prints it instead. Both render the same
resolved plan on stderr with no network and no spend; the default output is
unchanged and stays the normative surface for §7's MUST-print items, so a
fact appearing only in the diagram is a defect. §8's verb table gains the
flags and states that no verb is added. §13's "dashboards or any UI"
non-goal gains a parenthetical placing a one-shot static render outside it.
§11 gains M25. The shape and glyph vocabulary lives in ADR-051,
deliberately not in the spec: a second implementation must print the
listing, and is free to draw or skip the picture. No schema, ledger, wire,
or exit-code change. Build queued as M25 (until it lands, decided contract,
not shipped behavior).
v0.35 — 2026-09-02 (M24 build: participants, built)
Changed: §11 M24 marked built. No normative text changed beyond that
mark: v0.34 had already written the participants surface, and this pass
built it. Migration 0011 adds field_values.referent; step_events
carries answered; human/filter|compose|review and their agent/*
aliases ship as runner-owned manifests with no protocol session; the
planner validates of:/render: as uses:, refuses engine: naming
agent/*, refuses when: <review>.passed, and prints the cron note; the
runner pends participant records under <run-id>/<step-id>, walks them
in-run at a terminal, and collects answered events into facts with
human/<name>/agent/<name> provenance, the referent, and the
participant's cost; gtme answer and gtme show --run --pending join the
verb set; the claude-code engine is deleted. spec/ledger.sql, the
pipeline schema and the participant manifests ride this entry.
Not changed: the wire protocol (a participant step never opens a
session), and every non-participant step's behavior.
v0.34 — 2026-09-02 (ADR-048..050 reconciliation: participants; build queued as M24)
Changed: §2 (API is the only model engine; engine:
removed; claude-code retired); §3 field_values.referent,
step_events answered; §6 the review role and the
credentials_optional example; §7
of:/render: validation, the cron note; §8 gtme answer, gtme show --run --pending, the "People and agents answer" subsection,
--provenance shows the referent and note, the verb set; §9 of:,
render:, prompt: grammar, engine: a plan error; §10 items 3, 3a
(ai/review), 3b (human/*, agent/*), 5; §10a provenance form; §11
milestone M24. Schema artifacts (spec/ledger.sql, the pipeline and
manifest schemas) ride the build.
Not changed: nothing built — M24 is queued (accepted 2026-09-02 by
merging the packet).
v0.33 — 2026-09-01 (M23 build: honest costs + engine-owned limit, built)
Changed: §11 M23 marked built; no normative text changed beyond
v0.32 — migration 0010, both schema artifacts (spec/ledger.sql,
spec/binding-schema.json) and the COST message schema
(spec/schemas/msg-cost.schema.json admits basis) landed with the
build. Behavioural notes: built-ins label estimated explicitly on the
wire rather than relying on the absent default; a $0 total with
estimated rows prints $0 (estimated) (an unset rate stays visible
after the run, not just at plan); the plan-time rate resolves with the
binding's config_schema defaults applied, so plan and run agree.
v0.32 — 2026-08-31 (ADR-046/047 reconciliation: honest costs, engine-owned limit; build queued as M23)
Changed: §3 costs.basis; §5 COST basis with the
reserved-measured rule; §7 plan prints est/record: unset for an
unresolved templated rate; §8 receipt totals carry their basis; §10a
cost declaration may template amount_usd from config, page-billed
guidance (per: request), and limit as the source role's reserved
engine key; §11 milestone M23. Schema artifacts
(spec/binding-schema.json, spec/ledger.sql) ride the build.
Not changed: nothing built — M23 is queued. cost_estimate_usd
templating and registry-maintained cost declarations deferred to
ROADMAP.md (ADR-046).
v0.31 — 2026-08-31 (M22 build: on-change re-delivery, built)
Changed: §11 M22 marked built; no normative text changed beyond
v0.30 — migration 0009, both schema artifacts, and the plan's
redeliver: line landed with the build. Behavioural notes: variables
resolve before the dedupe decision on deliver steps; a re-delivery
upserts the existing row (first created_at kept, hash/run/status
refreshed); the skip reasons unchanged and already_delivered are
distinct in step_events.
v0.30 — 2026-08-31 (ADR-045 reconciliation: on-change re-delivery; build queued as M22)
Changed: §3 variables_hash; §6 manifest idempotency: native|ledger; §8 redeliver modes with adapter-gated defaults; §9
redeliver: grammar; §10a's binding key meaning completed; §11 milestone
M22. Schema artifacts ride the build.
Not changed: nothing built — M22 is queued.
v0.29 — 2026-08-31 (M21 build: scoped delivery dedupe, built)
Changed: §11 M21 marked built; no normative text changed beyond
v0.28 — migration 0008 rebuilds deliveries (SQLite cannot alter a
UNIQUE), spec/ledger.sql mirrors the migrated shape, and the doc's
ledger surface describes the triple key. Behavioural notes: the scope
resolves from the step's resolved config at run time (group handoffs and
undeclared adapters stay ''); gtme show prints scope on a delivery
when present; attestation updates address only their own scope's row.
v0.28 — 2026-08-31 (ADR-044 reconciliation: scoped delivery dedupe; build queued as M21)
Changed: §3 deliveries gains scope and the triple
UNIQUE; §6 manifest idempotency_scope; §8 deliver idempotency rewritten
around (target, scope, idempotency) with the global guarantee moved to
suppression groups; §10 declarations (instantly: campaign — the
configured name; attio: object; csv/deliver: path); both schema
artifacts; §11 milestone M21. Editorial: §8's doubled heading. One-time
migration consequence stated in ADR-044.
Not changed: nothing built — M21 is queued. spec/ledger.sql changes
ride the M21 build (it is machine-compared to the migrated database).
v0.27 — 2026-08-30 (M20 build: the Apollo split, built)
Changed: §11 M20 marked built; one normative amend found by the build —
§10 item 2's masked provides gains last_name (the vendor's obfuscated
form): a masked row carries no email, linkedin or domain, so §4's
name-hash tier is its only identity path and that tier requires a first
AND last name; the reveal supersedes the obfuscated value. Behavioural
notes: apollo/search is version 2; apollo/enrich joins the embedded
built-ins; pagination termination is empty/short page (the masked
response has no pagination object); a missing-need plan error now names
installed adapters that provide the field ("email ← apollo/enrich");
live smoke 2026-08-30: 3 masked rows $0, 3 reveals $0.03, one contact
legitimately email-less (absent-tolerance held).
v0.26 — 2026-08-30 (ADR-043 reconciliation: the Apollo split; build queued as M20)
Changed: §10 item 2 rewritten (apollo/search masked, per
the vendor's 2026-08-30 withdrawal) and item 2a added (apollo/enrich,
people/match, per-credit reveal); §9's canonical example gains the
reveal step — filter on free fields, pay past the filter; §11 milestone
M20. AUDIT.md (b) item 4 is applied by this diff.
Not changed: nothing built — M20 is queued.
v0.25 — 2026-08-30 (M19 build: the bindings registry, built)
Changed: §11 M19 marked built; no normative text changed — v0.23's
§6/§8/§10a contract is shipped behaviour, covered by M19's acceptance.
Behavioural notes from the build: the GitHub API and codeload endpoints
are env-overridable (GTME_GITHUB_API, GTME_GITHUB_CODELOAD) so the
offline acceptance drives a local tarball server with the address
grammar unchanged; the content hash is sha256 over the binding
directory's files (sorted slash paths, path NUL body NUL, .source.json
excluded); .source.json also records the repository url and path,
which update re-fetches from; a fixtures file may carry optional
config and input members so verify can drive a real engine run;
the registry index is consulted best-effort at add — unreachable
warns and skips the hash check, a mismatch refuses; ADR-030's fixture
minting stays ROADMAP-parked (no §8 verb exists for it) — the first
registry entry's fixtures are minted registry-side.
v0.24 — 2026-08-30 (M18 build: help --bindings, built)
Changed: §11 M18 marked built; no normative text changed — v0.23's
§8 contract is shipped behaviour, covered by M18's acceptance.
Behavioural notes from the build: the document is one JSON object with
the schema spliced in verbatim as its last member (an encoder would
compact it); the reference binding is whichever shipped binding is
fullest by bytes (amended 2026-08-30 with ADR-041; first built as
smallest, which picked the extract-less deliver binding), with its
fixtures file beside it; the discovery
section prints the live search path; ADR-042's adapters verbs appear
under a registry member flagged as queued until M19 ships them, so
the document never names a verb this binary lacks.
v0.23 — 2026-08-29 (ADR-041/042 reconciliation: the adapter surface; builds queued as M18 and M19)
Added: §8 gtme help --bindings (the second agent surface,
with its own round-trip criterion) and the gtme adapters verbs (registry
search, verified install, pin, update); §6/§10a URL-addressed, pinned
bindings via .source.json and the registry tier; §11 milestones M18 and
M19; spec/schemas/registry-index.schema.json. Changed:
§13's non-goal narrowed to a hosted marketplace — the registry (an
index and a fetch verb) is in scope. AUDIT.md (b) item 3 is applied by
this diff.
Not changed: nothing built — M18 and M19 are queued.
v0.22 — 2026-08-29 (M17 build: deliver preflight, built)
Changed: §11 M17 marked built; no normative text changed — v0.21's
contract is shipped behaviour, covered by M17's acceptance. Behavioural
notes from the build: the preflight session runs in runStep before the
step's records are even prepared, so a blocked armed run leaves them at
the previous state with no claimed events; the outcome is recorded as a
step-level preflight event (detail: status, reason, checks); a dry run
reports blocked and continues (nothing sends anyway); Instantly's
first-class targets (first_name, last_name, company_name,
personalization) map into the lead body and are not checked against
the template, only merge variables are.
v0.21 — 2026-08-29 (ADR-040 reconciliation: deliver preflight; build queued as M17)
Added: §5 PREFLIGHT and OPEN preflight; §6 preflights
capability; §8 deliver preflight (dry-run/arm behaviour, receipt); §10
item 6 Instantly's checks; §11 milestone M17;
spec/schemas/msg-preflight.schema.json, msg-open.schema.json,
manifest.schema.json, the wire README. Editorial: §11's M15/M16 order.
Not changed: nothing built — M17 is queued.
v0.20 — 2026-08-29 (M16 build: the judgment cache, built)
Changed: §11 M16 marked built; no normative text changed — v0.19's
contract is shipped behaviour, covered by M16's acceptance. Behavioural
notes from the build: the runner computes both keys (the signature from
the step's config, resolved model and provides schema; the input hash
from the projection), so the adapter is untouched; the signature's model
is the one the armed run would use, never the simulate override, so a
rehearsal skips what an armed run skips; a cached filter fail counts as
both cached and filtered on the receipt; cache: 0d on an AI step is
respend: true; collected (ADR-038) records carry the keys too, so a
re-run after a collection submits only what changed.
v0.19 — 2026-08-29 (ADR-039 reconciliation: the judgment cache; build queued as M16)
Added: §7 judgment cache for AI roles (signature + input
hash, no window by default, cache:/respend: as the knobs); §11
milestone M16. Changed (proposed): §7 respend warning narrowed to
paid enrich/verify; §10a provenance ai/<op> @ <model-id>#<signature> and
the done event's signature/input detail keys (prose; no DDL).
Not changed: nothing built — M16 is queued.
v0.18 — 2026-08-29 (M15 build: asynchronous steps, built)
Changed: §11 M15 marked built; no normative text changed — v0.17's
contract is shipped behaviour, covered by M15's acceptance. Behavioural
notes from the build: the fixture engine's batch surface persists its
batches and script cursor beside the script (GTME_AI_FIXTURE_DEFER),
since a provider holds a batch across processes; a deferred submit sends
one request per record with the shared prompt as a cached block; a
collection with an invalid answer for a record fails that record by
omission (there is no retry against a batch); gtme runs shows an
in-flight count for pending runs.
v0.17 — 2026-08-29 (ADR-038 reconciliation: asynchronous steps; build queued as M15)
Added: §5 PENDING message and OPEN pending, with the
in-flight rules; §8 in-flight steps (deferred: true as the pipeline's
last step, the pending run status, collect-first gtme run with
--resume as its explicit form, receipt wording); §7 the respend warning
and §9 respend: true; §9/§10 items 3 and 5 deferred: true; §11
milestone M15; spec/schemas/msg-pending.schema.json,
msg-open.schema.json. §3 DDL comments: runs.status gains pending,
step_events.event gains pending|collected — comments only, no
migration, mirrored to spec/ledger.sql.
Not changed: nothing built — M15 is queued.
v0.16 — 2026-08-28 (M14 step 1 build: canonical: true on declared AI provides)
Added: §7/§9 canonical: true on a declared AI output field — the
explicit mapping onto a canonical field that §4a/§7 implied without
naming (found building ADR-033, queued in AUDIT.md (b), human-approved
2026-08-28); §4a tier 3 reworded to point at it. Every bare name
namespaces otherwise, canonical-looking or not, and gtme plan notes the
coincidence. spec/schemas/pipeline.schema.json: a provides map value
is null or an object of type/enum/canonical. §6 entity-agnostic
manifests: "entity_type": "*" names what §10.3 asserted without an
encoding (AUDIT.md (b), approved 2026-08-28); a source may not declare
it; manifest.schema.json describes the sentinel; the two AI manifests
declare it.
Added: §5 ATTEST — the wire form of ADR-036's three-way verdict,
which its spec-impact list left out (found building step (5); approved
2026-08-28): spec/schemas/msg-attest.schema.json, the wire README's
table, and the attests key in manifest.schema.json.
Changed: spec/ledger.sql and §3's DDL — M14's migration 0007
landed (step (4)): deliveries.status/sent_at, the current_values
and group_membership views; §3's queued-deltas note now records them as
landed. §10a heading typo (sql/enrich renamed sql/transform).
Changed: §11 M14 marked built — all five steps and the account-pattern
capstone run offline in the suite.
v0.15 — 2026-08-28 (ADR-032/033/035/036/037 reconciliation; build queued as M14)
Added: §7 declared AI provides, config values from the ledger, SQL at
plan (EXPLAIN, cross-record annotation), the one-commit-point warning;
§8 group/deliver, group-source limit:, groups remove --note,
delivery status semantics (accepted, attestation, sent only by
attestation), ledger read surface in help --agent; §9 provides: on AI
steps, limit: on the group source, {query:}/{segment:} config
values with example; §6 attests capability; §5 VERDICT+RECORD from a
filter; §4a <pipeline>.<field> default for AI outputs; §3 queued schema
deltas (deliveries.status/sent_at, two vocabulary views — DDL lands
with M14's migration); §11 milestone M14.
Changed: §10 items 3 and 5 (declared provides, prompt assembly rules,
fence, entity-agnostic manifests, AI steps hold no tools); §10a
sql/enrich renamed sql/transform throughout the normative text with
its two semantics stated (reads any identity; never caches).
spec/schemas/pipeline.schema.json: step gains provides; group source
gains limit.
Not changed: spec/ledger.sql — DDL moves with its migration, as
always. ROADMAP.md: expand retired by composition; Groups option C
refusal recorded as tested; SQL segments half delivered; three new
entries (patterns as bundles, asynchronous steps, deliver preflight).
v0.14 — 2026-08-17 (M13 build: delivers as steps, built)
Changed: §11 M13 marked built; no normative text changed — v0.13's
contract is now shipped behavior, covered by M13's acceptance tests.
Behavioral notes from the build: a document carrying the old top-level
deliver: block fails validation with an error naming the fix (move the
block into steps:); a withheld send (on_missing: skip, suppression)
advances run_records.state to the deliver step, which is what makes the
record visible to later steps and the terminus; gtme plan renders the §7
deliver call-out as one block after the step list (send surface: N deliver step(s), one line per step with target and touch scope); two
deliver steps naming the SAME target adapter share that target's
(target, idempotency) dedupe scope — the §3 key is per target, not per
step, so independent dedupe (ADR-031's promise) is per distinct target;
gtme runs' record summary counts fail verdicts without classifying them
(a bare run id carries no step roles) and says so. examples/, README's
quickstart, VALIDATION.md's pipelines, gtme help --agent's canonical
pipelines, and the e2e fixtures all moved off the old block in the same
change.
v0.13 — 2026-08-17 (ADR-031: delivers as steps)
Changed: The top-level deliver: block is gone; deliver adapters
are ordinary steps: entries — any number, any position (§9) — with
variables:/on_missing:/idempotency:/record:/suppress:
role-gated to deliver steps and every §8 deliver semantic (idempotency
scope, dry-run withholding, resolved-variables receipt, touch scope,
suppression) applying per step. New advancement rule made explicit:
on_missing: skip and suppression withhold the step's send but the
record advances; fail freezes it. Terminus clarified as completers-only
— a mid-pipeline delivery followed by a later failure does not join;
record:'s touched events remember sends. §7: gtme plan MUST call
out each deliver step (target, touch scope) — the at-a-glance send
surface moves from YAML position to plan output.
spec/schemas/pipeline.schema.json updated; milestone M13 queues the
build (until it lands, decided contract, not shipped behavior). Rejected
alongside (recorded in ADR-031): a run-scoped step cardinality —
aggregate exports are batch: true delivers; run-summary notifications
are a run-lifecycle hook, parked in ROADMAP.md.
v0.12 — 2026-08-16 (the name: gtme)
Changed: The project, binary, and every surface derived from them are
named gtme — as in GTM engineer — resolving the naming question
before first publication: gtm collides with existing tools. Renamed
wholesale (pre-public, zero users, no compatibility shims): the binary
(§2), the env prefix (GTME_LEDGER, GTME_CONCURRENCY, GTME_AI_*,
…), the home directory (~/.gtme), the schema $id host, and the
module path (github.com/gtme-run/gtme). Historical document text
was renamed too — this changelog's earlier entries read as if the tool
was always called gtme, which pre-publication it effectively was. The
one deliberate exception: gtm-campaign-zero-* in VALIDATION.md names
real external Instantly campaigns and keeps its historical spelling.
v0.11 — 2026-08-16 (apollo/search Go scaffolding retired)
Changed: apollo/search is now the registered built-in binding
(spec/bindings/apollo-search/); the Go adapter — never run live (no key
was ever set) and proven redundant by M8's full-field-parity receipt diff
— is removed. First vendor source shipped as pure YAML. The live smoke
test drives the binding engine. harvest/instantly Go adapters remain (they
carry tier-2 capabilities their bindings deliberately omit).
v0.10 — 2026-08-16 (M12: the universal Out floor)
Added: §10a http/deliver (anonymous deliver binding; step-level
idempotency: REQUIRED per ADR-023 — a plan error when missing, never a
default) and csv/deliver (variables as columns plus identity_key,
header-once, append-across-runs, §8 idempotency preventing duplicate
rows); milestone M12. Auth declared in an http/* step's config
(auth.env) now resolves through the credential machinery (§6: env
first, then ~/.gtme/secrets) and is plan-checked. ROADMAP's
"Universal Out floor" entry retired — built.
v0.9 — 2026-08-16 (M11: transform floor + ADR-030 payload retention)
Added: §3 payloads DDL — the ADR-030 cache tier, explicitly exempt
from append-only, never projected (mirrored in spec/ledger.sql); §5
optional payload attachment on outbound RECORDs — the capture path
ADR-030's mechanism requires, called out here because the ADR's
spec-impact list named §3/§6/§8/§10a and this is its minimal §5
counterpart (spec/schemas/msg-record-out.schema.json updated); §6
keep_payloads/payload_ttl_days retention surface with per-step
override (Go vendor adapters attach nothing yet — a recorded queued
adoption); §8 gtme vacuum and opportunistic run-start eviction; §10a
http/enrich and sql/enrich/sql/filter contracts as built (config
surfaces, 256 KB default cap, :run_id binding, identity_id result
contract, query-hash provenance, membership-style filters); milestone
M11 with acceptance criteria.
v0.8 — 2026-08-16 (M10 build: campaign bundles)
Changed: §8 bundles marked built. Behavioral notes from the build:
gtme freeze now preserves the frozen pipeline's own name (falling back
to frozen-<id> only for ad hoc runs, --name still wins) — a bundle
carries the campaign's identity; bundle adapter resolution takes
precedence over built-ins and the search path while a bundle runs, so
frozen binding versions win; external process adapters do not travel
(executables are not data) and freeze warns per step; a bundle's
relative input files (a source CSV) and credentials remain
operator-provided, per "membership and cache naturally differ". Bundles
carry each referenced binding's conformance fixtures, which is what
makes simulate-on-bundle fully offline; content hashes are verified on
every bundle run and a mismatch is a validation error.
v0.7 — 2026-08-16 (M9: groups — ADR-021 reconciliation)
Added: §3 layer-3 DDL — groups, append-only group_events with
exactly three event kinds (added/removed/touched), and the
group_members last-event-wins view (ADR-003's append-then-derive
pattern); §7 membership gates (require:/exclude:, the
judgment-memory mechanism) and plan-time group checks (referenced groups
must exist; record:/terminus create on demand; windows well-formed;
read-only ledger access at plan time stated); §8 touch scoping
(record:, defaulting to the pipeline name), suppression
(suppress: {group, within} layered above the idempotency floor, skips
receipted), the membership terminus, the
dry-runs-assert-nothing-durable rule, and the gtme groups verb set
(list with derived character, show, add/remove by key,
--from-segment/--query snapshots with evaluation provenance); §9
the five group keys with the qualify/send decomposition example;
milestone M9 acceptance criteria. Mirrored in spec/ledger.sql and
spec/schemas/pipeline.schema.json.
v0.6 — 2026-08-16 (M8 build: binding engine + simulation gate)
Changed: spec/binding-schema.json hardened by the M8 build against the
three real ports and the Attio binding (extraction paths: waterfalls,
absent: sentinel values, skip_if_input:, the engine-owned linkedin
classify-and-route transform, the $variables body splice, pagination
in: body|query, config defaults from config_schema, extract required
only for source/enrich, declared-but-unenforced retry windows refuse to
load). No other normative text changed: §10a's binding tier, its
conformance kit, binding discovery, ai/* model provenance, and §8's
--simulate gate are now built and covered by M8's acceptance tests
(receipt diffs against each Go twin; the campaign-zero shape simulating
end-to-end with zero network calls); §10a's http/enrich and sql/*
sections and §8's bundles remain decided-with-build-queued.
v0.5 — 2026-08-16 (ADR-022..029 reconciliation — spec only, build queued)
Added: §10a two-tier adapter model: declarative bindings interpreted
by one generic HTTP engine, spec/binding-schema.json, the hard
graduation rule, conformance-kit extension to bindings, engine
unification of inline http/* steps, the contract-owner naming rule with
ai/* model-identifier provenance, the OpenAPI-as-codegen-input rule,
http/enrich (JSON + markdown modes, mandatory freshness_days, size
cap, no-JS scope), sql/enrich/sql/filter with declared SQL contracts,
the ai/* purity invariant, and the universal-floor framing
(ADR-022..027); §7 dynamic provides beside dynamic needs (ADR-024); §8
simulation gate — gtme run --simulate, the completed
simulate → plan → dry-run → armed ladder, SIMULATED receipts excluded
from projection/cache (ADR-028); §8 campaign bundles — gtme freeze --bundle, spec/bundle-manifest.json, gtme run accepting a bundle
path, simulate-on-bundle (ADR-029); §11 next-milestones note (binding
engine vs groups sequencing pending).
Changed: nothing shipped changes in this pass — every v0.5 section is
decided contract with build queued, flagged as such in place.
v0.4 — 2026-08-15 (ADR-017/018/019 reconciliation + ADR-020)
Added: §4a canonical field registry with spec/fields/person.json,
spec/fields/company.json, spec/schemas/field-registry.schema.json, and
the three enforcement layers (ADR-017); §4 internal-form LinkedIn rule and
reserved github_username/twitter_handle identity tiers (ADR-020,
resolving the gap flagged under ADR-017); needs: dynamic manifest form
with static floor (§6, ADR-019); variables: egress mapping and
on_missing: skip|fail on deliver steps (§8, §9, ADR-018/019); columns:
ingress mapping on csv/source with auto-map, plan-time suggestions, the
identity-path check, and csv.* namespacing of unmapped headers (§7,
§10.1, ADR-018); gtme run --dry-run and the armed gate (§8, ADR-019);
one-of (anyOf) needs in the planner's contract walk, motivated by
harvest/profile needing any one LinkedIn URL shape (§7, §10.4, ADR-020);
milestone M7.
Changed: §10.6 instantly/add-to-campaign re-contracted to dynamic
needs with an email floor and variables:-driven merge fields — its
hard-coded first_name need and default first_line/ps_line custom
variables are removed; §9's example gained the deliver variables: block;
§7 gained the ADR-019 generalization of ADR-004's uses: mechanics.
v0.2 — 2026-08-15 (spec reconciliation)
Added: §0 Design Principles; RFC 2119 declaration; current_fields SQL
view as the sole current-value projection (ADR-003); uses: for AI-step
dynamic needs and its plan-time validation (ADR-004); gtme show (ADR-006);
gtme help --agent (ADR-007); webhook/source adapter and the spool+cron
event-driven recipe (ADR-009); the eight operator-story acceptance sections
(ADR-012); pointers to spec/ machine-checkable artifacts throughout.
Changed: CLI surface reduced to exactly init, secret, plan, run, query, show, runs, freeze, help --agent (ADR-005); milestones renumbered M1–M6
with M3 (old M4) absorbing plan/receipts/resume plus the new show/help
verbs, M6 (old M7) polish; §1 bet 4 rewritten from "two modes, one engine"
to YAML as the sole pipeline surface, with gtme freeze's job redefined as
run-to-YAML reconstruction rather than pipe-to-YAML conversion; non-goals
gained the pipe-syntax-deferred line and a pointer from the daemon non-goal
to the webhook/source recipe.
Removed: §8 pipe-mode mechanics (the RUN-handshake-over-stdout scheme,
mini-runner-per-process embedding) and the standalone source/filter/
enrich/compose/deliver CLI subcommands (ADR-005); the M3 pipe-mode
milestone.
v0.3 — 2026-08-15 (AUDIT.md category-(b) diffs, approved)
Added: §5 gains a note and worked example that a source's outbound
RECORD key is OPTIONAL (the runner canonicalizes it from fields); §6
gains credentials_optional and batch to its manifest shape and prose —
both already load-bearing in the shipped adapters, previously undocumented.
Also corrects §3 and §9: the field_value_ranks/current_fields view pair
(ADR-003) replaced an earlier, buggy single-view design from this same v0.2
pass, and the uses: example fields were corrected from name to
full_name to match apollo/search's real manifest — see AUDIT.md
category (a) for detail on both; not re-listed here since they were bugs
in v0.2's own text, not new decisions.
v0.1 — 2026-08-12 (initial spec)
Initial DECIDED sections 1–13 as designed before the 2026-08-14 session; see git history for the full text.