Skip to content

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.

NamePackageRole
ArtifactDefinition<T>@j0nathan-ll0yd/typesKey, media type, cache policy, and codec, as one value
ArtifactCodec<T>@j0nathan-ll0yd/typesApp-owned encode plus exactly one of validate or judge
ArtifactVerdict@j0nathan-ll0yd/typesWhat judge returns: ok: true + warnings, or ok: false + issues
ArtifactFinding@j0nathan-ll0yd/typesOne {id, message} a contract has to say about a body
defineArtifact@j0nathan-ll0yd/typesIdentity function; narrows the key to a literal, infers the value
jsonCodec(validate)@j0nathan-ll0yd/typesJSON.stringify; validator sees JSON.parse(JSON.stringify(v))
textCodec(validate)@j0nathan-ll0yd/typesIdentity encoding; validator sees the exact text
publishArtifact@j0nathan-ll0yd/awsEncode, validate, hash, stamp, write — the one checked operation
prepareArtifact@j0nathan-ll0yd/awsThe checked half alone; writes nothing, returns a frozen receipt
publishPreparedArtifact@j0nathan-ll0yd/awsWrites a receipt's retained bytes
PreparedArtifact@j0nathan-ll0yd/awsFrozen receipt: key, media type, cache policy, length, digest
PublishedArtifact@j0nathan-ll0yd/awsWhat 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 a jsonCodec validator; it keeps receiving JSON.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, and prepareArtifact refuses one that returns a thenable. Do not convert one to a predicate: (wire) => schema.safeParse(wire).success returns false for an invalid body and never throws, so the body would publish.
  • The text grammars for llms.txt, llms-full.txt, and index.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. validate is the pilot's starting point and its semantics are untouched. judge is 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 ​

typescript
// 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:

typescript
await publishArtifact({ bucket, artifact: ARTIFACTS.books, value: booksJson, provenance });

Multi-write preflight — the ComposeLlmContent and ExportHealthData shape, preserved:

typescript
// 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-sha256 is the digest of the exact bytes written. The checked path owns it: supplying it in metadata is rejected under any casing, because S3 user metadata becomes case-insensitive HTTP headers and Content-SHA256 reaches 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-at is 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. validate is typed (body: string) => void, and TypeScript's void return position accepts Promise<void>, so an async validator 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 judge reports the same defects at COMPILE time, because Promise<ArtifactVerdict>, a generator, and a boolean are none of them assignable to an ArtifactVerdict. 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: []}, which local-rules/artifact-validator-sync refuses at authoring time.
  • Advisory findings from a judge reach 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, and createUpload are unchanged and keep their metadata precedence, including an overridable content-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, ...}) resolves ARTIFACTS.books through the object literal and the defineArtifact call to the literal key.
  • publishPreparedArtifact({prepared: preparedBooks, ...}) steps back through prepareArtifact(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 as unresolved instead — the resolver has a bounded hop budget, not a promise to evaluate higher-order code.
  • artifacts.json gains an optional contentType per target, projected from the definition. Absent means "this write declares none", never a default.
  • export-sources.json still 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 real S3Client whose 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 one x-amz-meta-content-sha256 header. Covers exact-case, mixed-case, and upper-case digest keys, case collisions, the runtime-only absent value, and exportToS3 keeping its original precedence.
  • packages/types/test/artifact.test.ts — the declaration helpers alone: what the validator sees after serialization, the unserializable codec, and the never default 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 as unresolved rather 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.