Contextfulv0.23.0

Docs / Specification

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:

Flowchart diagram Flowchart, top to bottom. 10 nodes, 10 edges. pipeline manifests → PipelineSpec [declare] run contract: PipelineSpec → plan [compile]; → chunk plan [backfill, seed] connector contract: connector source → run substrate [batches] run contract: plan → run substrate [lower] run contract: run substrate → staging area [stage batch] run contract: staging area → land path [staged batch] run contract: land path → context store [batch write, commit]; → snapshot manifest [publish] store contract: chunk plan → run substrate [chunks] store contract: context store store contract: snapshot manifest connectorcontract connector source run contract compile lower stage batch staged batch PipelineSpec plan run substrate staging area land path store contract chunk plan context store snapshotmanifest declare backfill, seed chunks batches batch write,commit publish pipeline manifests

declare

Pipeline declarations: fields, manifest loading, content hashes, table names and lifecycle commands.

  • pipeline-spectesttesttestA pipeline specification takes PipelineSpec, its source and destination each SourceBlock, and each tables entry a bare name or a table block, the two forms mixing in one array.
  • serializationtestTOML and JSON deserialize into one PipelineSpec; the JSON Schema derived from it is the contract, and contextful schema export writes it to .contextful/schema/pipeline.json.
  • manifest-filetestStartup reads project config and inline [[pipeline]] blocks from contextful.toml, then reads pipelines/*.toml and pipelines/*.json. Startup collects specifications by id.
  • manifest-missingtestpipeline validate reading no manifest file raises PipelineManifestMissing, naming the declaration path.
    Whybecause a validation over a mistyped path otherwise checks nothing and exits zero
  • duplicate-idtestOne id declared twice raises PipelineDuplicateId, naming each file and the line its declaration starts on.
    Whybecause a silent pick between two declarations hides which one runs
  • spec-invalidtestA manifest file the canonical type cannot deserialize raises PipelineSpecInvalid, naming the file, the key path and the value found.
    Whybecause a partly read manifest runs a pipeline its author did not write
  • content-hashtestcontent_hash is 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.
    Whybecause a replay pin that moves on a no-op edit strands pending work
  • table-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, raise PipelineTableNameCollision at validation, naming both declarations.
    Whybecause two tables folding to one name land in one store table and interleave their rows
  • unbound-table-nametestA job target or model reference naming a destination table in a spelling the fold does not produce raises PipelineUnboundTableName, printing the expected spelling.
    WhyP1
  • order-by-defaulttestorder_by defaults to the injected ingest stamp and is inert on a keyless table; key semantics are unkeyed-union.
  • write-modetestwrite_mode is append, the default, or replace. Under append a run adds rows and no key retires; under replace the landing run is the table’s whole current state.
  • replace-unsupportedtestreplace beside a monotonic or opaque-token cursor, a backfill chunk plan or a seed ceiling raises PipelineReplaceUnsupported, naming the table.
    WhyP1
  • config-keytestA source config key outside the set that source enumerates raises PipelineUnknownConfigKey before any I/O, naming the key and the accepted keys; request headers and the forwarded guest table are exempt.
    WhyP1
  • lifecycle-verbstestplan compares desired state with the store without side effects; --json emits a structured diff. apply reconciles one or all pipelines. run fires one pipeline once. serve reconciles continuously.
  • plan-externalstestplan reports added, removed, changed and unchanged external steps against the applied snapshot in text and under externals in --json, with the same change fields as other declaration kinds.
    Whybecause external-only edits affect the applied snapshot and need a visible diff
  • apply-fires-nothingtestApplying a manifest triggers no run by itself. Repeating apply over unchanged sources performs no work except for elapsed schedules.
  • table-errortestteston_table_error = abort, the default, halts the fire at the first failing table. Under continue every other table lands, run exits non-zero naming the failed runs, and a scheduled fire appends its failure count to its log line.
  • chunked-loadteston_table_error governs 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 a monotonic cursor committing watermark-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.

transform

The engine applies declared transforms in order to rewrite a batch in place.

  • chaintesttestThe chain is an ordered list of TransformOp, declared once per pipeline and bound to the root table; a filter takes exactly one column.
  • aritytestA chain operation emitting more rows than it consumed raises PipelineTransformArity.
    WhyA-run#plan-compiled-content
  • filtertestA filter dropping a root row drops that row’s nested children with it.
    Whybecause a child landing under a parent id no surviving row carries is a dangling reference
  • column-missingtestA value filter or cast naming a column the batch does not carry raises PipelineTransformColumnMissing, printing the column and the table.
    Whybecause a cast over an absent column otherwise lands the batch untransformed
  • typed-casttesttestA cast rewrites one column’s type and keeps its name and position; a cast to binary, binary(n), float32[n] or float16[n] keeps a value that type’s JSON form reads and nulls any other.
  • type-carrytestA rename carries a pulled column type to the new name, a select drops it with its column, and a cast to a scalar type drops it.
  • extracttestextract copies 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-filtertestA filter naming absent keeps the rows whose column is missing or null, and one naming present keeps 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.

Scenarios

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.
    WhyA-run#secret-guard-has
  • llm-candidatetestAn LLM candidate starts with sk- at input start or after a byte outside ASCII alphanumerics, - and _; its tail spans that alphabet. Admission masks the whole candidate.
    WhyA-run#secret-guard-has
  • llm-admissiontestAn LLM candidate needs 32 B of tail: a proj-, svcacct-, service-, admin- or ant- prefix permits the whole tail; otherwise its first 32 bytes are ASCII alphanumerics.
    WhyA-run#secret-guard-has
  • assignment-keytestAn assignment key qualifies when it is a keyword or ends in one after _, - or ., reading - as _, so client_secret, x-api-key and db.password qualify and clientsecret does 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 its key= prefix.
  • mask-onlytestThe guard is on by default and blocks no run; each pull logs the count of masked cells per column.
    WhyA-run#secret-guard-has
  • 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 a DeadLetter passes 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.
    WhyA-authority#body-projection

Scenarios

land

The engine processes batches from pull to commit, records ingest counts and isolates decoder failures.

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.
    Whybecause a writer flushing every second otherwise pays a process start, a store open and a node resolution per batch
  • session-inputtestcontextful context land --session reads one SessionBatch per stdin line, lands each as its own run through one landing session, and prints that run’s outcome before reading the next line.
    Whybecause a host running no serving process still needs one session across its batches
  • session-schematestA landing session parses schema.json again only when its version — length, modification instant, file identity — moves; a land adding no column or type replaces no schema.json and takes no schema lock.
    Whybecause 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.

queued receive

A queued delivery answers 202 at once, and a consumer lands it later under the same batch key.

unreadable input

Input a decoder cannot read fails alone, lands as a dead letter, and decodes in a bounded child process.

Flowchart diagram Flowchart, top to bottom. 10 nodes, 10 edges. run contract: connector source → secret guard [pulled batch] run contract: secret guard → recorded pull [guarded batch] run contract: recorded pull → transform chain [normalize] run contract: transform chain → write-path redaction [transformed rows] run contract: write-path redaction → sink capability [redacted rows] run contract: sink capability → batch writer [native]; → child-table shredder [relational] run contract: batch writer → commit marker [rows + position] run contract: child-table shredder → batch writer [child rows] store contract: commit marker → catalog cache [cached position] store contract: catalog cache run contract pulled batch guarded batch normalize transformed rows redacted rows native relational child rows connector source secret guard recorded pull transform chain write-path redaction sink capability batch writer child-table shredder store contract cached position commit marker catalog cache rows + position

Scenarios

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.

typed changes

A typed export publishes keyed changes-v1 events from a staged outbox and acknowledges them in sequence.

  • typed-blocktestA typed Block declares format = "changes-v1", nonempty key columns and a schedule.
    Whybecause a consumer needs stable row identity and a delivery cadence
  • typed-statetestA typed export compares the latest stable committed table read through post-commit-read with its last acknowledged state; commits between reads coalesce into one publication.
    Whybecause a scheduled state export promises the observed frontier rather than every intermediate write
  • typed-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.
    Whybecause a commit hash alone cannot identify the view delivered to a consumer, and a receiver must not mistake a changed view for a retry
  • typed-eventstestEach changed key yields a versioned upsert with its visible row or a deletion with its key; unchanged keys yield no event.
    Whybecause a generic consumer applies the same keyed operations regardless of table shape
  • typed-ordertestTyped events order by encoded key and carry consecutive sequences and stable ids; a repeat delivery carries the same bytes and ids.
    Whybecause acknowledgement can fail after the target applies a batch
  • typed-completetestA publication-complete event follows every change, including an empty set, and states that publication’s change count.
    Whybecause a consumer needs a boundary after which its keyed state represents one observed frontier
  • typed-outboxtestEvents and their next keyed state stage together in the configured execution store; a pending publication blocks staging a later frontier.
    Whybecause a failed send must retain its exact payload and order across restarts
  • typed-identity-changedtestPending events under a changed read identity, or an export redirected to another target under the same name, raise ExportIdentityChanged without delivery; watch exits.
    Whybecause an old outbox cannot prove authorization under a changed view or establish a new target’s baseline
  • typed-acktestA typed export advances its durable acknowledgement sequence only after HTTP 2xx; acknowledging the completion event promotes its staged state in the same transaction.
    Whybecause a receiver acknowledgement and local state promotion have one durable order
  • typed-watchtestcontextful export watch <name> fires the typed export on its schedule and retries a refused send with capped exponential delay from the pending outbox.
    Whybecause delivery resumes without another operator invocation
  • typed-byte-limittestOne typed JSON request holds at most 65536 B.
    Whybecause a row-count bound alone admits an unbounded request body
  • typed-event-too-largetestIf a typed event exceeds one request’s capacity, the exporter raises ExportEventTooLarge. Watch stops without retrying that event.
    Whybecause 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.

delivery progress

A caller reads an outbox’s progress through a supported command and bounds a coordinated delivery to its own time slot.

Scenarios

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 Iceberg Block declares format = "iceberg", one landed table, the admitted credential it reads as, a REST catalog URI, a storage endpoint, a warehouse, a namespace, a table_name, a format_version of 2 or 3, and a token reference.
    WhyA-topology#catalog-read-add-on
  • readtesttestAn Iceberg export reads through post-commit-read every 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.
    WhyP5
  • token-preflighttestIf no adapter resolves the token reference, the exporter refuses at preflight as unresolved-name before reading any row.
    Whybecause a catalog the export cannot authenticate to is known before a file is written
  • egresstestEvery 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 raises ExportStorageUndeclared before any file is written.
    WhyA-topology#one-egress
  • casttestEach column takes the Iceberg type holding every value its stored type does: int, long, double, boolean, string for text and JSON, binary, fixed[n], and nested struct, list and map; a variant exports as its tagged struct.
    Whybecause a reader of the copy sees every source value unchanged, and the read face answers each cell in its stored type
  • maskedtestA column the credential’s mask rewrites exports at the type projected-type reads it as, so a masked cell publishes as the read face answers it.
    Whybecause a digest published under its column’s stored type decodes as bytes it never held
  • fixed-listtestA FixedSizeList<T, n> column exports as list<T>, the exporter reattaching the element’s field id and recording n in the table property contextful.fixed-size-list.<column>.
    Whybecause Iceberg has no fixed-size list in any format version, and the width is the one fact the cast drops
  • timestamp-nstestUnder format_version = 3, a nanosecond timestamp column exports as timestamp_ns or timestamptz_ns, keeping every digit.
    Whybecause a microsecond cast loses the digit a key or an ordering may depend on
  • type-refusedtestA nanosecond timestamp column, top-level or nested, under format_version = 2 raises ExportTypeUnsupported naming the column at preflight, before any row is read.
    WhyP1
  • file-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.
    WhyA-topology#catalog-read-add-on
  • 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.
    Whybecause a file held whole until it closes bounds the export’s host by file size rather than by part size
  • memorytestWhile 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.
    Whybecause a host with a small memory limit then runs an export whatever size its data files reach
  • createtestAn export finding no table creates it at the declared format version and commits its files as one append snapshot, requiring that main does not exist.
    WhyA-run#iceberg-overwrite-commit
  • overwritetesttestEach later export commits one overwrite snapshot that adds its data files and marks every live file of the parent snapshot deleted, requiring the table uuid, main at the parent snapshot id, and the current schema and default spec ids.
    WhyA-run#iceberg-overwrite-commit
  • 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 raises ExportCommitConflict.
    WhyA-run#iceberg-overwrite-commit
  • orphanstesttestAfter commit-retry refuses, 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.
    Whybecause catalog maintenance removes only files some snapshot referenced, so an uncommitted file stays until its writer removes it
  • commit-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 or main leaves its parent.
    Whybecause a commit the catalog applied, or is still applying, after the connection dropped references every file it added
  • commit-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.
    Whybecause a proxy’s 408 or 429 may follow a commit the catalog applied, and only the specification’s refusals prove it did not
  • retentiontestThe exporter expires no snapshot and deletes no committed file; the catalog’s snapshot expiration owns retention.
    Whybecause a reader may still plan a query against the previous snapshot while the next export commits
  • frontiertestThe 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.
    Whybecause each retained snapshot holds a full copy, and an unchanged export buys a reader nothing
  • schema-changedtestA source whose columns or types no longer match the Iceberg table’s current schema raises ExportSchemaChanged before any file is written; the export evolves no schema.
    Whybecause a reader’s queries name the columns they were written against, and an evolution the exporter chooses changes them unannounced
  • table-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.
    Whybecause files written to the declared shape mislabel an existing table’s version or partition values
  • audiencetestEach 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.
    WhyA-run#iceberg-audience
  • 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.
    Whybecause a gateway restating credential admission in part admits a revoked credential while appearing to check it
  • wake-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.
    Whybecause one table’s exports serve one audience each, and a wake carries one credential
  • namespace-sharedtestTwo Iceberg exports naming one namespace under different credentials, one credential under two namespaces, or one table raise ExportNamespaceShared at preflight, naming both exports.
    WhyA-run#iceberg-audience
  • caller-sqltestCaller-written SQL reaches the catalog’s query engine only from a store entry declaring caller_sql for a single-audience warehouse; otherwise the gateway runs the entry’s templates against the mapped namespace.
    WhyA-run#iceberg-audience
  • routetestWhere a deployment adds a catalog query engine, the gateway answers POST /stores/<id>/query from a reader whose audience key maps to a namespace through that engine, and sends every other caller to the Container.
    WhyA-topology#catalog-read-add-on
  • gateway-entrytestA gateway store entry’s basin object names the query engine’s endpoint, optional templates with {namespace} and {table} placeholders, and boolean caller_sql; its token and audience map are derived secret names.
    Whybecause the store’s binding name already derives its other secret names, and an authored name lets one entry read another’s token
  • query-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 raises ExportQueryRefused and reaches no engine.
    WhyA-run#iceberg-audience

Scenarios

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.

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.

model

The engine builds model tables from SQL over store tables, validates contracts and tests, and records freshness and holds.

contracts

A published model declares a versioned column contract, and a contract change publishes only under its version bump.

builds

A build stages the model, checks its contract and tests, and records a watermark over its inputs.

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 prints Held, or Renewed over an unexpired hold; --json prints the receipt as an object.
  • status-verbtestbuild status <model> reports the last attempt separately from the published build; --json carries 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 raises ModelBuildUnknown.
    Whybecause a hold on a build that does not exist protects nothing and reads as protection
  • hold-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, raises ModelBuildCollected and commits nothing.
    Whybecause a hold over removed parts reports Held for a build no reader can read
  • hold-bucket-firsttesttestOn a store declaring [sync], build hold commits 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.
    Whybecause 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

publish

Published tables: contract identity, snapshot publication, build history, freshness and collection holds.

Flowchart diagram Flowchart, top to bottom. 7 nodes, 6 edges. run contract: pipeline build → staging tables [materialize] run contract: staging tables → matches the declared contract? [staged tables] run contract: matches the declared contract? → last published state [no: PipelineContractMismatch]; → snapshot manifest [yes, commit] run contract: last published state run contract: collection hold → snapshot manifest [collection skips] store contract: snapshot manifest → history logs [derives] run contract materialize staged tables no:PipelineContractMismatch pipeline build staging tables matches thedeclaredcontract? last publishedstate collection hold store contract snapshotmanifest yes, commit derives collection skips history logs

Scenarios

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