Artifact assurance pilot — Mantle API
Phases 1 and 2 of decisions/0124-artifact-assurance-implementation.md, from the Mantle side. This page is the seam LP consumes; the reference pages are @j0nathan-ll0yd/types and @j0nathan-ll0yd/aws.
What exists now
One checked publication operation, one pure declaration type, and one manifest projection that reads that declaration statically.
| Name | Package | Role |
|---|---|---|
ArtifactDefinition<T> | @j0nathan-ll0yd/types | Key, media type, cache policy, and codec, as one value |
ArtifactCodec<T> | @j0nathan-ll0yd/types | App-owned encode plus exactly one of validate or judge |
ArtifactVerdict | @j0nathan-ll0yd/types | What judge returns: ok: true + warnings, or ok: false + issues |
ArtifactFinding | @j0nathan-ll0yd/types | One {id, message} a contract has to say about a body |
defineArtifact | @j0nathan-ll0yd/types | Identity function; narrows the key to a literal, infers the value |
jsonCodec(validate) | @j0nathan-ll0yd/types | JSON.stringify; validator sees JSON.parse(JSON.stringify(v)) |
textCodec(validate) | @j0nathan-ll0yd/types | Identity encoding; validator sees the exact text |
publishArtifact | @j0nathan-ll0yd/aws | Encode, validate, hash, stamp, write — the one checked operation |
prepareArtifact | @j0nathan-ll0yd/aws | The checked half alone; writes nothing, returns a frozen receipt |
publishPreparedArtifact | @j0nathan-ll0yd/aws | Writes a receipt's retained bytes |
PreparedArtifact | @j0nathan-ll0yd/aws | Frozen receipt: key, media type, cache policy, length, digest |
PublishedArtifact | @j0nathan-ll0yd/aws | What was written, including the composed-at actually stamped |
Sixteen exported names in total: the twelve above, the two option interfaces PublishArtifactOptions and PublishPreparedArtifactOptions, and the two codec shapes ValidatingArtifactCodec and JudgingArtifactCodec the ArtifactCodec union is built from. Six are runtime values, ten are types. No new package, no catalog, no registry field, no evidence store, no cross-object transaction.
What LP owns
Everything about meaning. Mantle contains no LP key, schema, threshold, selector, or copy — the checked path never learns one, because the key and the contract both arrive on the definition the caller passes.
- The artifact module (
src/artifacts.ts), which is pure: no environment read, no client, no I/O. - The validators.
assertExportConforms's ajv compile-and-check becomes the body of ajsonCodecvalidator; it keeps receivingJSON.parse(JSON.stringify(payload)), so behaviour for unknown keys,Date,undefined, and non-finite numbers is unchanged. Each validator must stay synchronous and throwing — all fourteen LP validators already are, andprepareArtifactrefuses one that returns a thenable. Do not convert one to a predicate:(wire) => schema.safeParse(wire).successreturnsfalsefor an invalid body and never throws, so the body would publish. - The text grammars for
llms.txt,llms-full.txt, andindex.md. A contract slot is required, so those artifacts supply their existing presence and structure checks rather than obtaining a contract claim with no contract behind it. - The choice of slot.
validateis the pilot's starting point and its semantics are untouched.judgeis available for the grammars that also produce ADVISORY findings — the ones LP currently recovers by running the grammar a second time outside the codec. Returning them as{ok: true, warnings}puts them on the publication result, which is the read that retires. - Source selection, freshness meaning,
spec-contract,trigger, and the composition timestamp.
Shape at the LP call site
// src/artifacts.ts — pure. Imported by handlers AND read statically by `mantle generate dataflow`.
import { defineArtifact, jsonCodec, textCodec } from "@j0nathan-ll0yd/types";
import booksSchema from "../schemas/books-export.schema.json" with { type: "json" };
export const ARTIFACTS = {
books: defineArtifact({
key: "books.json",
contentType: "application/json",
cacheControl: "max-age=30, s-maxage=30",
codec: jsonCodec<BooksExport>((wire) => assertConforms(booksSchema, wire)),
}),
llmsTxt: defineArtifact({
key: "llms.txt",
contentType: "text/markdown; charset=utf-8",
cacheControl: "public, max-age=300, s-maxage=300",
codec: textCodec(assertLlmsTxtGrammar),
}),
} as const;Single write:
await publishArtifact({ bucket, artifact: ARTIFACTS.books, value: booksJson, provenance });Multi-write preflight — the ComposeLlmContent and ExportHealthData shape, preserved:
// Every value is encoded and judged BEFORE any write. One invalid payload publishes nothing.
const preparedBooks = prepareArtifact(ARTIFACTS.books, booksJson);
const preparedLlms = prepareArtifact(ARTIFACTS.llmsTxt, document);
const prepared = [preparedBooks, preparedLlms]; // for failure reporting only
const results = await Promise.allSettled([
publishPreparedArtifact({
bucket,
prepared: preparedBooks,
composedAt,
provenance,
metadata: artifactMetadata,
}),
publishPreparedArtifact({
bucket,
prepared: preparedLlms,
composedAt,
provenance,
metadata: artifactMetadata,
}),
]);
const failedKeys = results.flatMap((r, i) => (r.status === "rejected" ? [prepared[i].key] : []));composedAt is an explicit option, so one composition instant stamps every object a run writes. metadata carries spec-contract and trigger and still merges last.
Name each receipt and write it at its own call site. prepared.map((p) => publishPreparedArtifact({prepared: p})) reads well but binds the receipt to an anonymous callback parameter, which the manifest resolver cannot follow back to a declaration. That write is reported in unresolved rather than attributed to a key — visible, but not counted. The named form above costs two extra lines and preserves the only property that matters here: every value is judged before any write.
Guarantees, and their limits
Held:
- An invalid value, and an invalid serialized value, never reach
PutObject. content-sha256is the digest of the exact bytes written. The checked path owns it: supplying it inmetadatais rejected under any casing, because S3 user metadata becomes case-insensitive HTTP headers andContent-SHA256reaches the same header the digest occupies. Metadata keys that differ only in case, and non-string metadata values, are rejected for the same reason: each would let the stamp, the returned receipt, and the object disagree.composed-atis accepted in any casing and normalized to one entry, so a composer can share one instant across a run's objects and the receipt still reports what landed.- The bytes written decode back to exactly the text the validator accepted. An unpaired surrogate is rejected rather than silently published as U+FFFD.
- The receipt is frozen and its body is retained privately, so nothing between preparation and the write can redirect the key, restate the digest, or substitute bytes. A hand-built receipt is refused.
- Two callers — one JSON export, one Markdown document — share the operation with no branch inside Mantle.
- A validator that defers its work is refused, not trusted.
validateis typed(body: string) => void, and TypeScript'svoidreturn position acceptsPromise<void>, so anasyncvalidator type-checks; it would return a pending promise instead of throwing, leaving the body hashed, stamped, and written with its contract check still outstanding. The result is inspected, the thenable is settled so declining cannot crash the process, and nothing is published. - A codec supplying
judgereports the same defects at COMPILE time, becausePromise<ArtifactVerdict>, a generator, and a boolean are none of them assignable to anArtifactVerdict. The runtime shape check stays, for JavaScript callers and for the one residual the type cannot see: a detached promise followed by a fabricated{ok: true, warnings: []}, whichlocal-rules/artifact-validator-syncrefuses at authoring time. - Advisory findings from a
judgereach the caller on the receipt, frozen and copied. They are not stamped on the object — they describe the judgment, not the bytes.
Not claimed:
exportToS3,putObject, andcreateUploadare unchanged and keep their metadata precedence, including an overridablecontent-sha256. They promise storage, not a contract, and do not acquire this operation's guarantee by association.- A digest served beside its body establishes self-consistency, not authenticity.
Manifest projection
mantle generate dataflow reads the declaration with ts-morph. It never imports, loads, bundles, or executes the artifact module, and never serializes a validator.
publishArtifact({artifact: ARTIFACTS.books, ...})resolvesARTIFACTS.booksthrough the object literal and thedefineArtifactcall to the literalkey.publishPreparedArtifact({prepared: preparedBooks, ...})steps back throughprepareArtifact(ARTIFACTS.books, value)to the same declaration, so a named-receipt preflight costs no manifest coverage. A receipt reached through an anonymous callback parameter is not resolvable and is reported asunresolvedinstead — the resolver has a bounded hop budget, not a promise to evaluate higher-order code.artifacts.jsongains an optionalcontentTypeper target, projected from the definition. Absent means "this write declares none", never a default.export-sources.jsonstill traces each artifact's payload expression, so per-artifact attribution and multi-writer attribution are unchanged.- An unresolved or unrecognized write beside a declared artifact stays in
unresolved. A declaration does not suppress it.
Two supporting fixes were needed and are included: the resolver now follows a member access on a statically declared object, and its recursion key now includes the node kind — a shorthand property and its own name identifier shared a key, so {entries} in a write payload previously traced to no sources at all.
Verification
packages/aws/test/s3-checked-publication.test.ts— invalid value and invalid serialization never write; unknown keys,Date,undefined, and non-finite behaviour; UTF-8 bytes and digest; metadata precedence and digest ownership; receipt immutability and forgery; two callers with no app branch; multi-artifact preflight with named-key failure.packages/aws/test/s3-metadata-serialization.test.ts— the same rules measured through a realS3Clientwhose transport is replaced by a capturing handler, so the actual serializer runs. It pins the fact the guard exists for: two differently-cased metadata keys reach onex-amz-meta-content-sha256header. Covers exact-case, mixed-case, and upper-case digest keys, case collisions, the runtime-only absent value, andexportToS3keeping its original precedence.packages/types/test/artifact.test.ts— the declaration helpers alone: what the validator sees after serialization, the unserializable codec, and theneverdefault as it reaches the emitted declaration.packages/cli/test/dataflow/manifests.test.ts— key and media type from the definition, through both call shapes; per-artifact attribution; the unresolved neighbouring write; and the counterexample that a receipt bound to an anonymous callback parameter is reported asunresolvedrather than dropped.packages/cli/test/dataflow/artifact-module-bundle.test.ts— a pure artifact module with a JSON schema import bundles and loads in a separate process with no AWS credentials, region, or profile; no validator runs; the codec cannot be serialized into a manifest.
Not done here
Phases 3 to 6 remain: adopting all nine JSON exports, replacing the duplicated text writers, removing the Atlas dataflow overrides, relocating the daily integrity observation, the browser decode, and the release chain. Nothing in this phase deletes a consumer's machinery.