Skip to content

Deployment Provenance ​

Every artifact a Lambda writes can carry the commit it was built from, the instant the deployment bound that fact, and the workflow that ran it. Mantle derives those facts once, in mantle deploy, and stamps them at the artifact-writer seam.

Why the framework derives them ​

A workflow that binds TF_VAR_SOURCE_REVISION itself provisions provenance only on the path that workflow runs. A deploy from a laptop takes a different path and binds nothing, so the artifact still publishes but claims nothing about its source. mantle deploy computes the same values on both paths, so the guarantee holds by construction rather than by a comment in a script.

Declaring the scope ​

A Lambda opts in through defineLambda:

typescript
import { defineLambda } from "@j0nathan-ll0yd/core";

defineLambda({ provenance: "full" });
ScopeBindsLoader
'full'Source revision, deploy instant, workflow ref / run id / attemptloadDeploymentProvenance()
'revision'Source revision onlyloadSourceRevisionProvenance()

Use 'revision' when no contract rule describes the artifact. Stamping a deploy instant and a workflow on bytes that cite no rule claims more than the writer can support; the revision-only subset claims exactly what is true.

The declaration, not the environment-variable scanner, drives injection. Mantle attributes environment variables per module: importing one function from a file gives the Lambda every variable that whole file reads. Scanning would therefore hand a revision-only writer the full block as soon as it shared a helper with a full-block writer. The loaders live in @j0nathan-ll0yd/core, which the scanner does not walk, so the two scopes stay distinct however instance code is factored.

What mantle deploy binds ​

mantle deploy derives the facts before any side effect — before images are pushed, before tofu runs — and passes them as -var=:

Terraform variableEnvironment variableDerived fromRequired
source_revisionSOURCE_REVISIONgit rev-parse HEAD in the checkoutyes
deployed_atDEPLOYED_ATBind-time UTC instantyes
workflow_refDEPLOYMENT_WORKFLOW_REFGITHUB_WORKFLOW_REFno
workflow_run_idDEPLOYMENT_WORKFLOW_RUN_IDGITHUB_RUN_IDno
workflow_run_attemptDEPLOYMENT_WORKFLOW_RUN_ATTEMPTGITHUB_RUN_ATTEMPTno

The generator declares these variables only when a Lambda declares provenance, and mantle deploy passes exactly the declared set. A project that opts out gets a byte-identical variables.tf.

Required variables carry no "" default, matching the framework's posture for every other required variable: an unsupplied value fails at plan time instead of reaching the Lambda as an empty string.

Precedence versus TF_VAR_* ​

  1. An explicitly-set, non-blank TF_VAR_<name> wins.
  2. The CLI derivation fills every fact left unset.

A blank TF_VAR_* counts as unset, so blanking cannot defeat the derivation. When an explicit binding differs from what the checkout derives, mantle deploy prints both values and applies the explicit one.

That ordering lets a workflow already binding these values keep doing so unchanged while the framework closes the gap the same workflow leaves on a local deploy. Once a workflow's bindings are removed, the CLI supplies them and nothing else changes.

Failure modes ​

mantle deploy exits 1 before invoking tofu when:

  • A Lambda declares provenance, TF_VAR_source_revision is unset, and the working directory is not a git checkout with a resolvable HEAD.
  • TF_VAR_source_revision is set to something that is not a 40-hex commit sha.
  • A Lambda declares provenance and the working tree has uncommitted changes — tracked edits or untracked non-ignored files. The build bundles the bytes on disk while source_revision names the last commit, so a dirty deploy would ship bytes the stamp does not describe, with every downstream verifier green. The refusal names the offending paths. --allow-dirty-deploy downgrades it to a loud warning and marks the derivation log ([DIRTY TREE: --allow-dirty-deploy]). build/ output and git-ignored files are not counted. Ancestry of origin/main is outside this gate. The served source-revision identifies a commit; it does not establish ancestry. Atlas decision 0124 retired A20's continuous ancestry arm and declined a deployment-time ancestry prohibition as a substitute. A clean off-main commit can pass this check.

Deploying from a tarball stays possible: bind TF_VAR_source_revision explicitly.

Loading at runtime ​

The loaders fail closed. A Lambda deployed outside the provenance-aware path throws before it can publish an untraceable artifact.

typescript
import { loadDeploymentProvenance, loadSourceRevisionProvenance } from "@j0nathan-ll0yd/core";

const full = loadDeploymentProvenance(); // provenance: 'full'
const subset = loadSourceRevisionProvenance(); // provenance: 'revision'

Each validates format, not just presence: the revision must be a 40-hex sha, DEPLOYED_AT must be a UTC instant, and the run id and attempt must be digits.

Stamping an artifact ​

exportToS3 writes the stamps as S3 user metadata (x-amz-meta-*):

typescript
import { exportToS3 } from "@j0nathan-ll0yd/aws";
import { loadDeploymentProvenance } from "@j0nathan-ll0yd/core";

await exportToS3({
  bucket,
  key: "exports/health.json",
  data,
  provenance: loadDeploymentProvenance(),
  metadata: { "spec-contract": specContract },
});
KeySource
content-sha256Digest of the exact bytes written
composed-atInstant the bytes were composed
deployment-stageprovenance.stage
source-revisionprovenance.sourceRevision
deployed-atprovenance.deployedAt, when present
workflow-refprovenance.workflowRef, when present
workflow-run-idprovenance.workflowRunId, when present
workflow-run-attemptprovenance.workflowRunAttempt, when present

content-sha256 and composed-at are stamped on every write, with or without provenance: both derive from the bytes being written, so they need no configuration and cannot fail. An existing caller adopts nothing and still gets a body digest.

Absent optional facts emit no key rather than an empty one, so a reader can distinguish "this artifact makes no claim about the workflow" from "the workflow was blank".

What stays in the app ​

Contract identity is app-owned and does not belong in the framework: spec shas, estate-contract pins, rule versions, artifact key lists, and assurance record schemas. Those metadata key names are wire tokens; putting them in the framework would turn every rename into a framework release. Pass them through metadata.