@atrib/attest 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/revise.js ADDED
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The legacy `atrib-revise` tool: cognitive primitive #3 of D079, kept as a
3
+ // permanent alias over the attest write funnel. Narrows the schema to the
4
+ // revision event_type (spec §1.2.9 / D059) with the revision-specific
5
+ // required fields (revises + prior_position + new_position + reason). The
6
+ // signing + chain composition path is byte-identical to `attest` with
7
+ // ref.kind='revises'; a verifier MUST NOT distinguish revision records by
8
+ // the tool name that signed them.
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { z } from 'zod';
11
+ import { createSubmissionQueue, EVENT_TYPE_REVISION_URI } from '@atrib/mcp';
12
+ import { handleEmit, registerAttestTool, resolveEmitLocalSubstrateShadowFromEnv, } from './index.js';
13
+ import { resolveKey } from './keys.js';
14
+ const SHA256_REF_PATTERN = /^sha256:[0-9a-f]{64}$/;
15
+ const HEX_32_PATTERN = /^[0-9a-f]{32}$/;
16
+ export const ReviseInput = z.object({
17
+ revises: z.string().regex(SHA256_REF_PATTERN).describe("'sha256:<64-hex>' record_hash this revision supersedes per spec §1.2.9 / D059. " +
18
+ 'REQUIRED. The target record can be any prior record (yours or another agent\'s).'),
19
+ prior_position: z.string().min(1).max(4096).describe('One-line summary of the position being superseded. Captures what was previously held ' +
20
+ 'so a reader sees the contradiction surfaced as a first-class graph node rather than ' +
21
+ 'a silent edit.'),
22
+ new_position: z.string().min(1).max(4096).describe('One-line summary of the new position replacing the prior. The substantive claim that ' +
23
+ 'supersedes the revised record.'),
24
+ reason: z.string().min(1).max(4096).describe('Why the revision happened. New evidence, contradicting record, model update, ' +
25
+ 'corrected reasoning, etc. Recall pipelines surface this verbatim so future-self ' +
26
+ 'sees what motivated the change.'),
27
+ topics: z.array(z.string().min(1).max(128)).max(16).optional().describe('Optional topic tags. Used by recall\'s topics filter. ' +
28
+ 'Lowercase-hyphenated convention (e.g. "contradiction", "model-update").'),
29
+ context_id: z.string().regex(HEX_32_PATTERN).optional().describe('32-hex context_id. Defaults to process.env.ATRIB_CONTEXT_ID per D078 when omitted; ' +
30
+ 'falls back to a fresh genesis context_id if neither is set.'),
31
+ informed_by: z.array(z.string().regex(SHA256_REF_PATTERN)).optional().describe("Array of 'sha256:<64-hex>' record_hashes that informed this revision. " +
32
+ 'Sorted lexicographically before signing per §1.2.5. The `revises` reference ' +
33
+ 'is separate from `informed_by` and need not be duplicated here.'),
34
+ });
35
+ /**
36
+ * Register the legacy `atrib-revise` tool. The handler call shape is
37
+ * unchanged from the standalone @atrib/revise server, so historical
38
+ * behavior is preserved exactly; only the mounting surface moved.
39
+ */
40
+ export function registerReviseTool(mcp, deps) {
41
+ const localSubstrate = deps.options.localSubstrate === false
42
+ ? undefined
43
+ : (deps.options.localSubstrate ??
44
+ resolveEmitLocalSubstrateShadowFromEnv({
45
+ producer: 'atrib-revise',
46
+ transport: 'stdio-mcp-server',
47
+ }));
48
+ mcp.registerTool('atrib-revise', {
49
+ description: 'Supersede a prior position with a stated reason. Produces a signed ' +
50
+ 'revision event (spec §1.2.9 / D059) that adds a REVISES graph edge to ' +
51
+ 'the target record. Use when you now hold a position incompatible with a ' +
52
+ 'prior claim; the revision surfaces the change as a first-class graph node ' +
53
+ 'rather than a silent edit (records are immutable). Legacy alias: new ' +
54
+ 'callers should prefer `attest` with ref.kind="revises"; records are ' +
55
+ 'byte-identical either way.',
56
+ inputSchema: ReviseInput.shape,
57
+ }, async (rawInput) => {
58
+ const input = ReviseInput.parse(rawInput);
59
+ const result = await handleEmit({
60
+ input: {
61
+ event_type: EVENT_TYPE_REVISION_URI,
62
+ content: {
63
+ revises: input.revises,
64
+ prior_position: input.prior_position,
65
+ new_position: input.new_position,
66
+ reason: input.reason,
67
+ ...(input.topics ? { topics: input.topics } : {}),
68
+ },
69
+ revises: input.revises,
70
+ ...(input.context_id ? { context_id: input.context_id } : {}),
71
+ ...(input.informed_by ? { informed_by: input.informed_by } : {}),
72
+ },
73
+ key: await deps.resolveServerKey(),
74
+ queue: deps.queue,
75
+ producer: 'atrib-revise',
76
+ ...(localSubstrate ? { localSubstrate } : {}),
77
+ });
78
+ if (!result.signed) {
79
+ return {
80
+ isError: true,
81
+ content: [{ type: 'text', text: result.refusals.join('\n') }],
82
+ };
83
+ }
84
+ const out = {
85
+ signed: true,
86
+ record_hash: result.record_hash,
87
+ log_index: result.log_index,
88
+ inclusion_proof: result.inclusion_proof,
89
+ context_id: result.context_id,
90
+ warnings: result.warnings,
91
+ };
92
+ return {
93
+ content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
94
+ };
95
+ });
96
+ }
97
+ /**
98
+ * Wire up the legacy atrib-revise MCP server. Mounts `atrib-revise` plus
99
+ * `attest` (alias-window rule W1). The underlying signing pipeline is
100
+ * shared via handleEmit so revision records are byte-identical regardless
101
+ * of which tool produced them.
102
+ */
103
+ export async function createAtribReviseServer(options = {}) {
104
+ const deps = buildReviseWriteToolDeps(options);
105
+ const mcp = new McpServer({ name: 'atrib-revise', version: '0.1.0' });
106
+ registerReviseTool(mcp, deps);
107
+ registerAttestTool(mcp, deps);
108
+ return {
109
+ mcp,
110
+ flush: () => deps.queue.flush(),
111
+ };
112
+ }
113
+ function buildReviseWriteToolDeps(options) {
114
+ const logEndpoint = options.logEndpoint ?? process.env['ATRIB_LOG_ENDPOINT'];
115
+ const queue = createSubmissionQueue(logEndpoint);
116
+ return {
117
+ resolveServerKey: createServerKeyResolver(options),
118
+ queue,
119
+ logEndpoint,
120
+ options,
121
+ };
122
+ }
123
+ function createServerKeyResolver(options) {
124
+ if (Object.prototype.hasOwnProperty.call(options, 'key')) {
125
+ const fixed = options.key ?? null;
126
+ return async () => fixed;
127
+ }
128
+ let resolved = null;
129
+ return async () => {
130
+ resolved ??= resolveKey();
131
+ return resolved;
132
+ };
133
+ }
@@ -0,0 +1,103 @@
1
+ import { type AtribRecord, type ProofBundle } from '@atrib/mcp';
2
+ import { type ResolvedKey } from './keys.js';
3
+ /** The session_checkpoint event_type URI (§1.2.10, D139; extension-staged per D073). */
4
+ export declare const SESSION_CHECKPOINT_EVENT_TYPE_URI = "https://atrib.dev/v1/types/session_checkpoint";
5
+ /** §1.2.10 checkpoint body (producer-side; retroactive present-only-when-true). */
6
+ export interface SessionCheckpoint {
7
+ first_index: number;
8
+ prior_checkpoint?: string;
9
+ retroactive?: true;
10
+ session_root: string;
11
+ tree_size: number;
12
+ }
13
+ export type SessionCheckpointRecord = AtribRecord & {
14
+ checkpoint: SessionCheckpoint;
15
+ };
16
+ export interface EmitSessionCheckpointOptions {
17
+ /**
18
+ * 32-hex context_id whose stream to checkpoint. Defaults to
19
+ * `resolveEnvContextId()` (D078/D083 harness discovery). Without a
20
+ * resolvable context the checkpoint is skipped: a checkpoint over a
21
+ * synthesized orphan context would always be empty, which §1.2.10
22
+ * prohibits.
23
+ */
24
+ contextId?: string | undefined;
25
+ /**
26
+ * Mirror file to READ the ordered leaf stream from. Defaults to the
27
+ * ./storage.js write path (ATRIB_MIRROR_FILE, else the per-agent file
28
+ * under ~/.atrib/records). Overriding is for hosts that aggregate a
29
+ * multi-producer stream in one file; the emitted checkpoint is still
30
+ * WRITTEN via the storage convention, so keep the two aligned or the
31
+ * next checkpoint will not see this one as a leaf.
32
+ */
33
+ mirrorPath?: string | undefined;
34
+ /** Override the resolved key (primarily for testing). */
35
+ key?: ResolvedKey | null | undefined;
36
+ /** Override the log endpoint (defaults to ATRIB_LOG_ENDPOINT or the @atrib/mcp default). */
37
+ logEndpoint?: string | undefined;
38
+ /**
39
+ * §1.2.10.3 producer rule: set true when any newly covered leaf was not
40
+ * observed live by this producer (mirror/archive backfill). Present-only-
41
+ * when-true in the signed bytes; false and undefined are byte-identical
42
+ * (the field is omitted).
43
+ */
44
+ retroactive?: boolean | undefined;
45
+ /** Sidecar `_local.producer` label. Defaults to 'atrib-emit'. */
46
+ producer?: string | undefined;
47
+ /** Upper bound on the post-sign queue flush (see emitInProcess). Default 5000ms. */
48
+ flushDeadlineMs?: number | undefined;
49
+ /** Clock override for tests. */
50
+ now?: (() => number) | undefined;
51
+ }
52
+ export interface EmitSessionCheckpointResult {
53
+ /** Record hash of the signed checkpoint, or 'sha256:unknown' when skipped. */
54
+ record_hash: string;
55
+ log_index: number | null;
56
+ inclusion_proof: ProofBundle['inclusion_proof'] | null;
57
+ context_id: string;
58
+ /** The signed checkpoint body, or null when emission was skipped. */
59
+ checkpoint: SessionCheckpoint | null;
60
+ /** Number of leaves committed (0 when skipped). */
61
+ covered_leaves: number;
62
+ warnings: string[];
63
+ }
64
+ interface MirrorLeaf {
65
+ ref: string;
66
+ bytes: Uint8Array;
67
+ record: AtribRecord & {
68
+ checkpoint?: SessionCheckpoint;
69
+ };
70
+ }
71
+ /** Same default as ./storage.js mirrorPath(): read where we write. */
72
+ declare function defaultMirrorPath(): string;
73
+ /**
74
+ * Read the ordered leaf stream for one context_id from a §5.9 mirror:
75
+ * every record on the context, in append order, with its §1.2.3 canonical
76
+ * record hash. Returns [] when the mirror is missing or unreadable.
77
+ */
78
+ declare function readMirrorLeaves(path: string, contextId: string): Promise<MirrorLeaf[]>;
79
+ /** D099 content commitment: sha256(JCS({leaves: [...refs...]})), prefixed. */
80
+ declare function leavesArgsHash(leafRefs: readonly string[]): string;
81
+ /**
82
+ * Emit one session checkpoint over the local mirror's record stream for a
83
+ * context_id (D139, §1.2.10).
84
+ *
85
+ * Reads the ordered record hashes from the §5.9 mirror (append order = the
86
+ * producer-declared session order), links to the most recent prior
87
+ * checkpoint on the context (first_index = prior tree_size,
88
+ * prior_checkpoint = prior record hash), self-checks append-only extension
89
+ * against the prior session_root (refusing to sign what would be
90
+ * equivocation evidence against our own key), signs through the standard
91
+ * owner, submits non-blocking, and mirrors the record with the full leaf
92
+ * list in `_local.content.leaves` per D099.
93
+ *
94
+ * Never throws (§5.8): every failure returns a warnings-carrying result
95
+ * and the missed checkpoint widens the next interval.
96
+ */
97
+ export declare function emitSessionCheckpoint(options?: EmitSessionCheckpointOptions): Promise<EmitSessionCheckpointResult>;
98
+ export declare const __test_only__: {
99
+ readMirrorLeaves: typeof readMirrorLeaves;
100
+ leavesArgsHash: typeof leavesArgsHash;
101
+ defaultMirrorPath: typeof defaultMirrorPath;
102
+ };
103
+ export {};
@@ -0,0 +1,285 @@
1
+ // Producer-side session-checkpoint emission (D139, spec §1.2.10).
2
+ //
3
+ // emitSessionCheckpoint() layers on the existing emit pipeline stage by
4
+ // stage — the SAME key resolution (./keys.js resolveKey), the SAME signing
5
+ // owner (@atrib/mcp signRecord, the one path every emit-family record goes
6
+ // through via buildAndSignEmitRecord), the SAME D067 chain composition
7
+ // (@atrib/mcp inheritChainContext, never reimplemented), the SAME
8
+ // submission queue (@atrib/mcp createSubmissionQueue, non-blocking per
9
+ // §5.3.5), and the SAME §5.9 mirror convention (./storage.js mirrorRecord)
10
+ // — so checkpoint records are byte-identical to what any other emit-family
11
+ // producer would sign. There is NO new signing path here.
12
+ //
13
+ // It does not route through handleEmit/emitInProcess because their
14
+ // EmitInput schema (deliberately) cannot express the top-level `checkpoint`
15
+ // field; a checkpoint is not free-form `content`, it is a §1.2.10
16
+ // structural field that must land in the signed bytes. The record-assembly
17
+ // mirror of @atrib/mcp's `buildSessionCheckpointRecord` lives inline below
18
+ // until that module's exports land on the @atrib/mcp index (see the D139
19
+ // implementation notes); the tree math itself comes from the already-
20
+ // exported §2.3.2 helpers (`computeRoot`), reused verbatim.
21
+ //
22
+ // Leaf source (§1.2.10.1 leaf-ordering rule): the ordered record stream is
23
+ // read back from the local mirror in APPEND ORDER — the producer-declared
24
+ // session order for records read from a §5.9 mirror. Reading and writing
25
+ // target the same file (the ./storage.js convention: ATRIB_MIRROR_FILE,
26
+ // else ~/.atrib/records/atrib-emit-<agent>.jsonl) so every emitted
27
+ // checkpoint lands in the stream it formalizes and becomes a leaf of the
28
+ // next checkpoint's tree (self-exclusion is automatic: leaves are collected
29
+ // before the new checkpoint is appended).
30
+ //
31
+ // §5.8 degradation: emitSessionCheckpoint never throws. Every failure —
32
+ // missing key, unresolvable context, empty stream, mirror divergence,
33
+ // signing or submission errors — is logged with the `atrib-emit:` prefix,
34
+ // surfaced in `warnings`, and the checkpoint is simply skipped: a missed
35
+ // checkpoint just widens the next interval.
36
+ import { createReadStream } from 'node:fs';
37
+ import { stat } from 'node:fs/promises';
38
+ import { homedir } from 'node:os';
39
+ import { join } from 'node:path';
40
+ import * as readline from 'node:readline';
41
+ import canonicalize from 'canonicalize';
42
+ import { base64urlEncode, canonicalRecord, computeContentId, computeRoot, createSubmissionQueue, getPublicKey, hexEncode, inheritChainContext, resolveEnvContextId, sha256, SHA256_REF_PATTERN, signRecord, } from '@atrib/mcp';
43
+ import { resolveKey } from './keys.js';
44
+ import { mirrorRecord } from './storage.js';
45
+ /** The session_checkpoint event_type URI (§1.2.10, D139; extension-staged per D073). */
46
+ export const SESSION_CHECKPOINT_EVENT_TYPE_URI = 'https://atrib.dev/v1/types/session_checkpoint';
47
+ const encoder = new TextEncoder();
48
+ const DEFAULT_FLUSH_DEADLINE_MS = 5000;
49
+ const DAY_MS = 24 * 60 * 60 * 1000;
50
+ /** Same default as ./storage.js mirrorPath(): read where we write. */
51
+ function defaultMirrorPath() {
52
+ return (process.env['ATRIB_MIRROR_FILE'] ??
53
+ join(homedir(), '.atrib', 'records', `atrib-emit-${process.env['ATRIB_AGENT'] ?? 'claude-code'}.jsonl`));
54
+ }
55
+ /**
56
+ * Parse one mirror line into a record. Accepts both §5.9 on-disk shapes
57
+ * (bare record, or `{record, proof?, _local?}` envelope); malformed lines
58
+ * are skipped per §5.8. Mirrors @atrib/mcp's mirror.ts parseMirrorLine.
59
+ */
60
+ function parseMirrorLine(line) {
61
+ try {
62
+ const parsed = JSON.parse(line);
63
+ const candidate = 'record' in parsed && parsed.record ? parsed.record : parsed;
64
+ if (typeof candidate.context_id !== 'string' ||
65
+ typeof candidate.creator_key !== 'string' ||
66
+ typeof candidate.signature !== 'string' ||
67
+ typeof candidate.chain_root !== 'string') {
68
+ return null;
69
+ }
70
+ return candidate;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /**
77
+ * Read the ordered leaf stream for one context_id from a §5.9 mirror:
78
+ * every record on the context, in append order, with its §1.2.3 canonical
79
+ * record hash. Returns [] when the mirror is missing or unreadable.
80
+ */
81
+ async function readMirrorLeaves(path, contextId) {
82
+ try {
83
+ const stats = await stat(path);
84
+ if (stats.size === 0)
85
+ return [];
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ const leaves = [];
91
+ try {
92
+ const stream = createReadStream(path, { encoding: 'utf-8' });
93
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
94
+ for await (const line of rl) {
95
+ const trimmed = line.trim();
96
+ if (!trimmed)
97
+ continue;
98
+ const record = parseMirrorLine(trimmed);
99
+ if (!record)
100
+ continue;
101
+ if (record.context_id !== contextId)
102
+ continue;
103
+ const bytes = sha256(canonicalRecord(record));
104
+ leaves.push({ ref: `sha256:${hexEncode(bytes)}`, bytes, record });
105
+ }
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ return leaves;
111
+ }
112
+ /** D099 content commitment: sha256(JCS({leaves: [...refs...]})), prefixed. */
113
+ function leavesArgsHash(leafRefs) {
114
+ const json = canonicalize({ leaves: [...leafRefs] });
115
+ if (json === undefined) {
116
+ throw new Error('leaf list is not canonicalizable');
117
+ }
118
+ return `sha256:${hexEncode(sha256(encoder.encode(json)))}`;
119
+ }
120
+ /** Bridge to the queue's bare-hex proof cache (same as ./index.js getProofFor). */
121
+ function getProofFor(queue, recordHash) {
122
+ return queue.getProof(recordHash.startsWith('sha256:') ? recordHash.slice('sha256:'.length) : recordHash);
123
+ }
124
+ function skipped(contextId, warnings, warning) {
125
+ warnings.push(warning);
126
+ console.warn(`atrib-emit: session checkpoint skipped: ${warning}`);
127
+ return {
128
+ record_hash: 'sha256:unknown',
129
+ log_index: null,
130
+ inclusion_proof: null,
131
+ context_id: contextId,
132
+ checkpoint: null,
133
+ covered_leaves: 0,
134
+ warnings,
135
+ };
136
+ }
137
+ /**
138
+ * Emit one session checkpoint over the local mirror's record stream for a
139
+ * context_id (D139, §1.2.10).
140
+ *
141
+ * Reads the ordered record hashes from the §5.9 mirror (append order = the
142
+ * producer-declared session order), links to the most recent prior
143
+ * checkpoint on the context (first_index = prior tree_size,
144
+ * prior_checkpoint = prior record hash), self-checks append-only extension
145
+ * against the prior session_root (refusing to sign what would be
146
+ * equivocation evidence against our own key), signs through the standard
147
+ * owner, submits non-blocking, and mirrors the record with the full leaf
148
+ * list in `_local.content.leaves` per D099.
149
+ *
150
+ * Never throws (§5.8): every failure returns a warnings-carrying result
151
+ * and the missed checkpoint widens the next interval.
152
+ */
153
+ export async function emitSessionCheckpoint(options = {}) {
154
+ const warnings = [];
155
+ const contextId = options.contextId ?? resolveEnvContextId();
156
+ if (contextId === undefined || !/^[0-9a-f]{32}$/.test(contextId)) {
157
+ return skipped(contextId ?? 'unknown', warnings, 'no valid 32-hex context_id resolved (pass contextId or set ATRIB_CONTEXT_ID); checkpoints are never emitted over synthesized orphan contexts');
158
+ }
159
+ try {
160
+ const key = options.key !== undefined ? options.key : await resolveKey();
161
+ if (!key) {
162
+ return skipped(contextId, warnings, 'no signing key resolved (set ATRIB_PRIVATE_KEY, ATRIB_KEY_FILE, or store seed in Keychain)');
163
+ }
164
+ const mirrorPath = options.mirrorPath ?? defaultMirrorPath();
165
+ const leaves = await readMirrorLeaves(mirrorPath, contextId);
166
+ if (leaves.length === 0) {
167
+ return skipped(contextId, warnings, `no records for context_id ${contextId} in mirror ${mirrorPath}; empty checkpoints are prohibited (tree_size >= 1)`);
168
+ }
169
+ // Most recent prior session_checkpoint on this context (its own leaf
170
+ // stream position is irrelevant; what matters is its committed range).
171
+ let prior;
172
+ for (const leaf of leaves) {
173
+ if (leaf.record.event_type === SESSION_CHECKPOINT_EVENT_TYPE_URI &&
174
+ leaf.record.checkpoint !== undefined) {
175
+ prior = leaf;
176
+ }
177
+ }
178
+ let firstIndex = 0;
179
+ let priorCheckpoint;
180
+ if (prior?.record.checkpoint !== undefined) {
181
+ const priorBody = prior.record.checkpoint;
182
+ if (!Number.isInteger(priorBody.tree_size) || priorBody.tree_size < 1) {
183
+ return skipped(contextId, warnings, 'prior checkpoint in mirror carries a malformed tree_size');
184
+ }
185
+ if (priorBody.tree_size >= leaves.length) {
186
+ return skipped(contextId, warnings, `no new leaves since prior checkpoint (tree_size ${priorBody.tree_size}, mirror has ${leaves.length}); producers skip empty intervals`);
187
+ }
188
+ // Append-only self-check (§1.2.10.2): the current stream's prefix must
189
+ // reproduce the prior session_root. Signing over a diverged prefix
190
+ // would mint equivocation evidence against our own key; skip instead.
191
+ const prefixRoot = `sha256:${hexEncode(computeRoot(leaves.slice(0, priorBody.tree_size).map((l) => l.bytes)))}`;
192
+ if (prefixRoot !== priorBody.session_root) {
193
+ return skipped(contextId, warnings, `mirror prefix (${priorBody.tree_size} leaves) recomputes to ${prefixRoot} but the prior checkpoint committed ${priorBody.session_root}; emitting would equivocate`);
194
+ }
195
+ firstIndex = priorBody.tree_size;
196
+ priorCheckpoint = prior.ref;
197
+ if (!SHA256_REF_PATTERN.test(priorCheckpoint)) {
198
+ return skipped(contextId, warnings, 'prior checkpoint record hash is malformed');
199
+ }
200
+ }
201
+ const now = options.now ?? Date.now;
202
+ const timestamp = now();
203
+ // §1.2.10.3 producer hint: warn (do not block) when the new interval is
204
+ // stale against the default verifier bound and undeclared.
205
+ const newestLeaf = leaves[leaves.length - 1];
206
+ const maxCoveredLeafTimestamp = leaves.reduce((max, l) => (typeof l.record.timestamp === 'number' ? Math.max(max, l.record.timestamp) : max), typeof newestLeaf.record.timestamp === 'number' ? newestLeaf.record.timestamp : 0);
207
+ if (options.retroactive !== true && timestamp - maxCoveredLeafTimestamp > DAY_MS) {
208
+ warnings.push('checkpoint timestamp exceeds the max covered leaf timestamp by more than 24h without retroactive: true; verifiers will tier it stale-undeclared (§1.2.10.3)');
209
+ }
210
+ const leafRefs = leaves.map((l) => l.ref);
211
+ const sessionRoot = `sha256:${hexEncode(computeRoot(leaves.map((l) => l.bytes)))}`;
212
+ const checkpoint = {
213
+ first_index: firstIndex,
214
+ ...(priorCheckpoint !== undefined ? { prior_checkpoint: priorCheckpoint } : {}),
215
+ ...(options.retroactive === true ? { retroactive: true } : {}),
216
+ session_root: sessionRoot,
217
+ tree_size: leafRefs.length,
218
+ };
219
+ // D067 chain composition through the shared helper — never reimplemented.
220
+ // The mirror tail on this context is the newest record in the stream we
221
+ // just committed, so the checkpoint chains directly onto it.
222
+ const chain = await inheritChainContext({
223
+ callerContextId: contextId,
224
+ mirrorPath,
225
+ randomContextId: () => contextId,
226
+ });
227
+ const creatorKey = base64urlEncode(await getPublicKey(key.privateKey));
228
+ const unsigned = {
229
+ spec_version: 'atrib/1.0',
230
+ // §1.2.2 with the pseudo-origin "atrib" for origin-less cognitive
231
+ // producers: input "atrib:session_checkpoint" (corpus-pinned).
232
+ content_id: computeContentId('atrib', 'session_checkpoint'),
233
+ creator_key: creatorKey,
234
+ chain_root: chain.chainRoot,
235
+ checkpoint,
236
+ event_type: SESSION_CHECKPOINT_EVENT_TYPE_URI,
237
+ context_id: contextId,
238
+ timestamp,
239
+ args_hash: leavesArgsHash(leafRefs),
240
+ signature: '',
241
+ };
242
+ // Single signing owner: the same @atrib/mcp signRecord every emit-family
243
+ // producer routes through.
244
+ const record = (await signRecord(unsigned, key.privateKey));
245
+ const recordHash = `sha256:${hexEncode(sha256(canonicalRecord(record)))}`;
246
+ // Non-blocking submission per §5.3.5 / §5.8; the queue owns retries.
247
+ const logEndpoint = options.logEndpoint ?? process.env['ATRIB_LOG_ENDPOINT'];
248
+ const queue = createSubmissionQueue(logEndpoint);
249
+ queue.submit(record, 'normal');
250
+ // §5.9 mirror with the D099 sidecar: the flat leaf list stays local in
251
+ // _local.content.leaves; the signed bytes carry only root + args_hash.
252
+ await mirrorRecord(record, getProofFor(queue, recordHash) ?? null, {
253
+ content: { leaves: leafRefs },
254
+ producer: options.producer ?? 'atrib-emit',
255
+ });
256
+ // Bounded drain (same posture as emitInProcess): short-lived callers
257
+ // must not inherit the queue's full retry budget on an unreachable log.
258
+ const flushDeadlineMs = options.flushDeadlineMs ?? DEFAULT_FLUSH_DEADLINE_MS;
259
+ const flushed = await Promise.race([
260
+ queue.flush().then(() => true),
261
+ new Promise((resolve) => setTimeout(() => resolve(false), flushDeadlineMs)),
262
+ ]);
263
+ if (!flushed) {
264
+ warnings.push(`flush exceeded ${flushDeadlineMs}ms deadline; checkpoint signed and mirrored locally, log submission may still be in flight`);
265
+ }
266
+ const proof = getProofFor(queue, recordHash);
267
+ if (!proof && flushed) {
268
+ warnings.push('submission queued; proof not yet available (poll the log later if needed)');
269
+ }
270
+ return {
271
+ record_hash: recordHash,
272
+ log_index: proof?.log_index ?? null,
273
+ inclusion_proof: proof?.inclusion_proof ?? null,
274
+ context_id: contextId,
275
+ checkpoint: record.checkpoint,
276
+ covered_leaves: leafRefs.length,
277
+ warnings,
278
+ };
279
+ }
280
+ catch (e) {
281
+ // §5.8: no failure here may reach the caller as a throw.
282
+ return skipped(contextId, warnings, `session checkpoint emission failed: ${e instanceof Error ? e.message : String(e)}`);
283
+ }
284
+ }
285
+ export const __test_only__ = { readMirrorLeaves, leavesArgsHash, defaultMirrorPath };
package/dist/sign.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { type AtribRecord } from '@atrib/mcp';
2
+ export interface BuildEmitRecordInput {
3
+ privateKey: Uint8Array;
4
+ eventType: string;
5
+ contextId: string;
6
+ chainRoot: string;
7
+ /** Cognitive content. Committed through args_hash by default; full content lives in the mirror. */
8
+ content: Record<string, unknown>;
9
+ informedBy?: string[] | undefined;
10
+ /**
11
+ * Optional cross-session causal anchor (spec §1.2.6 / D044). Caller is
12
+ * responsible for ensuring the genesis-record-only invariant holds; the
13
+ * index.ts handler validates this before reaching here.
14
+ */
15
+ provenanceToken?: string | undefined;
16
+ /**
17
+ * Optional annotates target per spec §1.2.7 / D058. Required when
18
+ * eventType is the annotation URI; FORBIDDEN on any other event_type.
19
+ * The index.ts handler enforces the require/forbid invariant before
20
+ * reaching here. Format: "sha256:" + 64 lowercase hex.
21
+ */
22
+ annotates?: string | undefined;
23
+ /**
24
+ * Optional revises target per spec §1.2.9 / D059. Required when
25
+ * eventType is the revision URI; FORBIDDEN on any other event_type.
26
+ * The index.ts handler enforces the require/forbid invariant before
27
+ * reaching here. Format: "sha256:" + 64 lowercase hex.
28
+ */
29
+ revises?: string | undefined;
30
+ /**
31
+ * Optional §8.2 tool_name disclosure. Lets emit-signed records carry the
32
+ * verbatim or transformed tool name for downstream consumers (e.g.
33
+ * `recall_my_attribution_history` filtering by tool_name). Pass through
34
+ * unchanged: the caller picks the disclosure form (verbatim string,
35
+ * opaque alias, or hashed). Absence indicates the §8.1 default posture
36
+ * (no tool-name disclosure). Validators reject mixed structural forms;
37
+ * see middleware.ts disclosure pipeline for canonical shapes.
38
+ */
39
+ toolName?: string | undefined;
40
+ /**
41
+ * Optional §8.3 args_hash commitment. Lets emit-signed records carry a
42
+ * commitment to canonical args bytes for downstream consumers (e.g.
43
+ * `recall_my_attribution_history` filtering by args_hash, or replay
44
+ * detection). Format: "sha256:" + 64 lowercase hex. When omitted,
45
+ * explicit emit signs sha256(JCS(content)) so local bodies remain
46
+ * replay-checkable without exposing the body to the public log.
47
+ */
48
+ argsHash?: string | undefined;
49
+ /**
50
+ * Optional §8.3 result_hash commitment. Same posture semantics and wire
51
+ * format as args_hash, but commits to the tool result bytes.
52
+ */
53
+ resultHash?: string | undefined;
54
+ }
55
+ /**
56
+ * Build, sign, and return a complete AtribRecord ready for submission.
57
+ * Pure aside from the signing primitive itself; no network I/O here.
58
+ */
59
+ export declare function buildAndSignEmitRecord(input: BuildEmitRecordInput): Promise<AtribRecord>;
60
+ /**
61
+ * Best-effort URI leaf extraction. For atrib-namespace URIs returns the
62
+ * trailing path segment (e.g. 'observation', 'annotation'); for extension
63
+ * URIs returns the URI itself.
64
+ */
65
+ declare function leafOfEventTypeUri(uri: string): string;
66
+ export declare const __test_only__: {
67
+ leafOfEventTypeUri: typeof leafOfEventTypeUri;
68
+ };
69
+ export {};
package/dist/sign.js ADDED
@@ -0,0 +1,74 @@
1
+ // Build + sign an attribution record for the agent's emit call. Reuses
2
+ // @atrib/mcp's signing primitives so emit-signed records are byte-identical
3
+ // in canonical form to wrapper-signed records, a verifier MUST NOT be
4
+ // able to distinguish "wrapper signed this" from "emit signed this."
5
+ //
6
+ // Per spec §1.2.2: content_id is a stable identifier for the *kind* of
7
+ // action, not the specific invocation. The wrapper derives it from
8
+ // server_url + tool_name; we use the synthetic pair (`mcp://atrib-emit` +
9
+ // leaf of event_type URI), so all observations share content_id, all
10
+ // annotations share content_id, etc. Explicit emit records also commit to
11
+ // their local content through args_hash, so same-millisecond emits with
12
+ // different sidecar bodies cannot collapse into the same signed record.
13
+ //
14
+ // The `content` argument never lands on-chain; the log stores commitments
15
+ // only per spec §2.10. Full content lives in the local JSONL mirror for
16
+ // the agent's own recall.
17
+ import { computeContentId, getPublicKey, signRecord, base64urlEncode, sha256, hexEncode, } from '@atrib/mcp';
18
+ import canonicalize from 'canonicalize';
19
+ const SYNTHETIC_SERVER_URL = 'mcp://atrib-emit';
20
+ const encoder = new TextEncoder();
21
+ /**
22
+ * Build, sign, and return a complete AtribRecord ready for submission.
23
+ * Pure aside from the signing primitive itself; no network I/O here.
24
+ */
25
+ export async function buildAndSignEmitRecord(input) {
26
+ const publicKey = base64urlEncode(await getPublicKey(input.privateKey));
27
+ const toolName = leafOfEventTypeUri(input.eventType);
28
+ const contentId = computeContentId(SYNTHETIC_SERVER_URL, toolName);
29
+ const argsHash = input.argsHash ?? contentHash(input.content);
30
+ // informed_by must be sorted lexicographically per §1.2.5 to keep the
31
+ // canonical form stable across emitters. Omitted entirely (not null,
32
+ // not empty) when no references are given, presence affects JCS.
33
+ const informedBySorted = input.informedBy && input.informedBy.length > 0
34
+ ? [...input.informedBy].sort()
35
+ : undefined;
36
+ const record = {
37
+ spec_version: 'atrib/1.0',
38
+ content_id: contentId,
39
+ creator_key: publicKey,
40
+ chain_root: input.chainRoot,
41
+ event_type: input.eventType,
42
+ context_id: input.contextId,
43
+ timestamp: Date.now(),
44
+ signature: '',
45
+ ...(informedBySorted ? { informed_by: informedBySorted } : {}),
46
+ ...(input.annotates ? { annotates: input.annotates } : {}),
47
+ ...(argsHash ? { args_hash: argsHash } : {}),
48
+ ...(input.provenanceToken ? { provenance_token: input.provenanceToken } : {}),
49
+ ...(input.resultHash ? { result_hash: input.resultHash } : {}),
50
+ ...(input.revises ? { revises: input.revises } : {}),
51
+ ...(input.toolName ? { tool_name: input.toolName } : {}),
52
+ };
53
+ return signRecord(record, input.privateKey);
54
+ }
55
+ function contentHash(content) {
56
+ const json = canonicalize(content);
57
+ if (typeof json !== 'string') {
58
+ throw new Error('content must be JCS-canonicalizable JSON');
59
+ }
60
+ return `sha256:${hexEncode(sha256(encoder.encode(json)))}`;
61
+ }
62
+ /**
63
+ * Best-effort URI leaf extraction. For atrib-namespace URIs returns the
64
+ * trailing path segment (e.g. 'observation', 'annotation'); for extension
65
+ * URIs returns the URI itself.
66
+ */
67
+ function leafOfEventTypeUri(uri) {
68
+ const slashIdx = uri.lastIndexOf('/');
69
+ if (slashIdx === -1)
70
+ return uri;
71
+ const leaf = uri.slice(slashIdx + 1);
72
+ return leaf.length > 0 ? leaf : uri;
73
+ }
74
+ export const __test_only__ = { leafOfEventTypeUri };