contract assurance · 80-formal.md · 81-engineering.md · 82-consumer.md · 83-gate.md · guide · card
The gate and the release tag
How contextful-ci checks a change and cuts a release: typed subcommands, the gate’s stages
in run order, its parts, folds and shards, its build jobs and footprint budgets,
the target ledger the evaluate stage reads, and the signed release tag with the local
packaging that follows it. Each operation keeps its assurance clause ids.
automate
Typed subcommands over compiled binaries, and the boundary shell keeps.
typed-subcommandtestAn automation step carrying a branch, a loop or a retry, or running past 5 lines, is a typed subcommand shelling out to compiled binaries.Why
P7subcommand-surfacetestA subcommand exposes typed options, generated help and an entry point a test calls directly.one-pathtestThe gate invokes the subcommand a contributor invokes.Why
because a local reproduction and a gate run then execute one code pathunchecked-shelltestA shell file in the tree thatshellcheckrejects raisesShellCheckFailedin the toolchain stage.Why
P7test-shellchecktestA gate stage starting a test process over a tree tracking a shell file puts theshellcheckunchecked-shellruns first on that process’sPATH.Why
because a suite asserting a script passesshellcheckotherwise depends on whether the sandbox image ships the linterbuild-time-toolchaintestThe automation toolchain links into no build profile and enters no release artifact.
gate
Stage order, secrets of record, the crate-graph, row-token, egress and dependency rules, the formal stage, build jobs, free disk, footprint budgets and surface checks.
stage-sequencetestThe gate runs its stages in order — pins, toolchain, schema, test-first, workspace, acceptance, evaluate, features, crate graph, connectors, TypeScript surfaces, formal, unwired, budget — all of both tiers when none is named, else the named subset, which--planprints.nightly-tiertestcontextful-ci stages --parts --tier nightlyprints the features, formal, unwired and budget parts in run order;stages --partsprints the remaining parts, those a change runs before merge, andgate --stageruns a part of either tier.stage-subsettestA selected subset runs in the sequence’s order; a stage reading an unselected predecessor’s output, with that output absent, raisesStagePredecessorMissing, naming both stages, before any stage runs.test-first-partstestThe test-first parts require a changed test in each changed source package undertest-first; each changed source package’s part checks only its own changed tests against the base.Why
because one base build per package fits the sandbox’s wall clock more reliablyworkspace-partstestThe workspace parts compile the feature-unified workspace untested, run differential CLI tests apart and remaining CLI tests in eight shards undercli-shard-map, and each other non-acceptance package suite in one of four groups.cli-shard-maptesttools/ci/cli-shards.tomlassigns each remaining CLI test, a nested-build group whole, one shard by recorded duration within 9 min per shard; an unmapped test, reported as drift, joins its group or the least-loaded shard, which adds unit tests and doctests.folded-partsteststages --partsfolds pins, toolchain, schema and test-first validation intopreflight; each package group’s workspace and evaluate parts intosuite.<group>, probes intosuite.ci; crate graph, connectors and surfaces intoperimeter; members stay runnable alone.Why
because every part pays its own sandbox setup and compile, and a fold pays it once for every membercompile-onlytestgate --stage <part> --compile-onlyruns eachcargo testinvocation the part runs, in its own target directory with--no-runand without its--arguments, and nothing else; a CLI shard compiles every shard’s invocations.Why
because a compile cache serves a part only when the warming build’s cargo arguments, environment and target directory match the part’scompile-groupsteststages --parts --compile-groupslists, in run order, the first part of each distinct compile signature — its ordered--compile-onlyinvocations and target directory — across both tiers unless--tiernames one.Why
because the eight CLI shards share one signature, and warming each compiles it eight timesfold-verdictstestA fold runs each member after an earlier member fails, skips a member reading a failed member’s output, and fails when any member fails.Why
because one fold check replaces a check per member, and one run reports every member’s verdictstage-reportstestEach stage prints the environment it leaves and its memory limit, peak and event counts, and a failing stage prints its diagnostics before propagating its exit code.Why
because memory exhaustion is silent, and a kill then reads as a number in the logpins-stagetestThe pins stage resolves every pinned artifact identity a run depends on before any compilation.evaluate-stagetestThe evaluate stage runs every gate-tier ledger entry and the native case set in the deterministic tier, one part per package group plus a probe part, each reporting the floor and baseline verdicts over its entries.schema-stagetestThe schema stage regenerates each derived artifact into a scratch location, compares it byte for byte against the committed copy, and runscontextful-spec lint.secret-ciphertexttestIn the schema stage, a key other thanDOTENV_PUBLIC_KEY*in a git-tracked.env*file other than.env.exampleholding a value without theencrypted:prefix, or a tracked.env.keys, raisesSecretPlaintext, naming file and key.Why
because a tracked file reaches every clone, and only dotenvx ciphertext is safe there while its private key stays untrackedsecret-scopetestIn the schema stage, a key other thanDOTENV_PUBLIC_KEY*in a git-tracked.env*file whose block — the assignments above it, up to the first blank line — opens with no comment carrying text raisesSecretScopeMissing, naming file and key.Why
because a provider token cannot report its own grants, so the comment above it is the one record of what it reachesformal-stagetestThe nightly formal stage runscheck-command,command,protocol-check, the protocol differential over 10000 generated cases on both lease paths, andjournal-check; a non-zero exit from any reds the run.Why
P7typescript-surfacestestThe TypeScript surfaces run each declared typecheck, test and build script in one stage; an absent script skips only that script.native-test-filestestThe surfaces stage runs every regular native.test.tsfile under a direct TypeScript surface’stest/ortests/tree through Node’s TypeScript strip runner, and refuses absent passing tests or pinned names.Why
because a package script can omit a pinned test while reporting successsurface-check-failedtestA surface whose typecheck, unit tests or framework build fails raisesSurfaceCheckFailed, naming the surface and the script.Why
P7
affected parts
stages --affected maps a diff to the parts it reaches.
affected-partstestcontextful-ci stages --parts --base <rev> --affectedlists, in run order, the parts of the selected tier the diff from the merge base of<rev>and HEAD reaches, the required tier always includingpreflight.affected-everytestA change toCargo.toml,Cargo.lock, a toolchain file, anything undercrates/,tools/or.github/, or any path no other affected clause maps reaches every part of either tier.affected-spectestA change underspec/orreferences/reaches test-first validation, one underspec/the foundation workspace part, and each clause whose statement orspec/pins.tomlentry changed the parts running its pinned test or theorem.affected-spec-inputstestA change tospec/01-topology.mdorspec/capabilities.tomlreaches crate-graph, tospec/adr/A-assurance.mdthe formal stage, and tospec/terms/assurance.tomlevery budget part.Why
because those stages read the file directly, apart from any clause pinaffected-removedtestA clause the merge base’s lock holds and HEAD’s lacks reaches the evaluate stage.Why
because the evaluate stage refuses a ledger entry naming a clause the lock lacksaffected-unplacedtestAn unreadable spec lock or pins file, or a changed clause pinned outside every workspace package, reaches every required part.Why
because a pin the listing cannot place runs on a part the listing cannot nameaffected-surfacestestA change underapps/orpackages/reaches the TypeScript surfaces, acceptance and crate-graph parts.affected-formaltestA change underformal/other than alean-toolchainfile reaches the formal and evaluate stages, the foundation, CLI and CLI differential workspace parts, and every feature part.
dependency rules
The crate-graph stage holds the dependency graph and runtime source to their rules.
crate-graphtestThe crate-graph stage holds run-path crates to reaching read-path crates through the three crossing crates alone; another edge raisesCrateGraphViolation, naming both crates.Why
P5locked-resolvetestThe crate-graph stage resolves every graph--lockedagainst the committedCargo.lock, so a lock behind its manifests, or one rewritten before the stage starts, fails the stage before any rule runs; the stage never rewrites it.Why
because a rule held over a graph the lock file does not record passes a build that resolves anotherrow-tokentestEvery row-returning function of the store crate takes the enforcement token type, whose constructor is private to the enforcement module; a row path outside a registered relation raisesEnforceUnmediatedPath, naming the function.Why
P5dependency-denytestThe crate-graph stage runs pinned cargo-denycheck bansover each profile’s resolved graph, denying every crate a profile-wide dependency refusal names; a hit raises that refusal’s error, naming the profile and the path.deny-profile-graphtestA profile’s graph is the binary resolved with default features off and the profile’s feature on, across every target, excluding development dependencies; the stage prints each profile the binary declares no feature for.deny-listtestdeny.tomlholds one[bans]entry per crate or feature a profile-wide dependency refusal names, its reason that refusal’s clause id; an entry missing or extra raisesDenyListDrift, naming it.Why
because a refusal whose crate the list omits passes every profile graph linking itunconfigured-egresstestAn outbound call reachable under the default configuration — usage ping, license check, update probe — raisesUnconfiguredEgressin the crate-graph stage, naming the call site.Why
because some deployments run with no external reach, and a call nobody configured moves data outside every grant, zone and audit recordinterpolated-claimtestA source lint over the runtime crates finding a subject claim formatted into SQL text raisesEnforceInterpolatedSubjectClaim, naming the file and line.Why
P1
footprint
Build resources and each profile’s artifact size stay within their bounds.
parallel-jobstestA footprint build’s parallel job count is its build memory — the cgroup memory ceiling, else the physical or container-engine memory — divided by 3 GiB, at least one and at most the processor count.Why
because one optimized C++ unit of the SQL engine peaks near 3 GiBfree-disktestA stage starting with less than 2 GiB of free disk raisesBuildDiskPreconditionand exits 28 before doing work.Why
P7edge-budgettestThe edge profile compresses to at most 50 MiB.full-budgettestThe full profile compresses to at most 150 MiB.control-budgettestThe control profile compresses to at most 60 MiB.footprinttestPer nightly run and per profile, the footprint step builds the static-linked Linux target, compresses it, and holds its size to the profile’s budget and its dynamic dependencies to the platform C library.Why
P7budget-stagetestThe budget stage runsfootprintover every profile, in one nightly-tier part per profile, and the evaluate stage builds no profile.Why
because one link-time-optimized release build of the full profile fills most of one stage’s wall clockfootprint-exceededtestAn artifact over its profile’s budget, or carrying a dynamic dependency beyond the platform C library, raisesFootprintBudgetExceeded, naming the profile.Why
P7
unsettled: Which workload, cadence and drift bound does the idle-resident soak run under, given that a multi-day soak fits no per-change gate? owner: build affects: assurance.gate
unsettled: Which cache hit rate holds test-first and workspace under the per-stage wall clock once the Cargo.lock-keyed build cache serves the gate container (issue 31)? owner: build affects: assurance.gate
measure
The target ledger: each tracked target, the clause it serves, how it is measured and its record per commit.
ledgertesttestevals/ledger.tomlholds one entry per tracked target: an id, an owning clause id, a run-report metric path, a tier, a method naming one integration test, bench target, case set or probe, and a threshold.unresolved-entrytestAn entry whose owning clause, method or metric path resolves to nothing raisesMeasureEntryUnresolvedbefore any measure runs.Why
P7open-entrytestAn entry naming an issue in place of a method reports open and gates nothing, andevals/ledger.mdcarries every entry’s computed status.tiertestA gate-tier entry decides the evaluate stage, a trend-tier entry decides nothing, andcontextful-ci measureruns the gate tier alone unless--tiernamestrend,scheduledorall.judged-tiertestAn entry whose metric names a judged figure is scheduled-tier; on another tier it resolves to nothing underunresolved-entry.Why
because a judged figure costs a model call per item and moves with the judge, not with the changecount-firsttestA gate-tier entry measures a count, a ratio within one run or a size under a locked resolve; a wall-clock or resident-memory figure is trend-tier.Why
because a shared container moves wall-clock figures past any band narrow enough to catch a regressionrecordtestA measure writes one JSON record carrying its entry id, value, sample count, seed and run stamp; a gate-tier method finishing without one raisesMeasureRecordMissing.Why
P7seededtestEvery generated fixture and randomized schedule derives from the record’s seed, and replaying that seed reproduces a count-valued entry’s value.Why
because a failure that cannot replay cannot be fixedseed-mismatchtestA gate or scheduled record whose seed differs from its entry’s declared seed raisesMeasureSeedMismatchand counts as red; a trend mismatch leaves no comparable record.Why
because a figure measured under another seed replays nothing the ledger namestiming-iterationstestA timed batch reports p50 and p95 over 200 repeats of its operation, each after warm-up.timing-batchestestA timed figure is the median of 5 repeats of its timed batch.trend-directiontestEach trend-tier ledger entry declareshigher_is_betterorlower_is_betterfor its metric.trend-baselinetestA trend figure compares with the newest earlier successful default-branch report for the same entry and seed underrunner-stamp; absent matching history yields no annotation.trend-bandtestA trend figure more than 25 percent worse than its baseline annotates the run report and fails no stage.runner-stamptestThe run block carries the runner’s processor model, processor count and memory limit, and a trend figure compares only against a baseline with the same stamp.absolute-thresholdtestA threshold is an absolute figure of this system’s own measure, and it tightens only throughraise-only.threshold-costtestA gate-tier target that rises under<=, falls under>=or moves under==carries a# cost:comment above its entry that its entry at<rev>lacks, orcontextful-ci measure --since <rev>raisesMeasureThresholdLoosened.Why
because a loosened gate otherwise reads as a changed number, and the cost it accepts goes unrecordedhistorytestA run on the default branch attaches its run report to the measured commit underrefs/notes/measures, and no verdict reads a note.run-reporttestcontextful-ci measure-reportwrites the note line — commit, run id, attempt, exit code, records and trend annotations — with an integer run id, and reads back every earlier note whatever its run id’s JSON type.Why
because earlier notes onrefs/notes/measurescarry text run ids, and an unreadable note hides every older baselinemeasured-basistestA bound with a measured basis names a trend-tier or scheduled-tier ledger entry id as its benchmark.
Scenarios
assurance.measure.unresolved-entry: WHEN an entry namesrun.journal.entry-keys, THEN the run raisesMeasureEntryUnresolvedand no test runs.assurance.measure.trend-band: WHEN a trend p95 moves from 40 ms to 52 ms on a matching runner stamp, THEN the report carries a +30 percent annotation and the stage passes.assurance.measure.trend-baseline: WHEN a newer failed report follows two successful matches, THEN the trend compares with the newer successful match and the stage passes.assurance.measure.run-report: WHEN the baseline note’s run id is the textcontextful-measures_2026-10-14and the run’s is42, THEN the report writes42and its annotation names that text run id as its baseline.assurance.measure.open-entry: WHEN an entry’s method is{ issue = 43 }, THENevals/ledger.mdlists it open and the evaluate stage ignores it.
release
The release tag’s version, the refusals guarding it, the signed annotated tag contextful-ci tag creates, and the local packaging the operator runs from it.
versiontesttestA release tag isv0.<minor>.<patch>:<minor>counts the milestones computingclosedin the tagged commit’sspec/status.mdplus each store format major and each manifest format past1, and<patch>counts the existing tagsv0.<minor>.*.store-formattestA commit’s store format is theFormatVersion::CURRENTconstant in itscrates/contextful-core/src/store/lay_out.rs, and1.0where that constant is absent; a constant that does not parse raisesTagStoreFormatUnread.manifest-formattestA commit’s manifest format is theMANIFEST_FORMATconstant in itscrates/contextful-core/src/store/sync.rs, and1where that constant is absent; a constant that does not parse as an integer raisesTagManifestFormatUnread.annotatedtestcontextful-ci tagcreates a signed annotated tag onHEADnaming the closed milestones once every refusal of this operation clears, and never moves or replaces an existing tag.dirty-treetestA working tree or index differing fromHEADraisesTagTreeDirty.Why
because the gate then judges files the tagged commit does not holdoff-branchtestAHEADunreachable from the default branch,origin/HEADunless--branchnames another, raisesTagOffDefaultBranch.version-regressedtestA computed version below the highest existingv<major>.<minor>.<patch>tag raisesTagVersionRegressed, naming both.format-break-patchtesttestA computed<minor>equal to the highest existing tag’s, whileHEAD’s store format major or manifest format differs from that tag’s, raisesTagFormatBreakPatch, naming both formats.format-notestesttestA tag whose store format major exceeds the highest existing tag’s carries the message lineStore format <old> → <new>: upgrade every reader, then every writer together., and one whose manifest format exceeds it the same line openingManifest format.Why
because an older reader refuses a manifest of the newer major, and an older writer refuses the table once a newer writer commitsworkspace-versiontestA[workspace.package]version inHEAD’sCargo.tomlthat is absent or differs from the computed version raisesTagWorkspaceVersionMismatch, naming both.gate-failedtestOnce the other refusals clear, every gate stage runs againstHEADwithHEAD~1as base; a failing stage raisesTagGateFailed, naming the stage’s refusal, and no tag is created.release-tag-versiontestcontextful-ci releaseover aHEADcarrying av*tag, none of which equalsvplus the[workspace.package]version, raisesReleaseTagVersionMismatch, naming both, before any build.Why
because a tag cut withoutcontextful-ci tagotherwise ships binaries reporting another version
Scenarios
assurance.release.version: WHEN milestones 0 to 7 and 11 computeclosedandv0.9.0exists, THEN the computed version isv0.9.1.assurance.release.version-regressed: WHEN milestone 11 reopens afterv0.9.0, THEN the computedv0.8.0raisesTagVersionRegressed.assurance.release.store-format: WHEN 16 milestones computeclosed,v0.16.1declares store format1.0andHEADdeclares2.0, THEN the computed version isv0.17.0and its message carriesStore format 1.0 → 2.0.assurance.release.manifest-format: WHEN 16 milestones computeclosedunder store format2.0,v0.17.0declares manifest format1andHEADdeclares2, THEN the computed version isv0.18.0and its message carriesManifest format 1 → 2.assurance.release.format-break-patch: WHEN milestone 11 reopens afterv0.9.0while the store format moves from1.0to2.0, THEN the computedv0.9.1raisesTagFormatBreakPatch.
deploy-apps
The one GitHub Actions workflow, its trigger, permission and credential shape, and contextful-ci deploy cloudflare, the command it and an operator run to ship one Cloudflare app.
workflow-triggertestThe workflow.github/workflows/deploy-cloudflare.yml, written in YAML’s JSON subset, starts onworkflow_dispatchalone, with a requiredappchoice listing every app the deploy command ships and an optionalrefstring.Why
because a deploy then starts only when an operator names one app, never from a push, a schedule or a pull requestworkflow-permissionstestThe workflow grantscontents: readalone, runs one job on theubuntu-24.04image, pins eachusesto a 40-hex commit ofactions/checkoutoractions/setup-node, and checks out without persisting credentials.Why
because a tag an action’s owner moves runs unreviewed code beside the deploy tokenworkflow-credentialstestThe workflow reads exactly the secretsCLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_ID, into its deploy step’s environment, and carries no URL, hostname or run of 32 hex digits outside a pinned commit.Why
because the repository is public, and each organization deploys into its own accountjob-timeouttestThe deploy job’stimeout-minutesis at most 90 min.Why
because a full-profile release build with its conformance pass fits that, and a hung deploy holds the token no longerone-commandtestThe workflow’s onerunstep iscontextful-ci deploy cloudflare --app "$APP", the command an operator runs locally with the same two environment variables.Why
because a local deploy and a workflow deploy then execute one code pathworkflow-refusedtestAnother file under.github/workflows, or a deploy workflow breachingworkflow-trigger,workflow-permissions,workflow-credentials,job-timeoutorone-command, raisesDeployWorkflowRefusedin the schema stage, naming the file and the breach.Why
because the token’s reach then stays reviewable in one file every change is checked againstcredentials-absenttestcontextful-ci deploy cloudflarewithCLOUDFLARE_API_TOKENorCLOUDFLARE_ACCOUNT_IDunset or empty raisesDeployCredentialsAbsent, naming the variable, before any command runs.Why
because wrangler otherwise falls back to an interactive login or a cached accountresourcestestThe command creates each D1 database and R2 bucket the app’swrangler.example.jsoncbinds when absent, named after the Worker and binding, and writes their identifiers only into the untrackedwrangler.deploy.jsoncbeside it.Why
because an identifier belongs to one account, and the committed template serves every accountcontainer-imagetestFor an app declaring a container, the command packages thecontextful-fullx86_64-unknown-linux-muslrelease inside the Dockerfile’s pinned Alpine builder and prepares eachimagesbuild context from that archive beforewrangler deploy.Why
because the image then carries the exact binary its release metadata and compatibility report namebuilder-jobstestThe builder compiles with the caller’sCARGO_BUILD_JOBSwhen set, and otherwise with one job per 1536 MiB of the memorydocker inforeports, at least one.Why
because one job per CPU in a smaller Docker VM gets the C++ compiler killed during the SQL engine and link-time optimization buildsworktree-mounttestFrom a git worktree, the builder also mounts the common git directorygit rev-parse --git-common-dirnames, read-only at its own path.Why
because a worktree’s.gitfile points outside the source mount, and the release records the source commitscheduling-policytestThe execution app’s container declares thedurable_objectscheduling policy and animagesmap, and its container class starts each instance with that map’s image and thestandard-1instance type.Why
because an account may refuse the default policy, and the container library passes neither image nor instance type to a Durable Object-managed starthealthtestAfterwrangler deploy, the command repeats an anonymousGET /to the URL wrangler reports until a status in the app’s expected set arrives or--health-waitelapses, printing each status alone; the wait elapsing raisesDeployUnhealthy, naming the last status.Why
because wrangler reports success before the Worker answers, and a freshworkers.devroute answers a redirect firstexpected-statustestThe execution app answersGET /with 404, the gateway with 404 or 503, and the console with 200 or 302.Why
because each answer is the app’s unauthenticated default: no route, no configured store, the prerendered pagequiet-outputtestThe command prints its step names and the health status; wrangler’s output prints only on failure, with each URL and each run of 32 or more hex digits replaced.Why
because a public repository’s Actions logs are public, and wrangler’s output names the account’s subdomain and resource identifiers