From 54645b57292f9e70481e33dade3b23f3a243177c Mon Sep 17 00:00:00 2001 From: AEGIS Date: Wed, 8 Jul 2026 06:22:18 -0500 Subject: [PATCH] fix(attestation-envelope): bridge to audit-chain's real writeRecord() shape (v0.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AttestationEnvelope's 0.5.0 CHANGELOG entry claimed it was the "canonical shape for... audit-chain entries," but audit-chain's actual AuditRecord (record_id, namespace, event_type, hash, prev_hash, actor, timestamp, payload, metadata?) shares no field vocabulary with the envelope (traceId, tenantId, ticketRef, model, costUsd, result, sealedAt, hmac) — the claim was aspirational and had zero real consumers. Add toAuditChainWriteOptions() to bridge the two as separate layers: the envelope becomes the record's payload, with namespace/actor defaults derived from tenantId/agentId rather than forcing a field-for-field merge. Surfaced while scoping a generalized rubric-contract primitive off of evidence-core, whose src/audit/adapter.ts independently reimplements this same bridge (correctly) because no shared version existed to depend on. --- CHANGELOG.md | 8 +++ package.json | 2 +- src/contracts/attestation-envelope.ts | 55 ++++++++++++++++ src/index.ts | 7 +- tests/attestation-envelope.test.ts | 94 +++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 tests/attestation-envelope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dbe5b04..8677a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.14.0 - 2026-07-08 + +### New Exports + +- **`toAuditChainWriteOptions(envelope, options?)`** (`src/contracts/attestation-envelope.ts`) — bridges `AttestationEnvelope` into `@stackbilt/audit-chain`'s `writeRecord()` options. `AttestationEnvelope` and audit-chain's `AuditRecord` are different layers, not the same shape: the 0.5.0 CHANGELOG entry describing `AttestationEnvelope` as the "canonical shape for... audit-chain entries" was aspirational and, as of this release, still had zero real consumers — none of the four named instances (stackd, audit-chain, reading-envelope, evals) had adopted it, and audit-chain's actual `AuditRecord` (`record_id, namespace, event_type, hash, prev_hash, actor, timestamp, payload, metadata?`) shares no field vocabulary with the envelope (`traceId, tenantId, ticketRef, model, costUsd, result, sealedAt, hmac`). This function makes the envelope the record's `payload` rather than forcing a field-for-field merge, and derives sensible `namespace`/`actor` defaults from `tenantId`/`agentId` for the chain-identity fields the envelope has none for. `AuditChainWriteOptions` and `ToAuditChainOptions` types exported alongside it. + +Surfaced while scoping a generalized rubric-contract primitive off of `@stackbilt/evidence-core`'s bespoke `src/audit/adapter.ts`, which independently reimplements this exact bridge (correctly, matching audit-chain's real shape) because no shared version existed to depend on. + ## 0.13.0 - 2026-07-04 ### New Exports diff --git a/package.json b/package.json index 7832c15..96dfcad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stackbilt/contracts", - "version": "0.13.0", + "version": "0.14.0", "files": [ "dist", "README.md", diff --git a/src/contracts/attestation-envelope.ts b/src/contracts/attestation-envelope.ts index 3f7bce7..82d732c 100644 --- a/src/contracts/attestation-envelope.ts +++ b/src/contracts/attestation-envelope.ts @@ -12,6 +12,12 @@ import { z } from 'zod'; * (trace_id + tenant_id binding to prevent cross-tenant inference), * evals GraderRun receipts. * + * This schema is the sealed-payload shape, not audit-chain's own + * `AuditRecord` shape — the two are different layers (see + * `toAuditChainWriteOptions()` below for the bridge). As of 2026-07, + * no consumer has adopted this yet; each of the four instances above + * still has its own bespoke sealing logic. + * * Goal: let audit-chain become the canonical sealing layer under stackd * receipts and dispatch traces (Stackproof product primitive). */ @@ -52,3 +58,52 @@ export function canonicalPayload(e: AttestationEnvelope): string { sealedAt: e.sealedAt, }); } + +/** + * `@stackbilt/audit-chain`'s `writeRecord()` options, minus the `bindings` + * and `chainHead` arguments a caller supplies separately. Duplicated here + * (not imported) to keep this package dependency-free — see + * `@stackbilt/audit-chain`'s `AuditRecord` / `writeRecord()` for the source + * of truth on this shape. + */ +export interface AuditChainWriteOptions { + namespace: string; + event_type: string; + actor: string; + payload: Record; + metadata?: Record; +} + +export interface ToAuditChainOptions { + /** audit-chain namespace for this envelope's chain. Defaults to `tenant:{tenantId}`. */ + namespace?: string; + /** audit-chain event_type. Defaults to `'attestation.sealed'`. */ + eventType?: string; + /** audit-chain actor. Defaults to `envelope.agentId`, falling back to `'system'`. */ + actor?: string; +} + +/** + * Bridge an `AttestationEnvelope` into `@stackbilt/audit-chain`'s + * `writeRecord()` options. + * + * `AttestationEnvelope` and audit-chain's `AuditRecord` are different + * layers, not the same shape: the envelope is the sealed unit of work + * (result + provenance — traceId, tenantId, model, cost, hmac), while + * `AuditRecord` is the generic hash-chained log entry that wraps *any* + * event. This function makes the envelope the record's `payload` and + * derives sensible defaults for the chain-identity fields (`namespace`, + * `actor`) that the envelope has no equivalent for — it does not attempt + * to unify the two field vocabularies into one flat schema. + */ +export function toAuditChainWriteOptions( + envelope: AttestationEnvelope, + options: ToAuditChainOptions = {}, +): AuditChainWriteOptions { + return { + namespace: options.namespace ?? `tenant:${envelope.tenantId}`, + event_type: options.eventType ?? 'attestation.sealed', + actor: options.actor ?? envelope.agentId ?? 'system', + payload: { ...envelope }, + }; +} diff --git a/src/index.ts b/src/index.ts index 6d230ee..f498620 100644 --- a/src/index.ts +++ b/src/index.ts @@ -129,9 +129,14 @@ export type { Posture, PostureSignal, PostureGateResult } from './contracts/post export { AttestationEnvelopeSchema, canonicalPayload, + toAuditChainWriteOptions, } from './contracts/attestation-envelope.js'; -export type { AttestationEnvelope } from './contracts/attestation-envelope.js'; +export type { + AttestationEnvelope, + AuditChainWriteOptions, + ToAuditChainOptions, +} from './contracts/attestation-envelope.js'; export { CcTaskStatusSchema, diff --git a/tests/attestation-envelope.test.ts b/tests/attestation-envelope.test.ts new file mode 100644 index 0000000..e33ecb8 --- /dev/null +++ b/tests/attestation-envelope.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { + AttestationEnvelopeSchema, + canonicalPayload, + toAuditChainWriteOptions, +} from '../src/index.js'; + +const envelope = { + envelopeId: '123e4567-e89b-12d3-a456-426614174000', + traceId: 'trace-abc', + tenantId: 'tenant-1', + ticketRef: 'github:Stackbilt-dev/aegis#42', + agentId: 'agent-7', + model: 'claude-sonnet-5', + provider: 'anthropic', + inputTokens: 100, + outputTokens: 50, + costUsd: 0.01, + result: { ok: true }, + sealedAt: '2026-07-08T00:00:00.000Z', +}; + +describe('AttestationEnvelope', () => { + it('validates a canonical envelope shape', () => { + const result = AttestationEnvelopeSchema.safeParse(envelope); + expect(result.success).toBe(true); + }); + + it('produces a deterministic canonical payload', () => { + const payload = canonicalPayload(envelope); + expect(JSON.parse(payload)).toEqual({ + envelopeId: envelope.envelopeId, + traceId: envelope.traceId, + tenantId: envelope.tenantId, + ticketRef: envelope.ticketRef, + agentId: envelope.agentId, + model: envelope.model, + provider: envelope.provider, + costUsd: envelope.costUsd, + sealedAt: envelope.sealedAt, + }); + }); + + describe('toAuditChainWriteOptions', () => { + it('derives audit-chain identity fields from the envelope by default', () => { + const opts = toAuditChainWriteOptions(envelope); + + expect(opts.namespace).toBe(`tenant:${envelope.tenantId}`); + expect(opts.event_type).toBe('attestation.sealed'); + expect(opts.actor).toBe(envelope.agentId); + }); + + it('falls back to a system actor when agentId is absent', () => { + const { agentId: _agentId, ...withoutAgent } = envelope; + const opts = toAuditChainWriteOptions(withoutAgent); + + expect(opts.actor).toBe('system'); + }); + + it('lets the caller override namespace, event_type, and actor', () => { + const opts = toAuditChainWriteOptions(envelope, { + namespace: 'content:draft-9', + eventType: 'evidence.validation.completed', + actor: 'system:evidence-engine', + }); + + expect(opts.namespace).toBe('content:draft-9'); + expect(opts.event_type).toBe('evidence.validation.completed'); + expect(opts.actor).toBe('system:evidence-engine'); + }); + + it('carries the full envelope as the record payload without mutation', () => { + const opts = toAuditChainWriteOptions(envelope); + + expect(opts.payload).toEqual(envelope); + expect(opts.payload).not.toBe(envelope); + }); + + it('produces a payload that round-trips back through the envelope schema', () => { + const opts = toAuditChainWriteOptions(envelope); + const reparsed = AttestationEnvelopeSchema.safeParse(opts.payload); + + expect(reparsed.success).toBe(true); + }); + + it('matches the shape of audit-chain writeRecord() options (minus bindings + chainHead)', () => { + const opts = toAuditChainWriteOptions(envelope); + + expect(Object.keys(opts).sort()).toEqual( + ['actor', 'event_type', 'namespace', 'payload'].sort(), + ); + }); + }); +});