contract run · 30-run.md · 31-pipeline.md · 32-derive.md · guide · card
Pipelines and the write path
A pipeline declares a source and the tables that receive its rows. The compiler produces a
plan, the runner lands source batches in the order land defines, and a build publishes
committed tables:
declare
Pipeline declarations: fields, manifest loading, content hashes, table names and lifecycle commands.
pipeline-spectesttesttestA pipeline specification takesPipelineSpec, itssourceanddestinationeachSourceBlock, and eachtablesentry a bare name or a table block, the two forms mixing in one array.serializationtestTOML and JSON deserialize into onePipelineSpec; the JSON Schema derived from it is the contract, andcontextful schema exportwrites it to.contextful/schema/pipeline.json.manifest-filetestStartup reads project config and inline[[pipeline]]blocks fromcontextful.toml, then readspipelines/*.tomlandpipelines/*.json. Startup collects specifications byid.manifest-missingtestpipeline validatereading no manifest file raisesPipelineManifestMissing, naming the declaration path.Why
because a validation over a mistyped path otherwise checks nothing and exits zeroduplicate-idtestOneiddeclared twice raisesPipelineDuplicateId, naming each file and the line its declaration starts on.Why
because a silent pick between two declarations hides which one runsspec-invalidtestA manifest file the canonical type cannot deserialize raisesPipelineSpecInvalid, naming the file, the key path and the value found.Why
because a partly read manifest runs a pipeline its author did not writecontent-hashtestcontent_hashis the sha256 of the specification’s RFC 8785 canonical JSON with every optional field at its default elided, so an explicit default hashes as its absence.Why
because a replay pin that moves on a no-op edit strands pending worktable-nametestThe engine names destination tables<pipeline id>_<table name>, replacing each non-alphanumeric character with_and lowercasing each ASCII uppercase letter.table-name-collisiontestTwo tables whose destination names fold to one spelling, or one whose name is another’s<name>_dead_letter, within one pipeline or across pipelines, raisePipelineTableNameCollisionat validation, naming both declarations.Why
because two tables folding to one name land in one store table and interleave their rowsunbound-table-nametestA job target or model reference naming a destination table in a spelling the fold does not produce raisesPipelineUnboundTableName, printing the expected spelling.Why
P1order-by-defaulttestorder_bydefaults to the injected ingest stamp and is inert on a keyless table; key semantics areunkeyed-union.write-modetestwrite_modeisappend, the default, orreplace. Underappenda run adds rows and no key retires; underreplacethe landing run is the table’s whole current state.replace-unsupportedtestreplacebeside amonotonicoropaque-tokencursor, a backfill chunk plan or a seed ceiling raisesPipelineReplaceUnsupported, naming the table.Why
P1config-keytestA source config key outside the set that source enumerates raisesPipelineUnknownConfigKeybefore any I/O, naming the key and the accepted keys; request headers and the forwarded guest table are exempt.Why
P1lifecycle-verbstestplancompares desired state with the store without side effects;--jsonemits a structured diff.applyreconciles one or all pipelines.runfires one pipeline once.servereconciles continuously.plan-externalstestplanreports added, removed, changed and unchanged external steps against the applied snapshot in text and underexternalsin--json, with the same change fields as other declaration kinds.Why
because external-only edits affect the applied snapshot and need a visible diffapply-fires-nothingtestApplying a manifest triggers no run by itself. Repeatingapplyover unchanged sources performs no work except for elapsed schedules.table-errortestteston_table_error = abort, the default, halts the fire at the first failing table. Undercontinueevery other table lands,runexits non-zero naming the failed runs, and a scheduled fire appends its failure count to its log line.chunked-loadteston_table_errorgoverns the single-pass live pull alone; a seed or backfill chunk plan halts the fire on failure under either setting.incremental-fieldtesttestincremental = "<field>"names the stream clock, an RFC 6901 pointer into each fetched row when it opens with/; with it the source reports amonotoniccursor committingwatermark-shape, and without it the source refetches in full.
unsettled: What retires a key a source stops serving under append: a run declaring itself complete state, or a per-connector delete signal? owner: pipeline affects: run.declare
unsettled: Does on_table_error take a per-table override, and a cap on failed tables before a fire halts anyway? owner: pipeline affects: run.declare
compile
The compiler converts a pipeline specification into a versioned plan with a shared schema.
authoring-surfacetestAuthoring code runs only at build time and emits a content-hashed plan. No authoring code executes in the serving environment. No build profile embeds a scripting runtime.plan-nodetestA plan is a flat node list with predecessor edges, each node aNodeKind.plan-versiontestA plan’s version is the leading 16 chars of the sha256 over the RFC 8785 canonical JSON of its{id, nodes}.plan-schematestOne schema library defines the plan type. Every language binds to its JSON Schema. The execution engine deserializes plan JSON against that schema.inline-step-bodytestAstepbody other than a connector reference raisesPipelineInlineStepBody.control-flowtestA data-dependent conditional or loop in a workflow body raisesPipelineUndeclaredControlFlow; branching travels asbranchand fan-out asparallel.node-id-collisiontestA node id repeated in one plan raisesPipelineNodeIdCollision, naming the id and both positions.loweringtestA plan lowers node by node ontosubstrate-port.unjoined-paralleltestAparallelnode nojoinnode names, or a node other than its join reading it or one of its bodies, raisesPipelineParallelUnjoinedat compile.Why
because an output read around its join reaches a partial result no run record marks as partialbranch-armtestAbranchnode records its predicate node’s output once, trimming surrounding whitespace to produce the arm label. Every replay selects that recorded arm and skips the other arms’ nodes.branch-scopetestEach body of aparallelnode runs under step labels prefixed by the parallel node’s id, and its outcome reaches only the join, asfan-out-joinstates.
transform
The engine applies declared transforms in order to rewrite a batch in place.
chaintesttestThe chain is an ordered list ofTransformOp, declared once per pipeline and bound to the root table; afiltertakes exactly one column.aritytestA chain operation emitting more rows than it consumed raisesPipelineTransformArity.filtertestA filter dropping a root row drops that row’s nested children with it.Why
because a child landing under a parent id no surviving row carries is a dangling referencecolumn-missingtestA value filter or cast naming a column the batch does not carry raisesPipelineTransformColumnMissing, printing the column and the table.Why
because a cast over an absent column otherwise lands the batch untransformedtyped-casttesttestA cast rewrites one column’s type and keeps its name and position; a cast tobinary,binary(n),float32[n]orfloat16[n]keeps a value that type’s JSON form reads and nulls any other.type-carrytestArenamecarries a pulled column type to the new name, aselectdrops it with its column, and a cast to a scalar type drops it.extracttestextractcopies the value at an RFC 6901 pointer into a named column, null where the pointer names nothing, keeps the source column, and lands the new column with no pulled type.presence-filtertestAfilternamingabsentkeeps the rows whose column is missing or null, and one namingpresentkeeps the rest; neither requires the batch to carry the column.
unsettled: Does the chain grow past these five operations, or does richer work stay post-landing SQL? owner: pipeline affects: run.transform
normalize
The engine normalizes nested data, assigns identity columns and converts nesting to relational tables where the sink requires it.
host-stagetestType inference over deferred-typing JSON, struct flattening, list-to-child extraction and id assignment run in engine code; no connector reimplements them and no dataframe library participates.normalized-formtestThe canonical form uses nested Arrow structs and lists. The sink projects this form into relational tables late, so a source landing in two sinks normalizes once.modetestnativekeeps nesting the sink supports and expands unsupported nesting withschema-diff-home.relationalflattens structs into parent-child column names and converts lists into child tables joined by a foreign key.mode-resolutiontestMode resolves per stream per sink: explicit declaration, then sink capability, thennative.mode-unknowntestA mode outside the two raisesPipelineNormalizeModeUnknown, printing both spellings.identity-columnstestNormalize injects a content-hash row id, byte-identical across re-runs of one input, on every table; a load id on the root; a parent id and list index on each child; and a root id below the first child level.field-collisiontestA relational source field flattening onto an occupied column takes the first freesource_-prefixed name, preserving injected identity columns and the source value.list-index-missingtestA relational child table emitted without the list index that makes its projection reversible raisesPipelineListIndexMissing, naming the parent and the list.native-storetestOn the store sink,nativelands each undeclared column of objects and arrays as a struct or list column inferred over the batch; a column mixing kinds, or oneschema.jsonholds as a scalar, lands asJson.nesting-depthtestRecursion stops at the declareddepth, default 5 levels, landing a deeper subtree as oneJsonvalue.
Scenarios
run.normalize.mode: WHEN a relational stream holds a struct and a two-item list, THEN the store holds flattened parent columns and two indexed child rows joined to that parent.
guard-secrets
The secret guard masks credential-shaped text in pulled batches, dead letters and registered body results before storage.
placementtestThe secret guard runs at the one pull path streaming and backfill share, ahead of the recorded pull and the land path, so a replay reintroduces no credential.matcherstestMatchers are linear-time, regex-free forward scans, each anchored on a literal prefix; the credential catalogue lives in code under a precision and recall fixture test.llm-candidatetestAn LLM candidate starts withsk-at input start or after a byte outside ASCII alphanumerics,-and_; its tail spans that alphabet. Admission masks the whole candidate.llm-admissiontestAn LLM candidate needs 32 B of tail: aproj-,svcacct-,service-,admin-orant-prefix permits the whole tail; otherwise its first 32 bytes are ASCII alphanumerics.assignment-keytestAn assignment key qualifies when it is a keyword or ends in one after_,-or., reading-as_, soclient_secret,x-api-keyanddb.passwordqualify andclientsecretdoes not.mask-replacementtesttestThe replacement is the fixed[REDACTED:secret]marker, never a shape-preserving transform, over only the matched byte ranges widened to character boundaries; overlapping spans merge under the higher-priority pattern, and an assignment keeps itskey=prefix.mask-onlytestThe guard is on by default and blocks no run; each pull logs the count of masked cells per column.coveragetestThe guard reads pre-normalize string cells for plaintext shapes; encoded material and a credential split across two cells pass through.dead-letter-reasontestEvery field of aDeadLetterpasses the guard as a pulled row’s cells do, so a reason quoting a credential lands masked.recorded-bodytestRegistered body results pass through the same guard before shaping and canonical preparation; the engine reports per-column counts once per fresh paid preparation, excluding counts from the recorded wire and replay.
Scenarios
run.guard-secrets.assignment-key: WHEN a cell holdstoken_type=abcdefgh12, THEN it lands unchanged.
land
The engine processes batches from pull to commit, records ingest counts and isolates decoder failures.
stage-ordertestA batch passes pull, the secret guard, the recorded pull, normalize, the transform chain, write-path redaction, shredding and batch write; the run then commits rows and position together throughcommit-with-rows.unknown-destinationtestThe local context store is the only destination, and synthesized artifacts write back through it; any otherdestinationraisesPipelineUnknownDestinationat assembly, before any row moves.no-host-armtestThe destination world declares no host arm, so a guest supplies no destination.batch-writetestThe store creates a landing table on first schema observation,first-sight. The store writes each batch durably in a separate call. A batch may carry its ordinal for joining the run’s request ledger.typed-pulltestA pull’stypesobject maps a column to a type spelled astyped-landingreads it, and the run commit lands that column in it; an unreadable spelling fails the pull asincompatible.late-typetestA pull declaring a type for a column an earlier staged batch of its run carried undeclared raisesPipelineTypeDeclaredLate, deterministic, naming the column; the run commits nothing and retires its owner.Why
because that batch’s part already holds the column in its inferred type, and a staged part is immutableirreconcilable-schematestAn arriving schema the store cannot reconcile fails the batch asincompatible.commit-visibilitytestA commit makes a run’s rows visible for one table in one step; a crash before it leaves a recoverable partial run.ingest-tallytestA fire reports, in total and per table, its runs’fetched,keptandskippedfromRunRow,dropped_low_qualityasfetchedlesskept, andfailedtables; a non-zerofailedexits non-zero.Why
because a fire pulls one declared source through its tables, and afilteris the one stage that drops a fetched rowobject-arrival-waketestAn object-arrival key only wakes its prefix read, which lists from its held position as a scheduled fire does; a duplicate or reordered key lands nothing twice, and the source’s cadence still fires to catch a missed one.Why
because a notification is a hint a provider may repeat, reorder or drop, and the held position already decides what lands
sessions
A landing session commits each micro-batch read from stdin under one held table declaration.
sessiontestA landing session holds one store, node and table declaration across runs and commits each micro-batch as its own run; a later run rewrites no earlier run’s parts or manifest.Why
because a writer flushing every second otherwise pays a process start, a store open and a node resolution per batchsession-inputtestcontextful context land --sessionreads oneSessionBatchper stdin line, lands each as its own run through one landing session, and prints that run’s outcome before reading the next line.Why
because a host running no serving process still needs one session across its batchessession-schematestA landing session parsesschema.jsonagain only when its version — length, modification instant, file identity — moves; a land adding no column or type replaces noschema.jsonand takes no schema lock.Why
because a land per second otherwise rereads and rewrites the schema under the lock every writer of the table waits on
receive
A delivered receive batch enters the pull stage under a batch key and lands through a bounded queue and group.
receive-entrytestA delivered batch enters at the pull position ofstage-order: each declared table it touches lands as one run whose single pull yields that table’s rows.Why
because a pushed row needs the guard, the recorded pull and redaction a pulled row passesreceive-appliedtestA served delivery loads its source from one immutable applied version, falling back to declarations only when no version exists; its source outcome followssource-run-evidence.receive-version-grouptestAn applied receive group admits spooled members only from its loaded configuration digest and version; declaration fallback occupies a separate namespace, while queue capacity and the land lock remain shared.receive-acktestcontextful pipeline receive <id> --key <key>reads one batch on stdin and answers after every touched table commits, in declaration order, naming the batch id and each table’s run id; a failed table answers retryable only underretryable-classes.Why
because no commit spans two tables, so a sender learns success only once the last table commitsreceive-batch-keytestA batch key fixes each touched table’s run id stemreceive-<digest>.<table>and owner scope, a later attempt taking.2,.3; a table whose run under the key, or under the group its scope names, committed replays it and lands nothing.receive-resumetestA table under a batch key whose run did not commit resumes the key’s pending owner, recorded pull included, and no other key’s.Why
because a sender retrying after a lost answer must not lose uncommitted tablesreceive-batch-too-largetestA body exceeding the source’smax_batch_bytesraises non-retryableReceiveBatchTooLarge. The reader stops one byte past the bound. No table lands.Why
because an unbounded body is an unbounded allocation the sender controlsreceive-queuetestUp tomax_queue_depthbatches per pipeline hold queue slots while waiting or landing. Each batch holds its slot before body reading until the answer. One group lands at a time.Why
because concurrent senders share each table’s single-writer cursor leasereceive-spooltestA batch decodes and validates alone, so a malformed body answers alone, then spools, synced, to its pipeline’s queue before waiting for the land lock; a leader lands every spooled batch, a dead receiver’s included.receive-grouptestThe land lock’s holder lands the batches spooled under its credential, up tomax_queue_depthmembers and 64 MiB, as one run per touched table, keyed by the group or a lone member, then pushes once and answers each.receive-adopttestA leader first lands the batches a dead leader claimed as that leader’s group, resuming or replaying its run, before any new group forms, so each member key lands once.Why
because each member’s scope names the dead group, so a new group leaves that group’s owner pending and its members’ scopes renamedreceive-torntestAn attempt of a batch key, of the group its scope names, or of a new group’s key, whose commit the store holds while its run row never closed, closes committed with its stored rows, retires its owner and replays.Why
because a leader dying between its commit and its run’s close otherwise lands the committed rows againreceive-group-isolationtestA group run failing with a class outsideretryable-classeslands each member’s rows alone under its own key, so a batch the land path refuses answers alone and the group’s other members commit.Why
because one malformed body must fail no other batch, and a sender honoring a non-retryable answer drops its rowsreceive-waittestA spooled batch that neither holds the land lock nor joins a group within the wait ofcommit-waitwithdraws its spool and answers asreceive-backpressure.Why
because a batch no group takes must free its queue slot and tell its sender to resendreceive-backpressuretestIf batches hold every pipeline queue slot, the receiver raises retryableReceiveBackpressurebefore reading the arriving batch’s body.Why
because a sender told to wait sheds load the receiver cannot absorbreceive-retry-aftertestAreceive-backpressureanswer carries a retry interval of 1 s.receive-push-waittestA delivered batch’spull_before_runandpush_after_runwait at most 30 s forin-flight‘s push guard, then answer retryable; a resend under the key pushes its committed tables.Why
because a sender’s request outlives no 300 s wait, and a freed queue slot serves the next batch soonerreceive-write-not-grantedtestIf a credential lackswriteover any declared table of areceivepipeline, the receiver raisesReceiveWriteNotGrantedbefore reading the body. No table lands.Why
because a sender whose grant falls short learns it before spending the transferreceive-key-expiredtestA batch key replays or resumes only within the source’skey_retention_secsof its first attempt. After that window, the receiver raises non-retryableReceiveBatchKeyExpiredbefore reading the body. No table lands.receive-endpointtestPOST /receive/:pipelineon the served face takes the batch key in theContextful-Batch-Keyheader, admits the credential before reading the body, and answers200with thereceive-ackanswer.Why
because a sender refused for its credential or grant must not have spent the transferreceive-key-missingtestIf a delivery names no batch key, the receiver raises non-retryableReceiveBatchKeyMissingbefore reading the body. No table lands.Why
because a batch without a key can neither replay nor resume underreceive-batch-keyreceive-pipeline-unknowntestIf a delivery names an undeclared pipeline or a pipeline whose source is notreceive, the receiver raisesReceivePipelineUnknownbefore reading the body. No table lands.Why
because a sender’s misaddressed batch must not land in another pipeline’s tablesreceive-endpoint-statustestThe receive route answers400forreceive-key-missing,404forreceive-pipeline-unknown,403forreceive-write-not-granted,413forreceive-batch-too-large,410forreceive-key-expired,503withRetry-Afterforreceive-backpressure,receive-push-waitor a retryable failed table, and422otherwise.
queued receive
A queued delivery answers 202 at once, and a consumer lands it later under the same batch key.
receive-queuedtestUnderqueue-extension, a request asking for a queued answer receives202with its batch key after admission and queuing.202acknowledges queue custody, without claimingreceive-ack‘s success.Why
because a sender must not read a queued batch as committed when a later landing can still refuse itreceive-queued-admissiontestA queued delivery passes every checkreceive-endpointruns before reading a body, and a refused delivery queues nothing.Why
because a revoked or under-granted sender must learn its refusal before handing over custodyreceive-queued-outcometestGET /receive/:pipeline/:keyanswers a queued batch’s outcome:pending, each table’s committed run, or the typed refusal its landing raised.Why
because a sender holding only a queued receipt reads success or refusal from one placereceive-queued-windowtestA queued batch’s key window opens when it queues; a consumer reaching it pastreceive-key-expired‘s window records that refusal as its outcome and lands nothing.Why
because a batch delayed in the queue must end typed rather than land after its replay guarantee lapsedreceive-queued-spilltestIf a body exceeds the queue’s message cap, the receiver writes the body to the bucket under its batch key before queuing. The body is deleted after its outcome records.Why
because a queue message carries a reference when a body undermax_batch_bytesstill exceeds the provider’s capreceive-queued-asktestA served delivery carryingPrefer: respond-asyncasks forreceive-queued‘s answer; a delivery without that preference answers asreceive-endpoint.Why
because a sender reading the committed answer keeps it, and the preference is HTTP’s standard asynchronous requestreceive-queued-credentialtestA queued message carries the credential its route admitted; the consumer admits it again against the pinned keys, expiry and revocation, with possession and audience settled at the route, and records a refusal as the batch’s outcome.Why
because a credential revoked or expired while its batch waits must land no row under itreceive-queued-consumetestA consumer receives up to the queue’s batch size, lands each pipeline’s queued batches per credential asreceive-group, pushes, then acknowledges each message whose outcome recorded; a retryable failure leaves its message queued.Why
because a message acknowledged before its push loses the batch when the engine dies between commit and pushreceive-queued-redeliverytestA redelivered message lands through its batch key underreceive-batch-key, so a message delivered twice lands its rows once.Why
because a managed queue delivers at least oncereceive-queued-outcome-granttestThe outcome route answers a credential holdingwriteover every table of the pipeline and refuses any other asreceive-write-not-granted.Why
because the outcome names the runs a sender’s rows landed in, which only a sender of those tables reads
unreadable input
Input a decoder cannot read fails alone, lands as a dead letter, and decodes in a bounded child process.
unreadable-inputtestInput a parser cannot read raisesPipelineUnreadableInput, naming the path and the position inside it, permanent against the retry schedule and failing one table’s pull unless its source dead-letters it.input-faulttestA failure over one input carries its fault:inputwhen the input’s own bytes defeat the decoder,configurationwhen the declaration or the build does. A source dead-letters aninputfault alone, never by message.Why
because a message names an error for people, and branching on its text dead-letters any refusal that repeats the namedead-lettertestA pull’s dead letters land in the table<table>_dead_letter, oneDeadLetterrow per input, committed in the same run as the table’s rows.dead-letter-replacetestA replacing table whose read dead-letters any input fails the pull underunreadable-input, naming each input, and commits neither its rows nor its dead letters.Why
because dead letters committing alone record a success while the table stays stale, and repeat each dead letter every firedead-letter-occupiedtestDead letters whose<table>_dead_letteralready holds a relational child table, or a store table lacking aDeadLettercolumn, refuse the pull undertable-name-collisionand land nothing.Why
because two shapes committing into one table mix their schemaspartial-parsetestA reader stopping partway through a multi-part input raisesPipelinePartialParseover the whole input and lands none of its parts.parse-boundarytestEach input whose decoder is subject to process failure decodes alone in a child process outside the serving process, which bounds the child’s wall clock and memory and can terminate it.parse-deadlinetestA decode runs for at most 60 s from child spawn; expiry kills its process group and raisesparse-crashed.parse-memorytestA decoder child receives at most 512 MiB of memory; exceeding the bound fails that input underparse-crashed.parse-crashedtestA non-zero exit or fatal signal from the decode process raisesPipelineParseCrashednaming the input; no input ends the serving process.table-failedtestA table whose pull fails raisesPipelineTableFailedcarrying the table, the error kind and the run id; the fire then followson_table_error.Why
because a failure is answerable by name only when it carries its table and run
Scenarios
run.land.session-input: WHEN stdin carriesrun-1,run-2andrun-1again, THEN the session prints two commits and one replay, one line each.run.land.receive-group: WHEN 16 one-row batches spool behind a held land lock that then releases, THEN one run commits all 16 rows, one push follows, and every batch answers committed.run.land.receive-group-isolation: WHEN a well-formed batch and one whose row sets a reserved column spool into one group, THEN the well-formed batch commits and only the other answers non-retryable.run.land.receive-spool: WHEN a receiver dies after spooling its batch, THEN the next leader lands that batch in its group and a resend under its key replays.run.land.receive-adopt: WHEN a group’s leader dies after opening its run, after staging its parts, or after its commit, THEN a later batch lands apart and each member’s resend replays the group’s run, every row landed once.run.land.receive-torn: WHEN a group of two commits and its run row stays open, THEN one member resent beside a new batch and the other resent alone both replay the group’s run, and every row lands once.run.land.receive-wait: WHEN a spooled batch waits 30 s behind a held land lock, THEN it leaves the spool and answersReceiveBackpressure, retryable.run.land.receive-resume: WHEN the receiver dies after recording a delivered batch and the batch is resent under its key, THEN every landed cell but the run id is identical to the uninterrupted run’s.run.land.receive-queued-window: WHEN a consumer reaches a batch queued 3601 s earlier underkey_retention_secs = 3600, THEN its outcome readsReceiveBatchKeyExpiredand no table lands.run.land.receive-endpoint: WHEN a request carries no credential and a body pastmax_batch_bytes, THEN the route answers401without reading the body.
unsettled: Does a dead group’s first attempt that never committed close, once a later attempt lands its members, rather than staying running? owner: pipeline affects: run.land
export
An export reads committed table rows and delivers them to an operator-declared target with at-least-once delivery.
export-blocktestAn OTLP[[export]]block takesBlockwith one landedtableandsignal = "logs";contextful export run <name>delivers every row its cursor has not passed.signal-unknowntestIfsignalis notlogs, the exporter raisesExportSignalUnknownbefore reading any row.Why
because the one built-in arm maps a row onto a log record, and a span or metric needs a column mapping no block declarespost-commit-readtestExport reads committed runs only, through the read face under the admitted credential, so its grants, row policy and masks hold; no landing waits on an export.commit-ordertestExport reads rows past its cursor ordered by_commit_seq, then_row_seq. Export does not order by_ingested_at;commit-seqkeeps late-committing runs eligible.commit-seq-missingtestA table holding rows whose_commit_seqis null, from parts landed without the column, raisesExportCommitSeqMissingnaming the export and the row count, before any batch leaves.Why
because a null never passes a cursor, and a row an export skips in silence is a row its mirror losesbatch-rowstestOne delivery batch holds at most 500 rows.Why
because a batch that size sits below the face row ceiling and the request limits common collectors applylog-recordtestEach row becomes one OTLP log record:timeUnixNanofrom_ingested_at, each non-null column outside the_namespace an attribute, besidecontextful.{table,run_id,commit_seq,row_seq}.Why
because the attributes a target deduplicates on travel with the record, and the engine’s own columns stay its owndeliverytestThe exporter sends each batch as one OTLP/HTTP JSONPOSTthrough the mediated client. The allowlist contains only the endpoint host. Each header followshydration-is-just-in-time.secret-preflighttestIf no adapter resolves a header reference, the exporter refuses at preflight asunresolved-namebefore reading any row.Why
because a target the export cannot authenticate to is known before a batch is builtcursor-after-acktestThe cursor, the last delivered_commit_seqand_row_seq, commits only after the target answers 2xx for the batch.Why
because an unacknowledged batch remains eligible for delivery after a restartqueue-waketestA post-commit queue message only wakes an exporter, which reads throughcommit-orderand advances undercursor-after-ack; a lost message delays delivery to the next wake and skips no row.Why
because a message lost between commit and enqueue must not lose the rows it announcedlocal-statetestThe local storage profile keeps log cursors in.contextful/exports/<project>/<name>.json, outside corpus sync, and typed delivery state inmachine.sqlite.remote-statetestUndercontainer-profile, PostgreSQL holds typed outboxes, acknowledged keyed state and log checkpoints in the execution database; a replacement reoffers identical pending event ids before advancing its acknowledged frontier.d1-statetestUnderd1-profile, D1 commits export outboxes, acknowledged keyed state and log checkpoints; a sink commit without its D1 acknowledgement reoffers the same delivery identity.writer-fencedtestA PostgreSQL exporter whose durable writer lease has transferred raisesExecutionWriterFencedbefore dispatch or state publication; its lease row stays locked throughout an admitted outbound call, and takeover waits for that call.at-least-oncetestA run ending between a target’s acknowledgement and the cursor commit resends that batch on the next run; a target deduplicates oncontextful.commit_seqandcontextful.row_seq.delivery-refusedtestA target answering other than 2xx, or unreachable, raisesExportDeliveryRefusednaming the export and the answer, and the cursor stays where it stood.Why
because an unacknowledged batch is resent from the cursor, never skipped
typed changes
A typed export publishes keyed changes-v1 events from a staged outbox and acknowledges them in sequence.
typed-blocktestA typedBlockdeclaresformat = "changes-v1", nonemptykeycolumns and aschedule.Why
because a consumer needs stable row identity and a delivery cadencetyped-statetestA typed export compares the latest stable committed table read throughpost-commit-readwith its last acknowledged state; commits between reads coalesce into one publication.Why
because a scheduled state export promises the observed frontier rather than every intermediate writetyped-view-rechecktesttestA typed export re-reads its admitted table even without a source commit frontier change, and stages a publication when its key, schema, policy or visible state changes; the publication identifier covers the source frontier, read identity and visible keyed state.Why
because a commit hash alone cannot identify the view delivered to a consumer, and a receiver must not mistake a changed view for a retrytyped-eventstestEach changed key yields a versioned upsert with its visible row or a deletion with its key; unchanged keys yield no event.Why
because a generic consumer applies the same keyed operations regardless of table shapetyped-ordertestTyped events order by encoded key and carry consecutive sequences and stable ids; a repeat delivery carries the same bytes and ids.Why
because acknowledgement can fail after the target applies a batchtyped-completetestA publication-complete event follows every change, including an empty set, and states that publication’s change count.Why
because a consumer needs a boundary after which its keyed state represents one observed frontiertyped-outboxtestEvents and their next keyed state stage together in the configured execution store; a pending publication blocks staging a later frontier.Why
because a failed send must retain its exact payload and order across restartstyped-identity-changedtestPending events under a changed read identity, or an export redirected to another target under the same name, raiseExportIdentityChangedwithout delivery; watch exits.Why
because an old outbox cannot prove authorization under a changed view or establish a new target’s baselinetyped-acktestA typed export advances its durable acknowledgement sequence only after HTTP 2xx; acknowledging the completion event promotes its staged state in the same transaction.Why
because a receiver acknowledgement and local state promotion have one durable ordertyped-watchtestcontextful export watch <name>fires the typed export on its schedule and retries a refused send with capped exponential delay from the pending outbox.Why
because delivery resumes without another operator invocationtyped-byte-limittestOne typed JSON request holds at most 65536 B.Why
because a row-count bound alone admits an unbounded request bodytyped-event-too-largetestIf a typed event exceeds one request’s capacity, the exporter raisesExportEventTooLarge. Watch stops without retrying that event.Why
because an unchanged oversized event cannot make progress on retry
coordinated publications
A coordinated [[publication]] delivers every member of one producer completion and activates the members together.
group-blocktestA local[[publication]]takesPublicationDeclaration; its typed exportmemberscover every producer output exactly once and share one destination and header binding.group-sourcetestA successful store-driven job or materialized model build records one immutable completion binding its run identifier, pinned input frontier and all output table frontiers, including empty outputs.group-source-latesttesttesttestThe sqlite and D1 publication ledgers keep 1 completion per publication, the latest: one with other bytes refuses when it names the latest run, or its landed outputs’ greatestcommit_seqis at most the latest’s; any other replaces it.Why
because an earlier run replayed after a later one otherwise names its older input in the group header, and history kept past the latest grows with every buildgroup-deliverytestcontextful export group <name>reads every completed producer member throughpost-commit-read, rechecks every frontier, and stages one ordered outbox with explicit member-complete events followed by one group-complete event.group-identitytestA group’s identity binds its store incarnation, destination, namespace, read identity, predecessor, publication order, input frontier, membership, delivered base and next state; each event identifier binds that group and its sequence.group-activationtestA receiver stages member events transactionally and activates every member together only after validating the complete group; interrupted delivery preserves the active group, and acknowledged retries preserve its bytes and visibility.group-checkpointtestcontextful export checkpointsaves delivery state;contextful export restorereplays compatible immutable events while the receiver retains its accepted groups and active frontier, so an older checkpoint reverses no deletion or later publication.group-rebuildtestcontextful export rebuild <name> --namespace <epoch>exports the completed producer’s full admitted state into a fresh namespace without executing its effects; the receiver keeps its active namespace until the replacement group completes.group-input-digesttestA group plan’sinputcarries the lowercase hex SHA-256 of its producer completion’s JSON rather than the completion, so every request repeats a header of bounded size whatever the producer’s input frontier holds.Why
because a producer’s input frontier is caller-shaped JSON of any size, and each request repeats the group headergroup-request-boundtestStaging refuses a group whose header beside its smallest event exceedstyped-byte-limitwithPublicationHeaderTooLarge, and one holding an event unable to fit beside its header withtyped-event-too-large; the outbox stays unchanged.Why
because a staged group blocks every later frontier until acknowledged, so an unsendable one strands its outboxgroup-strandedtestStaging replaces a pending group unable to form its first request, which therefore no receiver holds, with the current frontier’s group; a group with an acknowledged prefix stays immutable.Why
because a group never offered carries no delivery obligation, while a receiver may already stage an acknowledged prefixgroup-quiescetestcontextful export group <name>first takes the publication’s dispatch intent, so serve starts no unit over its inputs, then holds those inputs exclusively through its pull, read, staging and delivery.Why
because a commit during the export moves the producer frontier the export rechecksgroup-quiesce-waittestAn export still waiting for the publication’s intent and inputs 300 s after it began waiting for either raisesPublicationInputsBusybefore its pull, read or staging; the outbox stays unchanged.Why
because a running producer ends on its own schedule, and the next attempt exports its completed frontiergroup-incompatibletestMembership drift, incarnation mismatch, a changed pending read identity, an incompatible checkpoint, an incomplete producer frontier, or a predecessor/order mismatch raisesPublicationIncompatiblebefore delivery or activation.group-storagetestA coordinated publication underpostgresraisesPublicationStorageUnsupportednaming the coordinated-group-outbox capability before execution, staging or delivery; acloudflare-d1sender state over 8 MiB raises it before staging.group-d1-statetestUnderd1-profile, D1 holds each group’s producer frontier, sender outbox and active namespace; one D1 batch commits a completing acknowledgement with its promoted state and active namespace.group-d1-leasetestAcloudflare-d1group writer holds one durable D1 lease per publication, and every sender write predicates on that lease and the row’s prior version.group-d1-spilltestAcloudflare-d1sender row over 64 KiB holds the SHA-256 of an R2 object written before the row commits; the blob sweep keeps every object a publication row references.Why
because a D1 write crosses the binding once and never resends, an R2 put resends, and an object reached only through a committed row is never half visiblegroup-d1-acknowledgetestAcloudflare-d1acknowledgement short of group completion advances the sender row’s version and records the group and acknowledged sequence in a row of its own, leaving the outbox body unwritten.Why
because the outbox holds the group’s delivered base and next state, so rewriting it per batch scales every acknowledgement with the published tablesgroup-d1-resumetestReading acloudflare-d1outbox resumes after the event carrying its recorded acknowledged sequence, whatever batch boundaries the reading binary cuts; a sequence naming no unacknowledged event of the pending group fails every read.Why
because batch boundaries follow the reading binary’s request ceiling, row cap and event encoding, which a replacement binary may changegroup-d1-takeovertestcontextful export group <name> --take-overundercloudflare-d1takes the publication lease whatever its holder or expiry and advances its fence, so the prior holder’s next write, a producer completion included, matches no lease and refuses.Why
because a delivery killed at its caller’s time bound holds the lease until expiry, and a fenced producer’s build retry records its completion againgroup-d1-recoverytestAfter an empty-disk replacement,contextful export groupresumes a staged or partially acknowledgedcloudflare-d1group from D1, reoffering identical group and event identifiers before staging a later frontier.
delivery progress
A caller reads an outbox’s progress through a supported command and bounds a coordinated delivery to its own time slot.
statustesttestcontextful export status <name>printsDeliveryProgressfor a declared publication or typed export, read through its profile’s ledger without a lease or delivery;--jsonprints one object, otherwise onekey=valueline.Why
because a caller ordering deliveries reads progress through a supported read, never the ledger’s internal rowsstatus-no-outboxtestStatus refuses an OTLP log export, which keeps a cursor rather than an outbox, and a name declaring neither, before reading any ledger; a publication underpostgresrefuses asgroup-storage.status-spilledtesttestAcloudflare-d1status reads a sender bodygroup-d1-spillplaces in R2 from its object, and counts from the acknowledged sequencegroup-d1-resumereplays.Why
because a body over the row limit leaves the inline column empty, so a reader of the row alone parses nothingstatus-versiontesttesttesttestEach committed stage, acknowledgement or restore of an outbox advances itsversionby one in the same transaction, and each acknowledgement records its wall-clock instant aslast_ack_at, RFC 3339 in UTC.Why
because a caller polling progress tells a moving outbox from a stalled one without reading its eventsstatus-sequencetesttesttestpending_eventscounts staged events no acknowledgement passed;acknowledged_sequencenames a pending group’s last acknowledged event, null before its first, and otherwise the outbox’s last acknowledged event, whose typed sequence continues across publications.Why
because a group numbers its events from zero, so an earlier group’s sequence misstates a pending onegroup-withintestUnder--within <secs>,contextful export groupstarts a batch only while the seconds since it began plus its longest batch round trip so far stay below the bound.Why
because a caller’s time slot ends whatever the target’s pace, and a batch started past it ends killed without its acknowledgementgroup-within-exittestA delivery stopping at its bound printsPublicationDeliveryBoundedon stderr and exits 75, apart from 0 for a complete group and 1 for a failure; the group stays pending, and the next run resumes after its acknowledged prefix.Why
because a caller schedules another slot for a pending group and investigates a failuregroup-progresstestcontextful export groupprintsacknowledged <n> events · group pendingafter each acknowledged batch,acknowledged <n> events · group completeafter the batch completing its group, andgroup completealone when no group is pending.Why
because a delivery killed at its caller’s bound has already reported every batch it acknowledged
Scenarios
run.export.group-within: WHEN a receiver holds the first batch for 6 s under--within 10, THEN the delivery acknowledges that batch, starts no second, and exits 75 with the group pending.run.export.group-stranded: WHEN an earlier sender staged a group whose header alone exceeds the request ceiling, THEN the nextcontextful export groupreplaces it with a digest-headed group and delivers it.run.export.group-quiesce: WHEN serve’s cycle finds the producer due while an export of its publication awaits the receiver, THEN the cycle reports the producer pending and the export acknowledges its group.run.export.group-d1-recovery: WHEN the container stops after D1 commits a staged group and before any delivery, THEN its replacement delivers that group’s exact bytes and the receiver activates every member together.run.export.group-d1-state: WHEN a replacement loses the acknowledgement of the group-complete batch, THEN it reoffers that batch, and the receiver’s accepted group absorbs the retry without a second activation.run.export.group-d1-acknowledge: WHEN a receiver completes a group of 880 deletes whose final acknowledgement the sender lost, THEN the replacement reoffers from the last recorded sequence, the receiver absorbs the retry, and the sender promotes the group.run.export.group-d1-takeover: WHEN a delivery is killed before its acknowledgement while its lease runs, THEN a retry with--take-overdelivers at once and the killed writer’s next write refuses as fenced.
unsettled: Does the arm map a table onto the OTLP span or metric signal, and through which column declaration? owner: pipeline affects: run.export
unsettled: Does a local-profile sender whose disk a replacement emptied continue its receiver’s group sequence, or refuse until contextful export rebuild opens a fresh namespace? owner: pipeline affects: run.export
export-iceberg
An Iceberg export publishes one audience’s admitted rows of one landed table as an Apache Iceberg table in a REST catalog, which a query engine reads without reaching the store.
blocktestAn IcebergBlockdeclaresformat = "iceberg", one landedtable, the admittedcredentialit reads as, a RESTcatalogURI, astorageendpoint, awarehouse, anamespace, atable_name, aformat_versionof 2 or 3, and a token reference.readtesttestAn Iceberg export reads throughpost-commit-readevery column outside the_namespace and writes fresh Parquet whose every column carries its field id from the table’s current schema; it references and copies no store file.Why
P5token-preflighttestIf no adapter resolves the token reference, the exporter refuses at preflight asunresolved-namebefore reading any row.Why
because a catalog the export cannot authenticate to is known before a file is writtenegresstestEvery catalog and object-store request passes through the mediated client, whose allowlist holds the declared catalog and storage hosts; a table configuration naming another storage host raisesExportStorageUndeclaredbefore any file is written.casttestEach column takes the Iceberg type holding every value its stored type does:int,long,double,boolean,stringfor text and JSON,binary,fixed[n], and nestedstruct,listandmap; a variant exports as its tagged struct.Why
because a reader of the copy sees every source value unchanged, and the read face answers each cell in its stored typemaskedtestA column the credential’s mask rewrites exports at the typeprojected-typereads it as, so a masked cell publishes as the read face answers it.Why
because a digest published under its column’s stored type decodes as bytes it never heldfixed-listtestAFixedSizeList<T, n>column exports aslist<T>, the exporter reattaching the element’s field id and recordingnin the table propertycontextful.fixed-size-list.<column>.Why
because Iceberg has no fixed-size list in any format version, and the width is the one fact the cast dropstimestamp-nstestUnderformat_version = 3, a nanosecond timestamp column exports astimestamp_nsortimestamptz_ns, keeping every digit.Why
because a microsecond cast loses the digit a key or an ordering may depend ontype-refusedtestA nanosecond timestamp column, top-level or nested, underformat_version = 2raisesExportTypeUnsupportednaming the column at preflight, before any row is read.Why
P1file-sizetestThe exporter writes data files of at most 128 MiB, opening a new file before the next rows carry the current one past that size; one row larger than that fills a file alone.multiparttestThe exporter streams each data file as a multipart upload, sending each 16 MiB part once encoded, a shorter last part, and the completion when the file closes; a file under one part uploads in one request.Why
because a file held whole until it closes bounds the export’s host by file size rather than by part sizememorytestWhile writing a data file, the exporter holds at most one part and one open row group of it in memory, whatever the file’s size, and closes a row group once its encoded size reaches 16 MiB.Why
because a host with a small memory limit then runs an export whatever size its data files reachcreatetestAn export finding no table creates it at the declared format version and commits its files as oneappendsnapshot, requiring thatmaindoes not exist.overwritetesttestEach later export commits oneoverwritesnapshot that adds its data files and marks every live file of the parent snapshot deleted, requiring the table uuid,mainat the parent snapshot id, and the current schema and default spec ids.commit-retrytesttestA conflicting commit reloads the table and, while its uuid and schema and partition spec ids are unchanged, retries with the written files, at most 3 attempts; a changed id or the last conflict raisesExportCommitConflict.orphanstesttestAftercommit-retryrefuses, or an earlier step fails, the exporter aborts its open multipart uploads and deletes every data and manifest file that export wrote; a retry deletes only the manifests it supersedes, keeping the data files it reuses.Why
because catalog maintenance removes only files some snapshot referenced, so an uncommitted file stays until its writer removes itcommit-unknowntesttesttestA commit whose outcome is unknown deletes no file while it may still apply; a later run records it as taken when the table holds its snapshot, and deletes its files once another table stands ormainleaves its parent.Why
because a commit the catalog applied, or is still applying, after the connection dropped references every file it addedcommit-statustestAny 2xx answer takes a commit, 409 conflicts, and 400, 401, 403, 404, 406, 419 and 422 refuse it with no effect; every other answer, or none, leaves its outcome unknown.Why
because a proxy’s 408 or 429 may follow a commit the catalog applied, and only the specification’s refusals prove it did notretentiontestThe exporter expires no snapshot and deletes no committed file; the catalog’s snapshot expiration owns retention.Why
because a reader may still plan a query against the previous snapshot while the next export commitsfrontiertestThe export records its committed snapshot id, source frontier, read identity and declared target, and commits nothing when all four equal the table’s current snapshot id and the export’s present values.Why
because each retained snapshot holds a full copy, and an unchanged export buys a reader nothingschema-changedtestA source whose columns or types no longer match the Iceberg table’s current schema raisesExportSchemaChangedbefore any file is written; the export evolves no schema.Why
because a reader’s queries name the columns they were written against, and an evolution the exporter chooses changes them unannouncedtable-shapetesttestAn existing table at another format version than the block declares, or partitioned, refuses before any file is written; an export writes unpartitioned tables at its declared version.Why
because files written to the declared shape mislabel an existing table’s version or partition valuesaudiencetestEach Iceberg export publishes for exactly one admitted credential, which maps to exactly one namespace; the gateway derives a reader’s namespace from its audience key, and the reader never names one.audience-keytestThe gateway maps the SHA-256 of a reader’s bearer audience key to one namespace through<BINDING>_BASIN_AUDIENCES; a key reads until its digest leaves that secret, with no expiry or denylist check.Why
because a gateway restating credential admission in part admits a revoked credential while appearing to check itwake-audiencetestA queue wake runs each Iceberg export of a woken table under its one admitted credential and skips, without failing, an export publishing for another credential.Why
because one table’s exports serve one audience each, and a wake carries one credentialcaller-sqltestCaller-written SQL reaches the catalog’s query engine only from a store entry declaringcaller_sqlfor a single-audience warehouse; otherwise the gateway runs the entry’stemplatesagainst the mapped namespace.routetestWhere a deployment adds a catalog query engine, the gateway answersPOST /stores/<id>/queryfrom a reader whose audience key maps to a namespace through that engine, and sends every other caller to the Container.gateway-entrytestA gateway store entry’sbasinobject names the query engine’sendpoint, optionaltemplateswith{namespace}and{table}placeholders, and booleancaller_sql; its token and audience map are derived secret names.Why
because the store’s binding name already derives its other secret names, and an authored name lets one entry read another’s tokenquery-refusedtestA mapped reader’s request naming a namespace, an undeclared template, a table outside[a-z_][a-z0-9_]*, or caller SQL on a shared warehouse raisesExportQueryRefusedand reaches no engine.
Scenarios
run.export-iceberg.commit-retry: WHEN catalog compaction commits between the export’s table load and its commit, THEN the export reloads, marks the compacted files deleted and commits on its second attempt.run.export-iceberg.type-refused: WHEN a table holding a nanosecond timestamp column exports underformat_version = 2, THEN preflight refuses naming the column and no Parquet file is written.run.export-iceberg.frontier: WHEN an export runs twice with no commit and no grant change between, THEN the second run commits no snapshot.
unsettled: Does a failed export list the multipart uploads under its data prefix, aborting one whose creation answer it lost? owner: pipeline affects: run.export-iceberg
backfill
The scheduler loads history through chunk plans with fenced leases and supports rewinding a declared range.
phasetestA run row records thePipelinePhaseit ran under by its snake-case name, and a row without a phase carries no field.chunk-plantestPlan shape followsCursorKind:monotonicplans independent time-window or id-range chunks,opaque-tokenone sequential chunk walked in order,snapshot-idversion ranges sequential per stream and parallel across streams.chunktestA chunk row isChunkRow; an interrupted plan resumes at the first chunk not done.chunk-leasetestA worker leases a chunk row, recording node id and attempt, for a 300 s time-to-live renewed every 60 s, and writes under a path segmented by run, node, chunk and attempt.Why
because an unquantified lease cannot be tested against a paused workerfenced-committestA chunk commits in one catalog update conditioned onattempt = :mine AND status != 'done'; an update matching no row leaves that worker’s files uncommitted.Why
because an expired holder otherwise commits over its successorcommit-signaltestThe catalog row signals commit; directory presence does not. Retention collects files under paths without a done row according to a retention window.attempt-isolationtestAn expired lease lets a second worker claim the chunk, increment the attempt and write under its own path.blocktest[pipeline.backfill]takesBackfillBlock;start_fromdefaults to zero.rewindtestA rewind returns every chunk overlapping the half-open range to pending.rewind-invalidtestAn inverted, empty or scale-incomparable rewind window raisesPipelineRewindWindowInvalid; a window overlapping no chunk is a no-op.Why
P4available-instanttestA backfill lands each row’s availability column asdeclarationresolves it from the source, while_ingested_atstays the commit instantstage-instantwrites.Why
because a 2026 backfill of 2014 history stamped with its commit instant answers no availability bound before 2026
unsettled: How long does the retention window keep files under a path with no done row? owner: pipeline affects: run.backfill
seed
A seed source bulk-loads history below a declared ordering ceiling before live ingestion.
blocktest[pipeline.seed]attaches a bulk-load source with a ceilingbelowexpressed on the seeded table’sorder_byscale.one-land-pathtesttestSeeded rows travelstage-orderunchanged, landing availability and_ingested_atasavailable-instantdoes; attribution survives only in the load id and a run recorded under theseedingphase.declaration-missingtestA seeded table withoutprimary_key, or withorder_byleft at the ingest stamp, raisesPipelineSeedDeclarationMissingat plan and validate.ceiling-breachedtestA seeded row whose ordering stamp reachesbelowraisesPipelineSeedCeilingBreachednaming the value; its chunk lands nothing, and the stamp is neither clamped nor dropped.ceiling-pointtestThe engine evaluates the ceiling on the root batch after normalization and the transform chain, before writing.ceiling-unevaluabletestA batch missing the ordering column, or a stamp unorderable against the ceiling, raisesPipelineSeedCeilingUnevaluable; a text stamp orders only whenZ-suffixed at the ceiling’s fractional width.scopetestSeed chunk and cursor state live under a<table>#seedscope apart from the live pipeline’s.connector-pintestA seeding run is exempt frompinned-plan-changedand records its connector identity as provenance.ceiling-bindingtestOn seed commit, an unsetmonotoniclive position takes the ceiling whenbelowparses as an integer; under the other cursor kinds the live position stays unset and starts from the source’s beginning.fingerprinttestEach run probes the seed source for a cheap fingerprint, a stat or a HEAD and never a download, and compares it with the one stamped at commit.source-changedtestA differing fingerprint raisesPipelineSeedSourceChangednaming both values; an equal one skips the load, and an unavailable one skips with a warning.commandstestseed statusprints each seeded table’s ceiling, commit state and fingerprint match;seed reset <pipeline> [--table T]clears the seed’s chunk plan and commit stamp, so the next run loads it again.compaction-cadencetestA seeded table that no job meetingfold-jobcovers raisesPipelineSeedCompactionMissingat validate and before a fire, naming the table.
model
The engine builds model tables from SQL over store tables, validates contracts and tests, and records freshness and holds.
model-blocktestA[[model]]block takesModelSpec, withstatement-source,model-policyandopt-out-record; an unknown key refuses asspec-invalid.statement-sourcetestA model declares exactly one of inlinesqlandsql_file; the latter reads UTF-8 text relative to the manifest containing the model.Why
because a manifest-relative statement file keeps a model portable across project rootstop-level-blocktestA manifest’s top-level key outside the set the engine enumerates raisesPipelineUnknownBlock, naming the key, the file and the accepted set.Why
because a misspelled block parses as nothing, and its declaration silently never runsmodel-idtestA model’sidnames the store table it builds; an id declared twice, equal to a pipeline destination table, or naming a table a landing wrote refuses astable-name-collision.sqltestA model statement is one read-onlySELECTover store tables. The engine admits the statement underwhole-tree-walkbefore reading any row. A model reads another model’s last published table.validate-statementstestpipeline validateadmits each model’ssqland every test’s statement as a build does, counting every relation but the model’s own id insqlas registered, holds each declared input torestricted-input, and raises the error the build raises.validate-undeclaredtestpipeline validatenames on stderr each relation a model’ssqlor test reads that no manifest table, pipeline destination or model declares, and still validates the model;buildresolves that relation against the store.Why
because a table landed without a manifest declaration is a legal input, while a misspelled name otherwise surfaces only atbuildrestricted-inputtestA build reading a table whoseTableDecldeclaresclass,policyorvisibilityraisesModelInputRestricted, naming the table and the declared keys.Why
because a build reads its inputs unmasked, so the model’s table serves their withheld cells to every readermaterializedtestmaterializedistable, the default: each build replaces the model’s rows whole.unique-keytestunique_keydefines the model’s row identity. A build with duplicate key values or a null in a key column refuses ascontract-mismatch.
contracts
A published model declares a versioned column contract, and a contract change publishes only under its version bump.
contract-blocktest[model.contract]declaresversion, a<major>.<minor>.<patch>string, andcolumns, each aContractColumnwithtypespelled astyped-landingreads it andnullabledefaulting to true.contract-requiredtestA model whosepublishis true, the default, and which declares no[model.contract]raisesModelContractUndeclaredat validation.Why
because a published table without a declared contract has no identity a reader can pin againstunpublishedtestA model declaringpublish = falsecommits its rows with no manifest section, so no build entry, hold or contract history names it.contract-majortestUnder the published build’s major, a contract that drops or retypes a published column, makes a non-null one nullable, adds a non-null column or changes the grain refuses ascontract-mismatch, naming the column and the major to bump.contract-minortestUnder the published build’s major, a contract that only adds nullable columns or makes nullable ones non-null publishes once its minor exceeds the published one, else refuses ascontract-mismatch, naming the added or tightened column and the bumped minor.injected-columnstestA build lands_ingested_atas its start instant,_run_idas its build id,_row_seqas the row’s position,_commit_seqas its commit’s value and_site_id, replacing any injected column the SQL selects;semantics_versionis 2.
builds
A build stages the model, checks its contract and tests, and records a watermark over its inputs.
freshness-blocktest[model.freshness]declaresmax_lagas an integer followed bys,m,hord; a model declaring none publishes a nullmax_lagand never computes stale.test-blocktestA[[model.test]]carries anameand oneSELECTover the staged rows, registered under the model’s id, and the store’s tables; a test returning any row fails.Why
because a test must inspect the candidate build before its rows become visible to readerstest-failedtestA failing test raisesModelTestFailed, naming the test and its row count; the build publishes nothing.Why
because a test exists to stop a build before readers trust itbuild-verbtestcontextful build <model>materializes the model into staging, checks its contract, runs its tests, then commits;--jsonprints the build id, row count and watermark.build-boundarytestA guarded model build admits its caller’s execution boundary after staging and before changing the persistent schema or published pointer; a refusal discards staging and preserves prior schema, membership and watermark.Why
because a stopped execution must not publish its staged modelunknown-modeltestbuildnaming no declared model raisesModelUndeclared, naming the declared models.Why
because a build of a misspelled id otherwise reports nothing to dobuild-frontiertestbuildreads each input at its committed frontier and fires no pipeline; a build ordered after a landing is abuildjob naming that pipeline inafter, underbuild-dependency.Why
becauseafteralready orders builds behind pipelines, and a verb firing sources needs their credentials and quota, and repeats paid calls on every retrymodel-cycletestIf a model’ssqlreaches its own id through one or more other models,pipeline validate, import, apply andbuildraiseModelCycle. The engine names every model on the cycle and reads no store.Why
because no member of a cycle has a first build, and once one exists each build folds in the other’s previous outputbuild-idtestA build’s id is the snapshot id it publishes.watermarktestA build’s watermark is{at, inputs}: per input table the snapshot id,through, the greatestcommit_seqread, andomitted, the count and digest of runs read outside the snapshot chain;atis the newest commit instant.Why
because a list of omitted runs grows by one key per run on an input no fold coverswatermark-digesttestomitted.digestis the lowercase hex SHA-256 over the sorted keys<run-id>/<node-id>of the omitted runs, each followed by a newline.watermark-listed-runstestA watermark input carrying arunslist and noomittedreads with those runs included understaleness; no build writesruns, and every echo of that watermark keeps the list.Why
because a manifest committed with listed runs stays readablebuild-horizontestcontextful build <model> --available-as-of <t>reads every input atavailable_as_ofequal totand recordstas the watermark’shorizon; a build without the flag records a null horizon.Why
because a build is reused across horizons only when its own bound is recorded beside it
holds and status
A hold keeps one build from collection, and status reads the last attempt beside the published build.
hold-verbtestbuild hold --for <n>[smhd] <model> <build>commits a hold until now plus the duration and printsHeld, orRenewedover an unexpired hold;--jsonprints the receipt as an object.status-verbtestbuild status <model>reports the last attempt separately from the published build;--jsoncarries their ids, status, freshness and staleness computed at the requested instant.hold-unknown-buildtestA hold naming a build no committed manifest of the model records raisesModelBuildUnknown.Why
because a hold on a build that does not exist protects nothing and reads as protectionhold-collectedtesttestA hold or renewal naming a build beside the bucket’s_collected.json, or whose parts the bucket of a store declaring[sync]records as collected, raisesModelBuildCollectedand commits nothing.Why
because a hold over removed parts reportsHeldfor a build no reader can readhold-bucket-firsttesttestOn a store declaring[sync],build holdcommits the hold to the bucket before printing a receipt the bucket’s copy names; a failed write, or meeting a collect of the build, undoes the local hold, listing any written object.Why
because a hold one disk alone records protects nothing from a collect elsewhere
unsettled: Does a model materialize incrementally, merging each build into its prior one on unique_key? owner: pipeline affects: run.model
unsettled: Does a model over a restricted table declare its own policy, or inherit the strictest policy among its inputs? owner: pipeline affects: run.model
Scenarios
run.model.build-frontier: WHEN abuildjob namingordersinafterfires in the cycle that landsordersrunr, THEN the published build’s watermarkthroughunderorders_itemsreaches thecommit_seqofr.
publish
Published tables: contract identity, snapshot publication, build history, freshness and collection holds.
manifest-committestThe snapshot manifest commit publishes a table’s data, contract identity, freshness and current build together. No separate file is authoritative for these values.Why
because separately written artifacts make a torn publication representablecontract-identitytestA published table’s identity iscontract_versionbesideschema_fingerprint, taken over the declared columns, their types and the grain.contract-mismatchtestA materialization whose columns, types or grain miss the declared contract raisesPipelineContractMismatch, naming the column and the expectation.Why
P4stagingtestA build materializes into staging and publishes throughmanifest-commit; a refused build leaves the last published state serving.Why
P4history-logstesttestcontract-history.jsonl,builds.jsonlandholds.jsonlin the model’s table directory hold append-only history derived from committed manifests and build-attempt records; each build and hold regenerates them, replacing an entry a source record disagrees with and keeping collected builds’ entries.build-entrytestAbuilds.jsonlentry is aBuildEntry, its status published, refused, partial or failed.failed-attempttestA published model records an attempt before materialization; a dead attempt without a committed manifest reportsfailed, keeps the prior published build serving, and contributes a failed build entry.Why
because a process killed before publication cannot write its own failure, while its attempt record and released lock survivestalenesstestA published model is stale attwhen an input holds a committed run outside its watermark snapshot chain, itscommit_seqabovethrough, committed beforetminusmax_lag;build status --jsonnames each such input understale_inputs.staleness-omitted-checktestAn input whose runs outside the watermark snapshot chain, at or belowthrough, differ in count or digest fromomittedholds a waiting run counted as committed beforetminusmax_lag.staleness-unsequencedtestA run carrying nocommit_seqcounts as at or belowthroughwhen committed at or before the watermark’sat.Why
because a run manifest may carry no sequence, andstaleness-omitted-checkcatches one the build never readstaleness-collected-coveredtestA run whose manifest retention collected counts as at or belowthroughwhen the current snapshot folding it records acommit_seqat or belowthrough.Why
because a snapshot’scommit_seqbounds every run it foldsstaleness-model-inputtestAn input that is a model also waits on each of its published builds newer than the watermark snapshot, aged by that build’screated_at.staleness-collected-runtestA waiting run whose manifest retention collected counts as committed beforetminusmax_lag.holdtesttestA hold is aHoldRecordcommitted asholds/<build id>.jsonin the model’s table directory, replacing any earlier hold on that build; collection skips a held build, and a hold confers no other authority.manifest-sectiontesttestThe manifest section is the newest publishing build’sPublishSection, itswatermarkaswatermark; freshness reads off it, and staleness followsstaleness, never stored.semantics-versiontestsemantics_versionadvances when the engine adds an injected column, andfingerprint_recipenames the fingerprint’s inputs, that column included.disclosure-digesttestA build records a digest over its table’sTableDeclclass,policyandvisibility, sorting set-valued fields; the manifest and build log carry it, and any declaration setswithheld_cells.
Scenarios
run.publish.staleness: WHEN no input lands after a build andmax_lagis1h, THENbuild statusat 100 days later reportsstalefalse.run.publish.staleness: WHENeventslands a run 1 second past the build andmax_lagis1h, THEN status reports fresh at 1 hour after the landing and stale,stale_inputs["events"], 1 second later.run.publish.staleness: WHEN a fold folds the runs a build read into a new snapshot, THEN status still reports the build fresh.run.publish.staleness-omitted-check: WHEN a run merged from another node carries acommit_seqbelowthroughand the build never read it, THEN status reports the input stale at once.run.publish.staleness-omitted-check: WHEN a relational run’s group marker lands after a build read a plain run committed above it, THEN status reports the input stale at once.run.publish.staleness-model-input: WHENdailyrebuilds afterweeklyread it andmax_lagis1d, THENweeklyreports stale,stale_inputs["daily"], 1 day after the rebuild.
unsettled: Is a build with a non-null horizon stale by landings committed after its horizon? owner: pipeline affects: run.publish
Shapes
A pipeline declaration:
[[pipeline]]
id = "record-sync"
incremental = "updated_time"
on_table_error = "abort"
[pipeline.source]
name = "http"
config = { endpoint = "https://api.example.test/v1/records", format = "json" }
[[pipeline.tables]]
name = "records"
primary_key = ["record_id", "observed_at"]
order_by = "observed_at"
write_mode = "append"
[[pipeline.transforms]]
op = "cast"
column = "value"
to = "float64"
[pipeline.backfill]
max_chunks = 512
chunk_size = "7d"
[pipeline.seed]
below = "2025-06-01T00:00:00Z"
source = { name = "file", config = { root = "exports/record-sync", format = "jsonl" } }
[[job]]
name = "nightly-fold"
schedule = "0 3 * * *"
kind = "fold"
target = "record_sync_records"
A model over a landed table, and the verbs that build and hold it:
[[model]]
id = "daily_records"
sql = "SELECT CAST(date_trunc('day', updated_time) AS VARCHAR) AS day, count(*) AS n FROM record_sync_records GROUP BY 1"
unique_key = ["day"]
[model.contract]
version = "1.0.0"
columns = [
{ name = "day", type = "utf8", nullable = false },
{ name = "n", type = "int64", nullable = false },
]
[model.freshness]
max_lag = "1d"
[[model.test]]
name = "counts-positive"
sql = "SELECT * FROM daily_records WHERE n <= 0"
contextful build daily_records --json
contextful build hold --for 7d daily_records <build id> --json