@action-state-group/capsule-emit
v0.3.0
Published
TypeScript producer and verifier for AAC format 4
Readme
capsule-emit-ts
TypeScript-native AAC format-4 producer and verifier. The package is ESM-first,
uses strict TypeScript, preserves signer-independent Capsule IDs, and emits the
same attached-payload COSE Producer Envelopes as capsule-emit-go.
The root entry point builds records only. It does not execute actions, generate
business IDs or timestamps, persist Capsules, retry effects, contact witnesses,
or authorize signers. Optional verified persistence is available behind the
./artifact subpath.
Install
Node.js 22 or newer is required.
npm install @action-state-group/capsule-emitBuild, sign, and verify
seal is the recommended application-facing API. It digests caller-owned JSON,
builds and verifies the format-4 Capsule, then signs its raw 32-byte Capsule ID
with an independent Producer Envelope.
import { randomBytes } from "node:crypto";
import {
createEd25519Identity,
seal,
verifyCapsule,
verifyEnvelope,
} from "@action-state-group/capsule-emit";
const identity = createEd25519Identity(randomBytes(32));
const result = seal({
capsule: {
actionId: "example/1",
actionType: "decide",
operator: "example-org",
developer: "example-agent@v1",
timestamp: new Date("2026-09-02T12:00:00Z"),
disposition: {
decision: "accept",
approver: "policy",
humanDisposed: false,
verdictClass: "executed",
},
},
payload: { task: "publish", issue: 123 },
agentOutput: { accepted: true },
model: { provider: "example", modelId: "model-v1" },
runtime: "example-runtime@1",
identity,
});
const capsule = verifyCapsule(result.payload);
if (capsule.capsuleId !== result.capsuleId) {
throw new Error("Capsule ID mismatch");
}
const envelope = verifyEnvelope(result.capsuleId, result.envelope);
if (!envelope.ok) {
throw new Error(
`Producer Envelope failed: ${JSON.stringify(envelope.findings)}`,
);
}
if (
!envelope.publicKey ||
!Buffer.from(envelope.publicKey).equals(Buffer.from(identity.publicKey))
) {
throw new Error("Producer Envelope signer is not authorized");
}verifyCapsule validates Capsule identity and Class 1 structure. It does not
authenticate local-only signature or key_id fields. verifyEnvelope
authenticates the public key carried by the Producer Envelope. Whether that key
is authorized for an operator, developer, or action remains caller policy.
Typed construction
Use build when the application already owns all typed Capsule fields and wants
construction separate from signing. received binds exact opaque bytes under a
caller-declared CPB type. carry is the same operation with the generic
foreign-artifact type. who, can, did, and audit reference already-built
Capsules in a typed composition without minting or persisting those members.
import { randomBytes } from "node:crypto";
import {
build,
buildComposition,
can,
createEd25519Identity,
did,
received,
seal,
sign,
who,
} from "@action-state-group/capsule-emit";
const identity = createEd25519Identity(randomBytes(32));
const common = {
actionType: "fyi" as const,
operator: "example-org",
developer: "example-agent@v1",
timestamp: "2026-09-02T12:00:00Z",
};
const identityCapsule = build({
...common,
actionId: "identity/1",
domain: "identity",
});
const providerAck = received(
{ ...common, actionId: "provider-ack/1" },
new TextEncoder().encode("opaque provider acknowledgement"),
"provider-ack",
);
const actionCapsule = build({
...common,
actionId: "action/1",
effect: {
type: "example.publish",
status: "planned",
irreversibilityClass: "two_way",
},
});
const composition = buildComposition({ ...common, actionId: "composition/1" }, [
who(identityCapsule),
can(providerAck),
did(actionCapsule),
]);
const envelope = sign(composition, identity);
console.log(providerAck.capsuleId, composition.capsuleId, envelope.length);The same composition can use the high-level signing path:
const signedComposition = seal({
capsule: { ...common, actionId: "composition/2" },
members: [who(identityCapsule), can(providerAck), did(actionCapsule)],
identity,
});Composition members must occupy distinct slots and refer to distinct verified format-4 Capsules. Carried and composed construction rejects explicit agent input/output digests because those records already own their construction commitments.
Compose with CLL
This package constructs and verifies AAC records. It does not persist them. Applications that also need checkpointed inclusion install the independent CLL package, store the full Capsule and Producer Envelope in application storage, and append only the verified 32-byte Capsule ID to CLL.
import { build, verifyCapsule } from "@action-state-group/capsule-emit";
import { MysqlStore } from "@action-state-group/cll/mysql";
const built = build({
actionId: "deploy-42",
actionType: "fyi",
operator: "example-org",
developer: "example-agent@v1",
timestamp: new Date(),
});
verifyCapsule(built.json); // returns verified metadata or throws
// Persist built.json and any Producer Envelope in application storage.
const mysqlUrl = process.env.MYSQL_URL;
if (!mysqlUrl) throw new Error("MYSQL_URL is required");
const cll = await MysqlStore.open(mysqlUrl, "application-log");
try {
await cll.append({
value: Buffer.from(built.capsuleId, "hex"),
appendedAt: new Date(),
});
} finally {
await cll.close();
}Neither package depends on the other. An application that uses both declares both dependencies explicitly.
capsule-emit creates no database tables. MysqlStore.open() and
SqliteStore.open() create only CLL's internal tables, documented in the
@action-state-group/cll backend guide.
Full Capsules and Producer Envelopes remain in application-owned storage.
JSON digests
digestJSON(value) returns the lowercase SHA-256 of RFC 8785 JCS bytes. It
rejects duplicate object names, excessive depth, floats, unsafe integers,
invalid UTF-8, and trailing JSON data on strict decoding paths.
import { digestJSON } from "@action-state-group/capsule-emit";
const requestDigest = digestJSON({ issue: 123, operation: "publish" });
const responseDigest = digestJSON({ accepted: true });Callers own the JSON shape and assign these values to effect request/response fields where appropriate. Raw payload values never enter the Capsule.
Verification and compatibility
The @action-state-group/capsule-emit/aac subpath exposes strict JSON decoding, current and
vintage Capsule-ID computation, Class 1 verification, and store verification
for persistence adapters. Top-level construction and verification remain
format-4-only.
isV4IrreversibilityClass(value) tests membership in the four
irreversibility-class values seeded by AAC draft-04. It deliberately returns
false for future registry extensions without claiming that an extension is
invalid.
Tests replay the complete upstream AAC corpus, all Producer Envelope vectors, and Go/Python authored, received, WHO, DID, and composition fixtures.
Cross-record references
Input.references accepts { type, digestAlg, digest, citationPurpose?,
logCoordinates? }. Use the registered type agent-action-capsule with
SHA-256 and its Capsule ID for an AAC citation. acted_on and responds_to
are seeded citation purposes; unknown purposes remain informational. References
cannot duplicate the same Capsule's chain parent. Foreign digest representations
belong to the referenced CPB type and are not restricted to AAC's hex encoding.
logCoordinates carries the wire members log_id, leaf_index and
inclusion_proof together as opaque claims. Class 1 does not authenticate the
proof or resolve external targets. Undefined references are omitted; [] is
preserved, including its effect on the format-4 Capsule ID.
import { build, verifyCapsule } from "@action-state-group/capsule-emit";
const common = {
actionType: "fyi" as const,
operator: "example-org",
developer: "example-agent@v1",
timestamp: "2026-09-02T12:00:00Z",
};
const request = build({ ...common, actionId: "request/1" });
const response = build({
...common,
actionId: "response/1",
references: [
{
type: "agent-action-capsule",
digestAlg: "SHA-256",
digest: request.capsuleId,
citationPurpose: "responds_to",
},
],
});
verifyCapsule(response.json);
console.log(request.capsuleId, response.capsuleId);References enter through Input, including seal({ capsule: { ... } }).
There is no separate reference builder or closed purpose enum.
Artifact storage
The optional ./artifact subpath persists exact sealed Capsules, Producer
Envelopes, and business originals. The root entry point stays storage-free:
better-sqlite3 and mysql2 are optional peer dependencies, and an application
installs only the backend it imports. TypeScript users of the SQLite backend
also install @types/better-sqlite3 (an optional peer dependency), because
better-sqlite3 ships no bundled type declarations; mysql2 bundles its own. Reads verify Capsule identity, the
Producer Envelope against caller-owned trusted keys, the storage inventory, and
every retained bound original before returning. Records are immutable,
byte-identical retries are idempotent, and a divergent write for the same
Capsule ID throws an ArtifactError with code: "conflict".
import Database from "better-sqlite3";
import { PAYLOAD_DIGEST } from "@action-state-group/capsule-emit/artifact";
import { SqliteArtifactStore } from "@action-state-group/capsule-emit/artifact/sqlite";
const db = new Database("artifacts.db");
db.pragma("foreign_keys = ON");
const store = new SqliteArtifactStore(db, "my-namespace", [trustedPublicKey]);
await store.init(); // provision v1 tables once during deployment
await store.put({
capsuleId: sealed.capsuleId,
capsule: sealed.payload,
producerEnvelope: sealed.envelope,
artifacts: [
{
name: "payload",
binding: PAYLOAD_DIGEST,
content: payloadBytes,
state: "present",
},
],
});
const record = await store.get(sealed.capsuleId);./artifact/mysql exposes the same API over a mysql2 pool. The inventory
checksum is byte-compatible with capsule-emit-go, so a Go writer and a
TypeScript reader interoperate over a shared database. See
DESIGN.md for the full contract.
Development
The embedded provisional registry snapshot mirrors
agent-action-capsule/python/agent_action_capsule/data/cpb_provisional.json.
Known provisional values change informational diagnostics without increasing
assurance. Refresh the snapshot from that source; the test suite checks its
provenance and content. Raw JCS still normalizes -0 to 0; Python's optional
strict input verification tier is a separate acceptance policy.
Shared reference and vocabulary vectors live in the AAC source checkout under
go/verify/testdata/. The producer-to-CLL check is maintained in
capsule-emit-go/scripts/check-producer-cll-interop.sh and runs in both emitters'
CI. It covers in-memory append/checkpoint interoperability without witness I/O.
npm install
npm run check
npm run buildInterop tests expect agent-action-capsule and capsule-emit-go as sibling
checkouts. Override those paths with AAC_ROOT and CAPSULE_EMIT_GO_ROOT.
Release
Releases are published from main with the manual
Publish npm package
GitHub Action:
- Update
versioninpackage.jsonandpackage-lock.json, commit the change, and wait formainCI to pass. - In GitHub, open the workflow, choose Run workflow, and select
main. - Verify the workflow published
@action-state-group/capsule-emitand created thev<version>GitHub release and tag on the published commit.
The npm package must have a GitHub Actions trusted publisher configured for
the action-state-group/capsule-emit-ts repository and
.github/workflows/publish.yml. No long-lived npm token is required. Re-running
the workflow is safe: it skips an existing npm version and verifies that its
Git tag points to the gitHead recorded by npm.
If npm contains the version but its tag is missing after this workflow has
changed, GitHub may reject recovery with the workflow's GITHUB_TOKEN. A
maintainer with workflow scope must create the tag at the npm gitHead, then
rerun the workflow to verify the tag and create any missing GitHub release:
version=0.1.2
git_head=$(npm view "@action-state-group/capsule-emit@$version" gitHead)
git fetch origin --tags
git tag -a "v$version" "$git_head" -m "Release v$version"
git push origin "refs/tags/v$version"License
Apache-2.0. The upstream Agent Action Capsule dependency is BSD-3-Clause.
