This document defines the eight primitives, their operations, their parameters, their defaults, and how they fail.
| Primitive | What it does |
|---|---|
| Payload | carries the data |
| Filter | changes the data |
| Predicate | asks a yes-or-no question about the data |
| Valve | joins a Predicate to a Filter |
| Tap | reads the data as it goes by |
| Hook | sees the lifecycle of a run |
| State | records what happened |
| Pipeline | runs the steps in order |
Python implements it. A runtime in another language implements the same contract, with identifier casing in that language's style, and is judged by the same conformance cases.
- Uniform semantics, uniform vocabulary. A concept has one name. There is no second way to do the same thing.
- Native idioms are deferred, not rejected. Indexers, operators and map protocols are out of scope for now. If added later they must be pure sugar over this vocabulary and must not change any semantics below.
- Sync is the default; async is opt-in. A sync caller must never pay for an event loop.
- Every rule here is checked. Semantics by the conformance cases; the vocabulary by the runner, which may only use the operations named here.
- A feature enters the contract with a conformance case, and Core holds nothing that is not in the contract.
Keys are strings. Values are JSON-shaped: null, bool, number, string, list,
map. There is one Payload type. A write changes the Payload and returns
it. copy() gives a separate Payload for the caller who wants to keep the
one they have.
| Operation | Meaning | Python |
|---|---|---|
| construct | from a native map (copied) | Payload(d) |
| get | value, or default, or absent |
get(k, default=None) |
| has | key present, also for a null value | has(k) |
| insert | set k = v in this payload; returns it |
insert(k, v) |
| remove | remove k from this payload; returns it |
remove(k) |
| merge | write other into this payload, other wins; returns it |
merge(other) |
| copy | a separate payload, same contents | copy() |
| to_dict | native map copy | to_dict() |
| with_trace | set the trace id on this payload; returns it | with_trace(id) |
| trace_id, lineage | observation | trace_id, lineage |
Absent is the language's null: None in Python.
Rules:
insert,remove,mergeandwith_tracechange the receiver and return it, so a step can write and return in one line:return payload.insert(k, v).copy()is shallow: the two payloads have separate keys and share their values. Change a nested value by copying it first.- The caller's input map is never changed (copied on construct).
runworks on a copy of the Payload it is given, so the caller's Payload is not changed either.- Only a Filter writes. A Tap, a Hook and a Predicate read the
Payload. To change the data, add a Filter. The linter is the guard
(
CUP-I004); the runtime does not check. - A Tap, a Hook or a Predicate that wants its own changed version, or that
keeps the Payload for later, takes a
copy().
call(payload) -> payload. A failure raises.
test(payload) -> bool.
- A Predicate is an object. It is identified by its class like a step, and
any setting (a threshold, a flag) is on the instance:
AtLeastPredicate("qty", 10). Functions, lambdas and closures are not Predicates. - A Predicate reads the Payload and does not write to it.
- One Predicate can be used in many Valves.
A Valve is added with one call, the condition first:
pipeline.add_valve(predicate, filter)
- When
predicate.test(payload)is true, the Filter runs. When it is false, the Payload passes through unchanged and the step is recorded instate.skipped, notstate.executed. - The step is identified by its Filter (see "Step identity").
add_valveis the one way to add a Valve. There is no Valve class for a user to make, andadd_filterdoes not accept one.
observe(payload), no return value. A Tap reads the Payload and does not
write to it. A Tap failure never halts the pipeline: the error is
recorded in state.errors under the Tap's identity, the Tap still counts as
executed, and hooks are not notified.
before(step, payload), after(step, payload), on_error(step, error, payload).
A Hook reads the Payload and does not write to it.
stepis the step's identity (see "Step identity"). Pipeline-level events pass absent (None).erroris the error itself (never just its message).- Order on success:
before(pipeline), then for each filter or Valvebefore(step),after(step), thenafter(pipeline). Taps get no hook events. - Order on a filter failure:
… before(step),on_error(step),on_error(pipeline). Noafterevents follow.
executed (identities, in order), skipped (identities), errors
(identity + error), timings (identity → seconds, when timing is on). A run
starts with a new State. A failed run keeps the steps that completed
before the failure.
| Operation | Python |
|---|---|
| construct | Pipeline() |
| add a filter | add_filter(f) |
| add a valve | add_valve(predicate, filter) |
| add a tap | add_tap(t) |
| nest a pipeline | add_pipeline(p) |
| add a hook | use_hook(h) |
| run (sync) | run(p) |
| run (async, opt-in) | run_async(p) |
| last run | state |
| timing and lineage | observe(timing=True, lineage=False) |
| each step's identity, in order | identities |
where each step was added (file:line) |
step_sites |
- Steps have no names. State and Hooks report each step's identity.
runis synchronous and never starts an event loop. If it meets an async step it raisesAsyncStepInSyncRunrather than silently paying for a loop.- A nested pipeline is one step: its identity appears once in the parent's State.
- Timing and lineage are off by default.
A step's identity is a pointer to the code that runs it, derived
automatically: add_filter(f), not add_filter(f, name). State, Hooks and
exported code all report this identity.
Why: in a codebase maintained by agents and reviewed by humans, an identity taken from the code cannot drift from it. A hand-written name is an unchecked label that can describe code which no longer matches it; a pointer to the filter is always true, more specific, and needs no one to maintain it.
- The filter's fully qualified symbol (
app.filters.Validate), plus an occurrence number only when the same identity appears more than once in one pipeline: the second is...Validate#2. Stable across unrelated edits. - Extra detail, not identity: the source location of the
add_*call (file:line), for agents and reviewers. - Valves: the Filter's identity; the predicate's symbol is extra detail.
- Pipelines used as a step are named types:
class UserOnboarding(Pipeline), identified by that type's symbol exactly as a filter is. A plain, unnamedPipelinecan run at the top level but cannot be nested. - Renames and moves change the identity. That is honest (it is different code); cross-version comparisons follow renames from version control.
- A filter defined in the entry script (
__main__.X) is refused like an anonymous one: its identity changes with how the program is started, and tests cannot import it.
Lambdas, closures, classes defined inside a function, inline functions: as filters, taps, valve predicates or nested pipelines. They are not independently testable and have no stable identity. Enforced in two layers:
- The runtime refuses them at
add_*time (AnonymousStep), with a message naming the fix. - The linter flags them at authoring time:
python -m codeupipe_lint.anonymous_steps <paths...>. One line per anonymous step,path:line:col CUP-A00N slug message; exit 1 when it finds any. An intentional one (a test of the refusal) is suppressed with acup: allow-anonymouscomment. Rules and limits: packages/codeupipe-lint/README.md.
Conformance. The shared cases name their steps; the runner builds every step, so it maps each reported identity back to the case's step name. An identity that is wrong, or differs between the dynamic run and an export, does not map and fails the comparison.
- Native idioms (principle 2): the
Mappingprotocol, indexers, operators. - Parallel steps and streaming. They join the contract when conformance cases exist for them.
- Detaching async Taps from the result. It needs its own decision, since it changes timing for callers who wait on a Tap's side effect.
- Other runtimes. A runtime in another language is written to this contract and added with its conformance runner.