Skip to content
pleach
Operate

Attestation

Ed25519-signed envelopes over audit-ledger slices — a pluggable key-store substrate that sits on top of the canonical row hash and turns it into portable, third-party-verifiable evidence.

Attestation extends the audit-ledger cluster: it sits on top of the hash chain. The chain proves a slice of the event log hasn't been mutated after the fact; attestation wraps a signed envelope around that slice so a third party — a regulator, an external auditor, a downstream replay — can verify it without needing read access to the writer's database.

The substrate ships as @pleach/core/attestation. It's a substrate subpath, not a SKU: signer, verifier, the key-store interface, two stub production adapters (AWS KMS + Vault Transit), and a real file-backed adapter on the attestation/testing subpath. Policy, rotation cadence, framework-specific envelope shapes — those land in @pleach/compliance or stay host-side.

Beta / @experimental. While @pleach/core ships stable, the attestation surface (and @pleach/compliance) ship beta for the first release. Concretely: envelopes are unsigned by default (signature: "" + compliance.signed: false) unless you wire a real key-store — the AWS KMS + Vault Transit production adapters are stubs, only the file-backed adapter signs today; the modelVersions / promptVersions / toolVersions maps are emitted empty (Phase-A thin payload); and ChainProofV1 has a pending type-unification across the attestation / eventLog subpaths. Treat "signed, third-party-verifiable" as the wired-keystore path, not the out-of-the-box default, until the 1.0 attestation cut.

Subpath@pleach/core/attestationSourcesrc/attestation/

What attestation adds

The chain alone is enough to detect tampering on rows you already control. It's not enough to prove anything to someone who doesn't. A regulator asking "show me what this model decided on 2026-04-12T14:00Z, signed" wants:

  • A fixed, canonical bytes representation of the slice.
  • A signature over those bytes by a key whose public half they can resolve out-of-band.
  • A chain proof showing how the attested HEAD sits inside the broader event log, so the slice can't be lifted from one tenant and replayed under another.

AttestationV1 is the envelope shape that carries all three. The chain provides the canonical hash of the covered slice (eventChainHash) and a ChainProofV1 locating the HEAD inside the tenant's chain; the signer signs the canonical encoding of that envelope; the verifier checks the signature and emits a structured VerifyResult. Chain replay itself stays delegated to @pleach/core/eventLog's verifyChain() — the two surfaces are independent on purpose.

The core surface

import {
  signAttestation,
  verifyAttestation,
  canonicalizeAttestationPayload,
  computePayloadHash,
  type AttestationV1,
  type AttestationPayloadV1,
  type AttestationFieldVersions,
  type AttestationSignatureAlgorithm,
  type ChainProofV1,
  type VerificationError,
  type VerifyResult,
  type AttestationKeyStore,
  type AttestationSignResult,
  AwsKmsAttestationKeyStore,
  VaultTransitAttestationKeyStore,
  NotImplementedError,
} from "@pleach/core/attestation";

The attestation/keyStores subpath re-exports the same key-store types and adapters for hosts that only need the key-store surface (e.g. a pure verifier service that resolves public keys but never signs).

import {
  AwsKmsAttestationKeyStore,
  VaultTransitAttestationKeyStore,
  type AttestationKeyStore,
} from "@pleach/core/attestation/keyStores";

The attestation/testing subpath ships the real file-backed adapter for unit tests and local development. Kept off the production barrel so it can't be reached by typing @pleach/core/attestation in app code.

import { FileBackedAttestationKeyStore } from "@pleach/core/attestation/testing";

Four surfaces, each with a single responsibility. A fifth — the RFC 3161 timestamp-authority client — is covered under issuedAt is a claim below:

SurfaceResponsibility
signAttestation / verifyAttestationThe sign/verify pair. Pure functions over an AttestationKeyStore.
canonicalizeAttestationPayload + computePayloadHashRFC 8785 JCS encoding + SHA-256. Exported so verifiers and signers share one canonicalizer.
AttestationKeyStore interface + production adaptersPluggable signing backend. AWS KMS + Vault Transit ship as Phase A scaffolds.
attestation/testing subpathReal FileBackedAttestationKeyStore for dev + unit tests.

The envelope shape

AttestationV1 is the wire-format. Every field is readonly; the signer freezes the returned object. The unsigned form AttestationPayloadV1 is Omit<AttestationV1, "signature" | "externalTimestamps"> — the exact shape that gets canonicalized and hashed. Both fields are excluded for the same reason: they are countersignatures over the payload, so neither can be inside what the payload's own signature covers.

interface AttestationV1 {
  readonly version: "1";
  readonly sessionId: string;          // RFC 4122 v4
  readonly tenantId: string;
  readonly attestationId: string;      // RFC 4122 v4, generated at sign time
  readonly issuedAt: string;           // ISO 8601 UTC — SELF-ASSERTED signer clock
  readonly modelVersions: { readonly [modelId: string]: string };
  readonly promptVersions: { readonly [promptId: string]: string };
  readonly toolVersions: { readonly [toolName: string]: string };
  readonly runtimeVersion: string;     // @pleach/core package version at attest time
  readonly eventChainHash: string;
  readonly firstEventId: string;       // event handle; don't assume UUID format
  readonly lastEventId: string;        // event handle; don't assume UUID format
  readonly eventCount: number;         // >= 1
  readonly signatureAlgorithm: AttestationSignatureAlgorithm; // "ed25519"
  readonly signaturePublicKeyId: string;
  readonly signature: string;          // base64(ed25519-sig(canonical(payload)))
  readonly redactionAllowlistHash: string;
  readonly chainProof: ChainProofV1;
  readonly firstChainedSequenceNumber: number;
  readonly externalTimestamps?: readonly Rfc3161TokenReceipt[];
}

issuedAt is a claim; externalTimestamps is a proof

issuedAt is read from the signing process's own clock. It tells you what the signer said the time was — which is worth exactly as much as your trust in the signer. An operator holding the signing key can backdate it and re-sign, and the signature still verifies.

externalTimestamps closes that gap. Pass a timestampAuthority client to attestChainState and it requests an RFC 3161 token over the chain proof's merkle root from an independent Time-Stamping Authority. The TSA countersigns (hash, genTime) with its own key, so the resulting receipt is evidence the attested content existed no later than the TSA's timestamp — a fact the key-holder cannot rewrite.

SurfaceResponsibility
createHttpTimestampAuthorityClientRFC 3161 client over HTTP. Vendor-neutral: you supply the TSA URL.
buildTimeStampReq / parseTimeStampRespThe DER codec, exported for callers that manage their own transport.
Rfc3161TokenReceiptThe stored receipt: base64 DER token, verified message imprint, hash algorithm, genTimeIso, plus the TSA's policyOid / serialNumberHex / accuracy / ordering / nonceHex and any embedded certificatesB64. accuracy is the one auditors want — it states how precise genTime actually is.

What the core client checks for you, and what it does not:

  1. The token is verified to cover your digest. TSTInfo.messageImprint is extracted from the response and compared against the digest that was sent, along with the hash-algorithm OID; a mismatch throws rather than returning a receipt. Without this a TSA could timestamp a different hash and you would store the token as though it covered yours.
  2. A nonce is sent by default and its echo is checked. A CSPRNG nonce (crypto.getRandomValues) rides every request and the response's TSTInfo.nonce must match, which binds the response to your request. Opt out with nonce: false for a TSA that rejects them.
  3. The signature itself is not verified in core. The token is carried opaque: @pleach/core performs no CMS/PKCS #7 verification, by design — that is Node-only and the substrate runs in the browser. Verify it either offline (openssl ts -verify -digest <imprint> -in token.der -token_in -CAfile <tsa-ca.pem>) or in-process with verifyRfc3161Receipt from @pleach/compliance.
  4. The token binds the chain root, not the attestation. Two attestations over the same chain state have interchangeable receipts. Recompute the imprint to check one: messageImprintHex === hex(sha256(hexDecode(eventChainHash))).

The clock behind issuedAt

Since issuedAt comes from the signing process's own clock, the quality of that clock decides how much honest error the field carries. A browser-side signer reading Date.now() inherits whatever the device clock says, which can sit minutes off and can jump backwards mid-session on an NTP resync.

createAnchoredClock is the Clock implementation core ships for that job:

import { createAnchoredClock } from "@pleach/core/runtime";

const clock = createAnchoredClock({
  anchorMs: mintInstantMs,   // the session's trusted instant
  serverNowMs,               // server time observed at construction
});

clock.now();          // skew-corrected epoch ms, monotonic
clock.sinceMintMs();  // offset from the mint, never negative

It corrects for device skew against the server instant, advances on performance.now() so ordinary clock nudges cannot perturb the timeline, latches monotonically so a backward jump can never un-order two events, and rebases when it detects the delta has undercounted through a tab suspension. It has no dependencies and degrades to the wall clock where performance is unavailable.

⛔ This does not turn issuedAt into a proof, and it is worth being precise about why. A more accurate clock reduces honest error — drift, skew, suspension. It does nothing about dishonest error, because an operator holding the signing key can still set whatever value they like before signing. The distinction the section above draws is untouched: only the TSA countersignature converts a claim into evidence. Use the anchored clock so your timestamps are right; use externalTimestamps so they are checkable.

Knowing which one you're producing

The gap between a claim and a proof is invisible at the call site. Wire a signer without a timestamp authority and everything succeeds — you get a signed envelope, it verifies, nothing warns — and you have been producing the weaker artifact the whole time.

So the quickstart takes the authority as an option, and both the agent and the runtime will tell you which kind you are producing:

import { createPleachAgent } from "@pleach/core";
import { createHttpTimestampAuthorityClient } from "@pleach/core/attestation";

const agent = await createPleachAgent({
  db: createSqliteDurableStore({ path: "./agent.db" }),
  signer: new FileBackedAttestationKeyStore({ privateKeyPath, keyId: "local" }),
  timestampAuthority: createHttpTimestampAuthorityClient({ url: TSA_URL }),
});

agent.evidence.timestamped;   // true — attestations carry an RFC 3161 receipt
agent.evidence.mintAnchorMs;  // the session's trusted mint instant, or null
agent.evidence.clockInjected; // whether timestamps come from an injected clock

The same shape is on the runtime as runtime.evidence. It is deliberately narrow:

  • It reports wiring, not reachability. An authority that is down or rejecting still reads timestamped: true — the failure surfaces when a timestamp is requested, which is not something a synchronous accessor can honestly promise.
  • There is no clockAnchored field. Any object with a now() satisfies the Clock seam, so core cannot verify that an injected clock is skew-corrected. A field implying otherwise would be a claim, and an introspection surface that overstates is worse than none — it is consulted precisely by people trying to find out whether they can rely on something.

Omitting timestampAuthority is a legitimate configuration — internal integrity checking, development, air-gapped deployments with no reachable authority. It is just worth choosing rather than defaulting into, which is what the accessor is for:

if (process.env.NODE_ENV === "production" && !agent.evidence.timestamped) {
  throw new Error("refusing to start: attestations would be claim-only");
}

Verifying the token (@pleach/compliance)

@pleach/compliance ships verifyRfc3161Receipt on its ./timestamp subpath. It performs real CMS verification — it locates the signer certificate, walks the chain to a pinned anchor verifying every link, checks each certificate was valid at genTime, checks the messageDigest signed attribute against a fresh digest of TSTInfo, and verifies the signature over SignedAttributes. The digest algorithm is resolved from the signature-algorithm OID via an explicit table rather than inferred, so the verifier never asserts on an algorithm nobody reviewed; SHA-1 and RSASSA-PSS return unsupported-algorithm.

It needs @sigstore/core — an optional peer dependency. Without it installed the call returns { verified: false, reason: "verifier-unavailable" } rather than throwing.

Two properties worth knowing before you cite a result:

  • Trust anchors are explicit and mandatory. You pass PEM or DER in; there is no env read, no OS trust store, no bundled vendor list. An empty anchor list is a hard verified: false — a token embedding its own self-signed root does not become trusted by carrying it.
  • Revocation is not checked. No CRL or OCSP fetch happens, and there is deliberately no revocation key in the returned checks object, so its absence cannot be misread as a pass. Likewise a genTime window reports "skipped" when you supply none — never "pass".

Attachment is fail-open by design: if the TSA is unreachable or returns a non-granted status, attestChainState logs and returns a valid, un-timestamped attestation rather than failing the attest. verifyAttestation's valid: true covers the ed25519 signature only and says nothing about any attached token.

A few of these earn explanation.

modelVersions / promptVersions / toolVersions. The attested run's full version surface. A regulator who later wants to replay the slice can pin every model, prompt contribution, and tool version to the exact bytes that were active at sign time. An empty toolVersions is legal — tool-free turns still attest.

eventChainHash + firstChainedSequenceNumber. Anchors the envelope to the underlying hash chain. When a chain reader resolves and yields rows, eventChainHash is the merkleRoot of the embedded chainProof — a real Merkle root over the covered slice — and eventCount is the real number of chained events. When no reader is available (or the chain yields zero rows), the envelope falls back to a documented empty-chain sentinel: an all-zero (32-byte) hash with a single-event short-circuit proof (proofPath: [], merkleRoot === leafHash === eventChainHash). The all-zero shape is greppable on purpose, so a consumer can tell a placeholder root from one computed against a real event log. firstChainedSequenceNumber is the first row inside the slice.

redactionAllowlistHash. The hash of the active scrubber allowlist at attest time. Lets a verifier confirm the slice was redacted under a specific policy without needing the policy itself.

chainProof: ChainProofV1. A real Merkle inclusion proof locating the attested leaf inside the tenant's broader chain. ChainProofV1 is one canonical shape, shared with @pleach/core/eventLog:

interface ChainProofV1 {
  readonly algorithm: "sha256";
  readonly version: "pleach.c9.v1";       // the canonicalization tag
  readonly tenantId: string;
  readonly chatId: string;
  readonly headSequenceNumber: number;
  readonly leafSequenceNumber: number;
  readonly leafHash: string;              // hex
  readonly merkleRoot: string;            // hex
  readonly proofPath: ReadonlyArray<{
    readonly hash: string;                // sibling hash, hex
    readonly side: "left" | "right";      // which side the sibling sits on
  }>;
}

proofPath carries sibling-with-side entries (not bare hex strings), enabling real Merkle verification; leafSequenceNumber

  • headSequenceNumber are the chain-native position handles (there is no leafIndex); and the C9 canonicalization tag is folded into version (pleach.c9.v1), not a separate field. An empty proofPath is legal for a single-event chain — the leaf is the root, so merkleRoot === leafHash.

The CMS envelope

AttestationV1.signature is Base64(ed25519(SHA-256(JCS(payload)))) — a bare signature with its algorithm, key id, and payload in sibling JSON fields. It is verifiable only by running Pleach's code. The people who have to verify a regulated record are a regulatory or security team, and the envelope their tooling already speaks is CMS: the US federal PKI is CMS-based (FIPS 201 / PIV), and the eIDAS long-term signature forms (CAdES) build on RFC 5652.

@pleach/core/attestation/cms emits that envelope — a detached CMS SignedData over the attestation's canonical bytes, signed with Ed25519 per RFC 8419.

import { buildAttestationDetachedCms } from "@pleach/core/attestation/cms"

const artifact = await buildAttestationDetachedCms(attestation, {
  keyStore,                    // the existing attestation signing seam
  certificateDer,              // DER of the signer's X.509 cert
  signingTime: new Date(),     // injectable, for deterministic tests
})

It is additive. signAttestation() and verifyAttestation() are untouched, and the CMS artifact is produced alongside the existing signature field, never instead of it.

Detached means the canonical bytes are hashed into the message-digest attribute and never copied into the envelope. Supply certificateDer whenever the artifact is meant to be independently checkable — without it the artifact is still conformant CMS, but no verifier can find a public key inside it, and openssl cms -verify has no flag to supply one out of band. With it, the SignerInfo is addressed by issuerAndSerialNumber, the certificate is embedded in SignedData.certificates, and the anti-substitution SigningCertificateV2 attribute (RFC 5035) is added.

The re-tag invariant, checked at emit time

RFC 5652 §5.4: when signedAttrs is present the signature does not cover the content. It covers the DER of the SignedAttributes — and those attributes are encoded inside SignerInfo as [0] IMPLICIT (tag 0xA0) but signed as a universal SET (tag 0x31). Getting that re-tag wrong produces a structure that verifies nowhere while remaining perfectly self-consistent. It is the most common CMS implementation bug.

So the artifact is read back: extractSignedAttributesFromArtifact pulls the [0] field out of the emitted DER and compares it, re-tagged, against the exact octets that were signed. A mismatch throws a named signed-attributes-mismatch at emit time rather than shipping a silently unverifiable artifact.

The module is graded by openssl cms -verify against a written file, not by round-tripping through the library that produced it. A structure that only verifies in its own implementation proves nothing.

The ASN.1 peers are optional

The encoders — @peculiar/asn1-schema, @peculiar/asn1-cms, @peculiar/asn1-x509 — are optional peer dependencies resolved by dynamic import, and listed at CMS_ASN1_PEER_PACKAGES. A consumer who never asks for CMS never installs them; one who does gets a named CmsAttestationError("asn1-peer-missing") naming the package to install, rather than a module-resolution stack trace.

Detect errors with isCmsAttestationError(err), never instanceof — the class is brand-keyed because a dual-bundle build can duplicate the declaration. The code field is one of asn1-peer-missing, malformed-certificate, invalid-signature-length, invalid-public-key, or signed-attributes-mismatch.

Canonicalization

canonicalizeAttestationPayload(payload) runs RFC 8785 JCS (JSON Canonicalization Scheme) over the unsigned payload: keys sorted lexicographically, no whitespace, UTF-8 bytes out. computePayloadHash(canonical) takes those bytes and returns SHA-256.

The two functions are exported separately for the same reason the hash chain exports canonicalizeRowForChain and computeRowHash separately: a verifier that wants to recompute the hash without running the full verifyAttestation path can call them directly, and a custom signing backend can sign the canonical hash without re-deriving canonicalization rules from a guess.

Mismatched canonicalization between signer and verifier surfaces at verify time as kind: "signature-mismatch". The signer and verifier import from one file specifically to make that drift impossible.

The AttestationKeyStore interface

interface AttestationSignResult {
  readonly signature: Uint8Array;   // ed25519 raw signature, 64 bytes
  readonly publicKeyId: string;     // opaque; matches AttestationV1.signaturePublicKeyId
}

interface AttestationKeyStore {
  sign(canonicalHash: Uint8Array): Promise<AttestationSignResult>;
  getPublicKey(publicKeyId: string): Promise<Uint8Array>;
}

Two paths through the interface:

  • Signer path. sign(canonicalHash) returns the raw 64-byte ed25519 signature plus the keyId the verifier needs to resolve the public key. Caller embeds both into the AttestationV1.
  • Verifier path. getPublicKey(publicKeyId) returns the ed25519 public key bytes (32 bytes), used by verifyAttestation() to re-check the signature.

The interface is intentionally minimal. No key-rotation surface, no permission model, no audit hooks — adapters layer those on top of their own infrastructure. AWS KMS has its own audit trail; Vault Transit has its own access control; the substrate stays out of their way.

Production adapters (Phase A scaffolds)

AwsKmsAttestationKeyStore and VaultTransitAttestationKeyStore ship today as constructor + interface-conformance scaffolds. Both adapters' sign() and getPublicKey() methods throw NotImplementedError; the real bodies land in v1.0 alongside the matching @pleach/compliance Phase B release. Phase A lets consumers type-check their wiring against the final shape.

import {
  AwsKmsAttestationKeyStore,
  VaultTransitAttestationKeyStore,
} from "@pleach/core/attestation";

const kms = new AwsKmsAttestationKeyStore({
  region: "us-east-1",
  keyArn: "arn:aws:kms:us-east-1:123456789012:key/abc-...",
});

const vault = new VaultTransitAttestationKeyStore({
  vaultAddr:    "https://vault.example.com:8200",
  transitMount: "transit",
  keyName:      "audit-attestation",
});

Both adapters are pinned to ed25519 at v1. KMS' EDDSA signing algorithm is the only accepted shape; Vault Transit's key type must be ed25519. ECC_NIST_P256 and other curves are rejected at the type level — AttestationSignatureAlgorithm is the literal "ed25519". publicKeyId is vault://<mount>/<keyName> for Vault and the full KMS ARN for AWS, disambiguating mixed-store deployments.

Signing is host/operator-provisioned

Attestation is an early-stage capability: the sign/verify functions and the envelope shape are stable, but the signing key store is host- or operator-provisioned, and the cloud-KMS path is not yet available. The default and file-backed signers work for local and dev use today.

The file-backed adapter (dev + tests)

FileBackedAttestationKeyStore is the real implementation the production adapters' scaffolds reference. It lives on the attestation/testing subpath so app code can't reach it through the production barrel.

import { FileBackedAttestationKeyStore } from "@pleach/core/attestation/testing";

const keyStore = new FileBackedAttestationKeyStore({
  privateKeyPath: "/run/secrets/attestation-key.bin",
  keyId:          "test-key-2026-06",
});

Key file format is 32 raw bytes — the ed25519 seed. No PEM, no PKCS#8: that's a heavy parser surface for a dev/test adapter. The private key is cached for the lifetime of the instance; callers that need rotation should construct a fresh store. sign() uses @noble/curves/ed25519 directly; getPublicKey() derives the public half from the cached seed.

Worked example

A host plugin that signs each completed AuditableCall and a verifier service that checks an attestation envelope over the wire.

// Signer side — runs in the host plugin's recordCall path.
import {
  signAttestation,
  type AttestationPayloadV1,
  type AttestationKeyStore,
} from "@pleach/core/attestation";

async function attestCall(
  call: AuditableCall,
  keyStore: AttestationKeyStore,
  publicKeyId: string,
): Promise<AttestationV1> {
  const payload: AttestationPayloadV1 = {
    version: "1",
    sessionId:      call.sessionId,
    tenantId:       call.tenantId,
    attestationId:  crypto.randomUUID(),
    issuedAt:       new Date().toISOString(),
    modelVersions:  call.fingerprint.modelVersions,
    promptVersions: call.fingerprint.promptVersions,
    toolVersions:   call.fingerprint.toolVersions,
    runtimeVersion: PLEACH_CORE_VERSION,
    eventChainHash: call.chainHash,
    firstEventId:   call.firstEventId,
    lastEventId:    call.lastEventId,
    eventCount:     call.eventCount,
    signatureAlgorithm:   "ed25519",
    signaturePublicKeyId: publicKeyId,
    redactionAllowlistHash: call.redactionAllowlistHash,
    chainProof:             call.chainProof,
    firstChainedSequenceNumber: call.firstSeq,
  };
  return signAttestation(payload, keyStore);
}

The verifier resolves the public key out-of-band — typically through whatever resolver the keyId scheme implies (KMS GetPublicKey, Vault Transit read key, or a local cache for the file-backed adapter) — and runs verifyAttestation.

// Verifier side — runs in a service that receives signed envelopes.
import {
  verifyAttestation,
  type AttestationV1,
  type VerifyResult,
} from "@pleach/core/attestation";

async function checkEnvelope(
  attestation: AttestationV1,
  resolvePublicKey: (keyId: string) => Promise<Uint8Array>,
): Promise<VerifyResult> {
  const publicKey = await resolvePublicKey(attestation.signaturePublicKeyId);
  return verifyAttestation(attestation, publicKey);
}

verifyAttestation never throws on signature mismatch. The returned VerifyResult is either { valid: true } or { valid: false, errors: VerificationError[] }, with each error tagged by kind:

kindMeaning
signature-mismatchThe signature didn't verify against the resolved public key + canonical payload.
schema-invalidversion, signatureAlgorithm, or signature field failed the structural check.
key-id-unknownpublicKey arg wasn't a 32-byte Uint8Array.
unsupported-algorithmThe attestation is well-formed but names an algorithm this build doesn't implement. Still valid: false — fail-closed; only the reason is honest.

Schema errors short-circuit before the signature check, so a malformed envelope surfaces schema-invalid rather than a downstream cryptographic failure.

unsupported-algorithm is split from schema-invalid because the distinction is evidentiary, not cosmetic. schema-invalid says the record is malformed — to an auditor that reads as corruption or tampering. An older verifier meeting a newer record is a statement about the verifier, and over a long retention window with a mixed-algorithm corpus it is a normal, expected event. One state needs the record re-examined; the other needs the verifier upgraded — two fixes, so two labels.

Relationship to the audit-ledger cluster

ConceptWhere it livesWhat it does
AuditableCall row@pleach/core/auditTyped per-call record. The unit of evidence.
Scrubbers@pleach/core/scrubbersRedact payload fields before the row persists.
Hash chain@pleach/core/eventLogprev_hash + row_hash columns; tamper-detection across rows.
Attestation@pleach/core/attestationSigned envelope over a chain slice; portable to third parties.
ProviderDecisionLedger@pleach/core (write path)The persistence surface every row flows through.

Scrubbers shape what gets written; the chain proves it wasn't mutated; attestation signs the proof. Each layer assumes the layer below holds.

What's not in scope today

@pleach/core/attestation is a substrate subpath, not a SKU. It intentionally does not ship:

  • A CLI. No pleach attest / pleach verify-attestation commands. Hosts wire the sign + verify functions into their own binaries.
  • Managed key rotation. No rotation scheduler, no key-lifecycle policy. The interface accepts a keyId; the adapter decides what rotation means.
  • Framework-specific envelope shapes. No FedRAMP-shape, HIPAA-shape, or SOC2-shape variants of AttestationV1. The envelope is one shape; framework-specific compliance bundles carry their own metadata alongside.
  • Compliance-mode policy enforcement. Nothing in the substrate refuses to start because attestation isn't configured. That's a host-side or @pleach/compliance decision.

The production KMS + Vault adapters' bodies land in v1.0 alongside the matching @pleach/compliance Phase B release. Until then, Phase A consumers wire the scaffolds for type-checking and run their dev paths through the file-backed adapter on @pleach/core/attestation/testing.

Where to go next