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/coreships 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; themodelVersions/promptVersions/toolVersionsmaps are emitted empty (Phase-A thin payload); andChainProofV1has a pending type-unification across theattestation/eventLogsubpaths. Treat "signed, third-party-verifiable" as the wired-keystore path, not the out-of-the-box default, until the 1.0 attestation cut.
@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:
| Surface | Responsibility |
|---|---|
signAttestation / verifyAttestation | The sign/verify pair. Pure functions over an AttestationKeyStore. |
canonicalizeAttestationPayload + computePayloadHash | RFC 8785 JCS encoding + SHA-256. Exported so verifiers and signers share one canonicalizer. |
AttestationKeyStore interface + production adapters | Pluggable signing backend. AWS KMS + Vault Transit ship as Phase A scaffolds. |
attestation/testing subpath | Real 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.
| Surface | Responsibility |
|---|---|
createHttpTimestampAuthorityClient | RFC 3161 client over HTTP. Vendor-neutral: you supply the TSA URL. |
buildTimeStampReq / parseTimeStampResp | The DER codec, exported for callers that manage their own transport. |
Rfc3161TokenReceipt | The 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:
- The token is verified to cover your digest.
TSTInfo.messageImprintis 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. - A nonce is sent by default and its echo is checked. A CSPRNG nonce
(
crypto.getRandomValues) rides every request and the response'sTSTInfo.noncemust match, which binds the response to your request. Opt out withnonce: falsefor a TSA that rejects them. - The signature itself is not verified in core. The token is carried
opaque:
@pleach/coreperforms 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 withverifyRfc3161Receiptfrom@pleach/compliance. - 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 negativeIt 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 clockThe 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
clockAnchoredfield. Any object with anow()satisfies theClockseam, 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
revocationkey in the returnedchecksobject, 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
headSequenceNumberare the chain-native position handles (there is noleafIndex); and the C9 canonicalization tag is folded intoversion(pleach.c9.v1), not a separate field. An emptyproofPathis legal for a single-event chain — the leaf is the root, somerkleRoot === 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 theAttestationV1. - Verifier path.
getPublicKey(publicKeyId)returns the ed25519 public key bytes (32 bytes), used byverifyAttestation()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:
kind | Meaning |
|---|---|
signature-mismatch | The signature didn't verify against the resolved public key + canonical payload. |
schema-invalid | version, signatureAlgorithm, or signature field failed the structural check. |
key-id-unknown | publicKey arg wasn't a 32-byte Uint8Array. |
unsupported-algorithm | The 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
| Concept | Where it lives | What it does |
|---|---|---|
| AuditableCall row | @pleach/core/audit | Typed per-call record. The unit of evidence. |
| Scrubbers | @pleach/core/scrubbers | Redact payload fields before the row persists. |
| Hash chain | @pleach/core/eventLog | prev_hash + row_hash columns; tamper-detection across rows. |
| Attestation | @pleach/core/attestation | Signed 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-attestationcommands. 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/compliancedecision.
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
Hash chain
The tamper-evidence layer attestation signs over.
AuditableCall row
The per-call evidence unit.
Scrubbers
Payload redaction at write time; attestation carries the allowlist hash.
Audit ledger
The ProviderDecisionLedger write path and the three compliance plug-points.
Compliance
The SKU that consumes attestation alongside framework-specific policy.
Tamper-evident hash chain
prev_hash + row_hash columns on the audit-ledger event log — what the chain proves, how rows get verified, and how the default-on writer-side stamping ships in the @pleach/core schema bundle.
Migrate to Pleach
Migration guides for the four most common starting points — AI SDK, LangChain, Anthropic Enterprise, OpenAI Enterprise.