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:
import { defineLambda } from "@j0nathan-ll0yd/core";
defineLambda({ provenance: "full" });| Scope | Binds | Loader |
|---|---|---|
'full' | Source revision, deploy instant, workflow ref / run id / attempt | loadDeploymentProvenance() |
'revision' | Source revision only | loadSourceRevisionProvenance() |
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 variable | Environment variable | Derived from | Required |
|---|---|---|---|
source_revision | SOURCE_REVISION | git rev-parse HEAD in the checkout | yes |
deployed_at | DEPLOYED_AT | Bind-time UTC instant | yes |
workflow_ref | DEPLOYMENT_WORKFLOW_REF | GITHUB_WORKFLOW_REF | no |
workflow_run_id | DEPLOYMENT_WORKFLOW_RUN_ID | GITHUB_RUN_ID | no |
workflow_run_attempt | DEPLOYMENT_WORKFLOW_RUN_ATTEMPT | GITHUB_RUN_ATTEMPT | no |
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_*
- An explicitly-set, non-blank
TF_VAR_<name>wins. - 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_revisionis unset, and the working directory is not a git checkout with a resolvableHEAD. TF_VAR_source_revisionis set to something that is not a 40-hex commit sha.- A Lambda declares
provenanceand the working tree has uncommitted changes — tracked edits or untracked non-ignored files. The build bundles the bytes on disk whilesource_revisionnames 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-deploydowngrades 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 oforigin/mainis outside this gate. The servedsource-revisionidentifies 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.
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-*):
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 },
});| Key | Source |
|---|---|
content-sha256 | Digest of the exact bytes written |
composed-at | Instant the bytes were composed |
deployment-stage | provenance.stage |
source-revision | provenance.sourceRevision |
deployed-at | provenance.deployedAt, when present |
workflow-ref | provenance.workflowRef, when present |
workflow-run-id | provenance.workflowRunId, when present |
workflow-run-attempt | provenance.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.
Related
@j0nathan-ll0yd/core-- the loaders.exportToS3-- the writer that stamps.DeploymentProvenance-- the shared type.- Deployment -- the surrounding
mantle deployflow.