@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/index.js ADDED
@@ -0,0 +1,1028 @@
1
+ // @atrib/attest: atrib's write-verb home. Registers the `attest` tool (the
2
+ // two-verb surface's write half) plus the three legacy write tool names
3
+ // (`emit`, `atrib-annotate`, `atrib-revise`) as permanent aliases over the
4
+ // same handleEmit funnel, so a record signed through any of the four names
5
+ // is byte-identical in canonical form. Reuses @atrib/mcp's signing and
6
+ // submission primitives so all of them match wrapper-signed records too.
7
+ //
8
+ // Scope:
9
+ // - Write union server: attest + emit + atrib-annotate + atrib-revise
10
+ // - Legacy single-purpose factories preserved (each mounts its legacy
11
+ // name plus `attest` per the alias-window rule W1)
12
+ // - One key per process (the agent's wrapper key)
13
+ // - Reuses @atrib/mcp signing + submission queue
14
+ // - Persists to the same JSONL convention as the wrapper; the default
15
+ // mirror filename pattern `atrib-emit-<agent>.jsonl` is frozen (L3):
16
+ // existing files keep their names forever
17
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
+ import { z } from 'zod';
19
+ import { randomBytes } from 'node:crypto';
20
+ import { homedir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { EVENT_TYPE_ANNOTATION_URI, EVENT_TYPE_REVISION_URI, LOCAL_SUBSTRATE_DEFAULT_TIMEOUT_MS, LOCAL_SUBSTRATE_REQUEST_SCHEMA, canonicalRecord, createHttpLocalSubstrateTransport, createSubmissionQueue, genesisChainRoot, hexEncode, inheritChainContext, isValidEventTypeUri, normalizeEventType, parentRecordHashFromEnv, resolveEnvContextId, SHA256_REF_PATTERN, sha256, tryLocalSubstrateCoordinator, } from '@atrib/mcp';
23
+ import { resolveKey } from './keys.js';
24
+ import { filterResolvableInformedBy } from './reference-resolution.js';
25
+ import { buildAndSignEmitRecord } from './sign.js';
26
+ import { mirrorRecord } from './storage.js';
27
+ import { AttestInput, isAttestMappingRefusal, mapAttestInput, } from './attest.js';
28
+ import { registerAnnotateTool } from './annotate.js';
29
+ import { registerReviseTool } from './revise.js';
30
+ // Read-side mirror inheritance: ATRIB_AUTOCHAIN_SOURCE points at the file
31
+ // atrib-emit reads to inherit cross-producer chain state (typically the
32
+ // wrapper's mirror). Falls back to ATRIB_MIRROR_FILE (where emit writes,
33
+ // rarely useful as a read source, but kept for backward compatibility),
34
+ // then to the per-agent default. Distinct from ATRIB_MIRROR_FILE which is
35
+ // where emit's own records are persisted.
36
+ function readMirrorPath() {
37
+ return (process.env['ATRIB_AUTOCHAIN_SOURCE'] ??
38
+ process.env['ATRIB_MIRROR_FILE'] ??
39
+ join(homedir(), '.atrib', 'records', `atrib-emit-${process.env['ATRIB_AGENT'] ?? 'claude-code'}.jsonl`));
40
+ }
41
+ const HEX_32_PATTERN = /^[0-9a-f]{32}$/;
42
+ // 16 bytes encoded as base64url with no padding = 22 chars per spec §1.2.6.
43
+ const PROVENANCE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{22}$/;
44
+ const DEFAULT_KEY_RESOLVE_RETRY_MS = 30_000;
45
+ const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
46
+ function keyResolveRetryMs() {
47
+ const raw = process.env['ATRIB_KEY_RESOLVE_RETRY_MS'];
48
+ if (raw === undefined || raw === '')
49
+ return DEFAULT_KEY_RESOLVE_RETRY_MS;
50
+ const parsed = Number(raw);
51
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_KEY_RESOLVE_RETRY_MS;
52
+ }
53
+ export function requiresExplicitContextId(env = process.env) {
54
+ const raw = env['ATRIB_REQUIRE_EXPLICIT_CONTEXT_ID'];
55
+ return raw !== undefined && TRUE_ENV_VALUES.has(raw.trim().toLowerCase());
56
+ }
57
+ const EmitInput = z.object({
58
+ event_type: z
59
+ .string()
60
+ .min(1)
61
+ .max(256)
62
+ .describe('Event type URI per spec §1.2.4. Common normative values: ' +
63
+ "'https://atrib.dev/v1/types/observation', '...annotation', '...revision'. " +
64
+ "The shorthand aliases 'observation', 'annotation', 'revision', 'tool_call', " +
65
+ "'transaction', and 'directory_anchor' are accepted and normalized before signing. " +
66
+ 'Extension URIs in any namespace OK.'),
67
+ content: z
68
+ .record(z.string(), z.unknown())
69
+ .describe('Semantic content of the cognitive event. Shape varies per event_type. ' +
70
+ 'For observation: { what: string, why_noted?: string, topics?: string[] }. ' +
71
+ "For annotation: { annotates: 'sha256:...', importance: 'critical'|'high'|'medium'|'low'|'noise', summary: string, topics?: string[] }. " +
72
+ "For revision: { revises: 'sha256:...', prior_position: string, new_position: string, reason: string }."),
73
+ context_id: z
74
+ .string()
75
+ .regex(HEX_32_PATTERN)
76
+ .optional()
77
+ .describe('32-hex context_id. If omitted, a fresh genesis context_id is generated and the record is treated as a new chain.'),
78
+ informed_by: z
79
+ .array(z.string().regex(SHA256_REF_PATTERN))
80
+ .optional()
81
+ .describe("Array of 'sha256:<64-hex>' record_hashes that informed this event. " +
82
+ 'By default, atrib-emit keeps only refs it can find in local mirrors or ' +
83
+ 'the configured log lookup. Sorted lexicographically before signing per §1.2.5.'),
84
+ allow_unresolved_informed_by: z
85
+ .boolean()
86
+ .optional()
87
+ .describe('Set true only when the caller deliberately wants to sign dangling ' +
88
+ 'informed_by refs. Defaults false so temp fixture hashes and evidence ' +
89
+ 'commitments do not become graph edges by accident.'),
90
+ chain_root: z
91
+ .string()
92
+ .regex(SHA256_REF_PATTERN)
93
+ .optional()
94
+ .describe("Caller-managed chain_root, the 'sha256:<64-hex>' hash of the immediately " +
95
+ 'preceding record in this context_id. When supplied alongside context_id, ' +
96
+ 'atrib-emit uses both verbatim instead of treating context_id as a fresh ' +
97
+ 'genesis. Required when caller manages chain state across multiple emits ' +
98
+ 'under one context_id (e.g. multi-record watcher pipelines). When omitted ' +
99
+ 'with context_id present, atrib-emit synthesizes the genesis chain_root ' +
100
+ 'per spec §1.2.3. Without context_id, this field is meaningless and ' +
101
+ 'returns a signed:false refusal.'),
102
+ provenance_token: z
103
+ .string()
104
+ .regex(PROVENANCE_TOKEN_PATTERN)
105
+ .optional()
106
+ .describe('22-char base64url cross-session causal anchor per spec §1.2.6 / D044. ' +
107
+ 'Genesis-record-only: atrib-emit refuses to sign a record that carries ' +
108
+ 'this field if its chain_root is not the genesis chain_root for the ' +
109
+ 'context_id (this returns a signed:false refusal rather than a malformed record).'),
110
+ annotates: z
111
+ .string()
112
+ .regex(SHA256_REF_PATTERN)
113
+ .optional()
114
+ .describe("'sha256:<64-hex>' record_hash this annotation describes per spec §1.2.7 / D058. " +
115
+ 'REQUIRED when event_type is the annotation URI; FORBIDDEN on any other event_type. ' +
116
+ 'atrib-emit enforces the require/forbid invariant per §1.2.7 (validators MUST reject ' +
117
+ 'violations) and returns a signed:false refusal rather than signing a malformed record.'),
118
+ revises: z
119
+ .string()
120
+ .regex(SHA256_REF_PATTERN)
121
+ .optional()
122
+ .describe("'sha256:<64-hex>' record_hash this revision supersedes per spec §1.2.9 / D059. " +
123
+ 'REQUIRED when event_type is the revision URI; FORBIDDEN on any other event_type. ' +
124
+ 'atrib-emit enforces the require/forbid invariant per §1.2.9 (validators MUST reject ' +
125
+ 'violations) and returns a signed:false refusal rather than signing a malformed record.'),
126
+ tool_name: z
127
+ .string()
128
+ .min(1)
129
+ .max(64)
130
+ .optional()
131
+ .describe('Optional §8.2 tool_name disclosure. Verbatim or transformed name (verbatim, opaque, ' +
132
+ 'or hashed per §8.2). Lets emit-signed records carry the tool name for downstream ' +
133
+ 'consumers (e.g. recall_my_attribution_history filtering by tool_name). Absence ' +
134
+ 'indicates the §8.1 default posture (no disclosure).'),
135
+ args_hash: z
136
+ .string()
137
+ .regex(SHA256_REF_PATTERN)
138
+ .optional()
139
+ .describe('Optional §8.3 args_hash commitment. Format: "sha256:" + 64 lowercase hex. Lets ' +
140
+ 'emit-signed records carry a commitment to canonical args bytes for downstream ' +
141
+ 'consumers (e.g. recall filtering by args_hash, or replay detection). Salted vs ' +
142
+ 'plain forms hash identically on the wire; the salt (when used) is carried in the ' +
143
+ 'separate args_salt field, which this surface does not yet expose.'),
144
+ result_hash: z
145
+ .string()
146
+ .regex(SHA256_REF_PATTERN)
147
+ .optional()
148
+ .describe('Optional §8.3 result_hash commitment. Format: "sha256:" + 64 lowercase hex. Lets ' +
149
+ 'emit-signed records carry a commitment to canonical result bytes for downstream ' +
150
+ 'consumers. Salted vs plain forms hash identically on the wire; the salt (when used) ' +
151
+ 'is carried in the separate result_salt field, which this surface does not yet expose.'),
152
+ });
153
+ const DEFAULT_LOCAL_SUBSTRATE_DEGRADATION = {
154
+ if_unavailable: 'sign locally in producer and continue without coordinator receipt',
155
+ primary_path_blocking: false,
156
+ };
157
+ const DEFAULT_LOCAL_SUBSTRATE_WATCHER_DEGRADATION = {
158
+ if_unavailable: 'sign locally in producer and write the WAL receipt through the existing drain',
159
+ primary_path_blocking: false,
160
+ };
161
+ const DEFAULT_LOCAL_SUBSTRATE_COMMIT_DEGRADATION = {
162
+ if_unavailable: 'sign locally in producer and continue without coordinator receipt',
163
+ primary_path_blocking: false,
164
+ };
165
+ function buildWriteToolDeps(options) {
166
+ const logEndpoint = options.logEndpoint ?? process.env['ATRIB_LOG_ENDPOINT'];
167
+ return {
168
+ resolveServerKey: createServerKeyResolver(options),
169
+ queue: createSubmissionQueue(logEndpoint),
170
+ logEndpoint,
171
+ options,
172
+ };
173
+ }
174
+ /** Register the legacy `emit` tool (polymorphic event_type surface). */
175
+ export function registerEmitTool(mcp, deps) {
176
+ mcp.registerTool('emit', {
177
+ description: 'Sign and submit an explicit cognitive event (observation, annotation, revision, etc.) under your atrib identity. Emits a record that chains with your wrapper-signed tool calls when context_id is shared. Legacy alias: new callers should prefer the `attest` tool; records are byte-identical either way.',
178
+ inputSchema: EmitInput.shape,
179
+ }, async (rawInput) => {
180
+ const input = EmitInput.parse(rawInput);
181
+ const result = await handleEmit({
182
+ input,
183
+ key: await deps.resolveServerKey(),
184
+ queue: deps.queue,
185
+ logEndpoint: deps.logEndpoint,
186
+ recordReferenceResolver: deps.options.recordReferenceResolver,
187
+ localSubstrate: resolveLocalSubstrateOption(deps.options.localSubstrate, {
188
+ producer: 'atrib-emit',
189
+ transport: 'stdio-mcp-server',
190
+ waitForAttempt: false,
191
+ }),
192
+ localSubstrateCommit: resolveLocalSubstrateCommitOption(deps.options.localSubstrateCommit !== undefined
193
+ ? deps.options.localSubstrateCommit
194
+ : deps.options.localSubstrate !== undefined
195
+ ? false
196
+ : undefined, {
197
+ producer: 'atrib-emit',
198
+ transport: 'stdio-mcp-server',
199
+ }),
200
+ });
201
+ if (!result.signed) {
202
+ return emitRefusalToolResult(result);
203
+ }
204
+ return emitSuccessToolResult(result);
205
+ });
206
+ }
207
+ /**
208
+ * Register the `attest` tool: the two-verb surface's write half. Maps the
209
+ * declared relationship (`ref`) onto the legacy EmitInput shape and
210
+ * delegates to the same handleEmit funnel, so attest-signed records are
211
+ * byte-identical in canonical form to legacy-name-signed records.
212
+ */
213
+ export function registerAttestTool(mcp, deps) {
214
+ mcp.registerTool('attest', {
215
+ description: 'Make a signed statement now: atrib\'s write verb. Signs an observation by default; ' +
216
+ 'declare ref: { kind: "annotates", target } to mark a past record\'s importance, or ' +
217
+ 'ref: { kind: "revises", target, reason } to supersede a prior position. One handler ' +
218
+ 'behind the legacy emit / atrib-annotate / atrib-revise names; records are ' +
219
+ 'byte-identical in canonical form regardless of which name signed them.',
220
+ inputSchema: AttestInput.shape,
221
+ }, async (rawInput) => {
222
+ const input = AttestInput.parse(rawInput);
223
+ const mapped = mapAttestInput(input);
224
+ if (isAttestMappingRefusal(mapped)) {
225
+ return {
226
+ isError: true,
227
+ content: [{ type: 'text', text: mapped.refusals.join('\n') }],
228
+ };
229
+ }
230
+ const producer = input.producer ?? 'atrib-attest';
231
+ const result = await handleEmit({
232
+ input: mapped.emitInput,
233
+ key: await deps.resolveServerKey(),
234
+ queue: deps.queue,
235
+ producer,
236
+ logEndpoint: deps.logEndpoint,
237
+ recordReferenceResolver: deps.options.recordReferenceResolver,
238
+ localSubstrate: resolveLocalSubstrateOption(deps.options.localSubstrate, {
239
+ producer,
240
+ transport: 'stdio-mcp-server',
241
+ waitForAttempt: false,
242
+ }),
243
+ localSubstrateCommit: resolveLocalSubstrateCommitOption(deps.options.localSubstrateCommit !== undefined
244
+ ? deps.options.localSubstrateCommit
245
+ : deps.options.localSubstrate !== undefined
246
+ ? false
247
+ : undefined, {
248
+ producer,
249
+ transport: 'stdio-mcp-server',
250
+ }),
251
+ });
252
+ if (!result.signed) {
253
+ return emitRefusalToolResult(result);
254
+ }
255
+ return {
256
+ content: [
257
+ {
258
+ type: 'text',
259
+ text: JSON.stringify({ ...result, event_type: mapped.event_type }, null, 2),
260
+ },
261
+ ],
262
+ };
263
+ });
264
+ }
265
+ /**
266
+ * Wire up the write-verb union server: `attest` plus the three legacy
267
+ * write names, all dispatching to one handleEmit funnel over one shared
268
+ * key resolver + submission queue. This is what the `atrib-attest` binary
269
+ * serves and what the primitive runtime / daemon mount for writes.
270
+ */
271
+ export async function createAtribAttestServer(options = {}) {
272
+ const deps = buildWriteToolDeps(options);
273
+ const mcp = new McpServer({ name: 'atrib-attest', version: '0.1.0' });
274
+ registerAttestTool(mcp, deps);
275
+ registerEmitTool(mcp, deps);
276
+ registerAnnotateTool(mcp, deps);
277
+ registerReviseTool(mcp, deps);
278
+ return {
279
+ mcp,
280
+ flush: () => deps.queue.flush(),
281
+ };
282
+ }
283
+ /**
284
+ * Wire up the legacy atrib-emit MCP server. Mounts `emit` plus `attest`
285
+ * (alias-window rule W1: every existing server also serves the new verb).
286
+ * Returns an AtribEmitServer handle whose `.mcp` is ready to attach to a
287
+ * transport (StdioServerTransport for the standalone binary; in-process
288
+ * transport for tests).
289
+ */
290
+ export async function createAtribEmitServer(options = {}) {
291
+ const deps = buildWriteToolDeps(options);
292
+ const mcp = new McpServer({ name: 'atrib-emit', version: '0.1.0' });
293
+ registerEmitTool(mcp, deps);
294
+ registerAttestTool(mcp, deps);
295
+ return {
296
+ mcp,
297
+ flush: () => deps.queue.flush(),
298
+ };
299
+ }
300
+ function createServerKeyResolver(options) {
301
+ if (Object.prototype.hasOwnProperty.call(options, 'key')) {
302
+ const fixed = options.key ?? null;
303
+ return async () => fixed;
304
+ }
305
+ let resolved = null;
306
+ let inFlight = null;
307
+ let lastMissAt = 0;
308
+ return async () => {
309
+ if (resolved)
310
+ return resolved;
311
+ const now = Date.now();
312
+ if (lastMissAt > 0 && now - lastMissAt < keyResolveRetryMs())
313
+ return null;
314
+ inFlight ??= resolveKey()
315
+ .then((key) => {
316
+ if (key) {
317
+ resolved = key;
318
+ }
319
+ else {
320
+ lastMissAt = Date.now();
321
+ }
322
+ return key;
323
+ })
324
+ .finally(() => {
325
+ inFlight = null;
326
+ });
327
+ return inFlight;
328
+ };
329
+ }
330
+ /**
331
+ * Build, sign, submit, mirror. Refused writes return `signed: false`;
332
+ * signed degradations stay `signed: true` and surface in `warnings`.
333
+ */
334
+ async function handleEmit({ input, key, queue, producer, logEndpoint, recordReferenceResolver, localSubstrate, localSubstrateCommit, }) {
335
+ const warnings = [];
336
+ const eventType = normalizeEventType(input.event_type);
337
+ if (!isValidEventTypeUri(eventType)) {
338
+ return refusalOutput(input.context_id ?? randomContextId(), [
339
+ `event_type is not a valid absolute URI per §1.4.5: ${input.event_type}`,
340
+ ]);
341
+ }
342
+ if (!key) {
343
+ return refusalOutput(input.context_id ?? randomContextId(), [
344
+ 'no signing key resolved (set ATRIB_PRIVATE_KEY, ATRIB_KEY_FILE, or store seed in macOS Keychain as service "atrib-creator")',
345
+ ]);
346
+ }
347
+ // chain_root without context_id is malformed: chain_root is meaningless
348
+ // outside the context it chains within. Refuse instead of synthesizing one
349
+ // of the two halves.
350
+ if (input.chain_root && !input.context_id) {
351
+ return refusalOutput(input.context_id ?? randomContextId(), [
352
+ 'chain_root requires context_id (chain_root has no meaning without a context to chain within)',
353
+ ]);
354
+ }
355
+ // provenance_token is genesis-record-only per spec §1.2.6. If the caller
356
+ // also supplied chain_root, that chain_root must equal the genesis
357
+ // chain_root for the context_id. Middleware refuses to sign malformed
358
+ // records per §5.8 rather than emit something the validator + verifier
359
+ // would reject.
360
+ if (input.provenance_token && input.chain_root && input.context_id) {
361
+ const genesisRoot = genesisChainRoot(input.context_id);
362
+ if (input.chain_root !== genesisRoot) {
363
+ return refusalOutput(input.context_id, [
364
+ 'provenance_token is genesis-record-only per §1.2.6; ' +
365
+ 'chain_root must equal genesisChainRoot(context_id) when provenance_token is supplied',
366
+ ]);
367
+ }
368
+ }
369
+ // annotates require/forbid invariant per spec §1.2.7 / D058. Validators MUST
370
+ // reject violations; we surface a signed:false refusal so callers see why
371
+ // we refused to sign rather than getting back a malformed record. Use the
372
+ // @atrib/mcp normative constant so the URI string lives in one place.
373
+ if (eventType === EVENT_TYPE_ANNOTATION_URI && !input.annotates) {
374
+ return refusalOutput(input.context_id ?? randomContextId(), [
375
+ 'annotation event_type requires annotates per §1.2.7 (D058); ' +
376
+ 'omitted records would fail validator admission',
377
+ ]);
378
+ }
379
+ if (input.annotates && eventType !== EVENT_TYPE_ANNOTATION_URI) {
380
+ return refusalOutput(input.context_id ?? randomContextId(), [
381
+ 'annotates is FORBIDDEN on non-annotation event_types per §1.2.7 (D058); ' +
382
+ `received event_type=${input.event_type}`,
383
+ ]);
384
+ }
385
+ // revises require/forbid invariant per spec §1.2.9 / D059. Same shape as
386
+ // the annotates invariant above. Validators MUST reject violations; we
387
+ // surface a signed:false refusal so callers see why we refused to sign
388
+ // rather than getting back a malformed record.
389
+ if (eventType === EVENT_TYPE_REVISION_URI && !input.revises) {
390
+ return refusalOutput(input.context_id ?? randomContextId(), [
391
+ 'revision event_type requires revises per §1.2.9 (D059); ' +
392
+ 'omitted records would fail validator admission',
393
+ ]);
394
+ }
395
+ if (input.revises && eventType !== EVENT_TYPE_REVISION_URI) {
396
+ return refusalOutput(input.context_id ?? randomContextId(), [
397
+ 'revises is FORBIDDEN on non-revision event_types per §1.2.9 (D059); ' +
398
+ `received event_type=${input.event_type}`,
399
+ ]);
400
+ }
401
+ // Env-var context_id default: when the caller omitted context_id, fall back
402
+ // to @atrib/mcp's resolveEnvContextId, which applies the D078 + D083
403
+ // precedence: ATRIB_CONTEXT_ID first (explicit operator/harness intent),
404
+ // then a registered harness env var (e.g. CLAUDE_CODE_SESSION_ID). Both
405
+ // produce a validated 32-hex string or undefined; invalid values silently
406
+ // fall through to the existing inheritChainContext logic. Explicit
407
+ // input.context_id still wins per "explicit beats implicit."
408
+ const callerContextId = input.context_id ?? resolveEnvContextId();
409
+ if (!callerContextId && requiresExplicitContextId()) {
410
+ return refusalOutput(input.context_id ?? randomContextId(), [
411
+ 'context_id is required by ATRIB_REQUIRE_EXPLICIT_CONTEXT_ID; no record signed',
412
+ ]);
413
+ }
414
+ // Multi-producer chain composition per spec §1.2.3 / D067. Single source
415
+ // of truth in @atrib/mcp's inheritChainContext: caller-supplied verbatim
416
+ // when both fields are supplied, else cascade through env-tail
417
+ // (cross-producer handoff) and mirror-file inheritance filtered to the same
418
+ // context_id, falling back to genesis. When caller omits context_id entirely,
419
+ // D072 synthesizes a fresh orphan context instead of inheriting another
420
+ // session from the mirror tail. The cognitive-extractor hook spawning
421
+ // atrib-emit with ATRIB_CHAIN_TAIL_<context_id> plus the agent's context_id
422
+ // is the primary case that needs preserving; pre-fix this produced isolated
423
+ // genesis records because atrib-emit's local resolver short-circuited on
424
+ // caller context.
425
+ const chain = await inheritChainContext({
426
+ callerContextId,
427
+ callerChainRoot: input.chain_root,
428
+ mirrorPath: readMirrorPath(),
429
+ randomContextId,
430
+ });
431
+ const contextId = chain.contextId;
432
+ const chainRoot = chain.chainRoot;
433
+ if (chain.inheritedFrom === 'fresh-orphan') {
434
+ // Per D072: caller passed no context_id, so the producer synthesized
435
+ // a fresh isolate rather than inheriting from the mirror tail. Surface
436
+ // this as a warning so operators can trace the runtime miswire that
437
+ // caused it (typically a Layer-2 hook that didn't thread session_id).
438
+ warnings.push(`synthesized orphan context_id ${contextId} (caller passed no context_id; fix runtime to thread session_id per D072)`);
439
+ }
440
+ // ATRIB_PARENT_RECORD_HASH env-var seeding (D104/D115/D116, producer-side
441
+ // parent-child causality threading). When a parent producer spawns a child
442
+ // producer (multi-process subagent, cross-process delegate, framework worker
443
+ // node, etc.) and writes its parent's record_hash into this env, each emit
444
+ // call auto-prepends it to informed_by. Uses the existing §1.2.5
445
+ // primitive, no spec change. Only valid sha256:<64-hex> values are honored;
446
+ // anything else is silently ignored. Caller-passed informed_by entries take
447
+ // precedence in ordering (env-seed prepends, dedupe preserves first occurrence);
448
+ // sign.ts then sorts lexicographically per §1.2.5 before the canonical-bytes
449
+ // hash so the wire-level shape is order-independent. Limitations: single-
450
+ // process hosts where parent and child share env (e.g., Claude Code's Task
451
+ // tool) cannot use this convention naively because the parent's PostToolUse
452
+ // signature fires after the child has already emitted; those cases need
453
+ // retroactive annotation or a future explicit handoff event. See D104.
454
+ const validParentHash = parentRecordHashFromEnv();
455
+ const resolvedInputInformedBy = await filterResolvableInformedBy(input.informed_by, {
456
+ allowUnresolved: input.allow_unresolved_informed_by,
457
+ resolver: recordReferenceResolver,
458
+ logEndpoint,
459
+ warnings,
460
+ });
461
+ const effectiveInformedBy = validParentHash
462
+ ? Array.from(new Set([validParentHash, ...(resolvedInputInformedBy ?? [])]))
463
+ : resolvedInputInformedBy;
464
+ let record;
465
+ try {
466
+ record = await buildAndSignEmitRecord({
467
+ privateKey: key.privateKey,
468
+ eventType,
469
+ contextId,
470
+ chainRoot,
471
+ content: input.content,
472
+ informedBy: effectiveInformedBy,
473
+ provenanceToken: input.provenance_token,
474
+ annotates: input.annotates,
475
+ revises: input.revises,
476
+ toolName: input.tool_name,
477
+ argsHash: input.args_hash,
478
+ resultHash: input.result_hash,
479
+ });
480
+ }
481
+ catch (e) {
482
+ return refusalOutput(contextId, [`signing failed: ${e instanceof Error ? e.message : String(e)}`]);
483
+ }
484
+ const recordHash = hashRecord(record);
485
+ const unsignedRecordBody = unsignedRecordBodyFromSigned(record);
486
+ let localSubstrateShadow;
487
+ let localSubstrateCommitted = false;
488
+ let localSubstrateReceiptId;
489
+ if (localSubstrate) {
490
+ localSubstrateShadow = dispatchEmitLocalSubstrateShadow({
491
+ localSubstrate,
492
+ recordBody: unsignedRecordBody,
493
+ expectedRecordHash: recordHash,
494
+ contextId,
495
+ chainRoot,
496
+ parentRecordHash: validParentHash,
497
+ producerLabel: producer ?? 'atrib-emit',
498
+ });
499
+ }
500
+ if (localSubstrateCommit) {
501
+ const commit = await dispatchEmitLocalSubstrateCommit({
502
+ localSubstrate: localSubstrateCommit,
503
+ recordBody: unsignedRecordBody,
504
+ expectedRecordHash: recordHash,
505
+ contextId,
506
+ chainRoot,
507
+ parentRecordHash: validParentHash,
508
+ producerLabel: producer ?? 'atrib-emit',
509
+ });
510
+ warnings.push(...commit.warnings);
511
+ if (commit.accepted) {
512
+ localSubstrateCommitted = true;
513
+ localSubstrateReceiptId = commit.receiptId;
514
+ }
515
+ }
516
+ // Submit asynchronously; the queue handles retry + degradation per §5.8.
517
+ // Cognitive events default to normal priority, annotations/observations
518
+ // never need to block the agent.
519
+ if (!localSubstrateCommitted) {
520
+ queue.submit(record, 'normal');
521
+ }
522
+ // Best-effort mirror; mirrorRecord internally swallows errors per §5.8.
523
+ // Persist the pre-sign `content` payload as a `_local` sidecar so
524
+ // consumers (recall, trace, summarize) can surface semantic context
525
+ // alongside the cryptographic evidence. The sidecar lives at the
526
+ // envelope level, the signed record bytes are unchanged.
527
+ await mirrorRecord(record, getProofFor(queue, recordHash) ?? null, {
528
+ content: input.content,
529
+ producer: producer ?? 'atrib-emit',
530
+ });
531
+ // Try to read a proof if the queue submitted synchronously and the log
532
+ // returned one within the same tick. Most submissions return null here
533
+ // and the proof shows up on a later poll via getProof.
534
+ const proof = getProofFor(queue, recordHash) ?? null;
535
+ if (!proof && localSubstrateCommitted) {
536
+ warnings.push('submission delegated to local substrate coordinator; proof not available in this process');
537
+ }
538
+ else if (!proof) {
539
+ warnings.push('submission queued; proof not yet available (poll the log later if needed)');
540
+ }
541
+ if (localSubstrate?.waitForAttempt && localSubstrateShadow) {
542
+ await localSubstrateShadow;
543
+ }
544
+ return {
545
+ signed: true,
546
+ record_hash: recordHash,
547
+ log_index: proof?.log_index ?? null,
548
+ inclusion_proof: proof?.inclusion_proof ?? null,
549
+ context_id: contextId,
550
+ ...(localSubstrateReceiptId !== undefined ? { receipt_id: localSubstrateReceiptId } : {}),
551
+ warnings,
552
+ };
553
+ }
554
+ function refusalOutput(contextId, refusals) {
555
+ return {
556
+ signed: false,
557
+ context_id: contextId,
558
+ refusals,
559
+ };
560
+ }
561
+ function contextIdFromRawInput(rawInput) {
562
+ if (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)) {
563
+ const contextId = rawInput['context_id'];
564
+ if (typeof contextId === 'string' && HEX_32_PATTERN.test(contextId)) {
565
+ return contextId;
566
+ }
567
+ }
568
+ return randomContextId();
569
+ }
570
+ function zodRefusals(error) {
571
+ return error.issues.map((issue) => {
572
+ const path = issue.path.length ? `${issue.path.join('.')}: ` : '';
573
+ return `${path}${issue.message}`;
574
+ });
575
+ }
576
+ function emitRefusalToolResult(result) {
577
+ return {
578
+ isError: true,
579
+ content: [{ type: 'text', text: result.refusals.join('\n') }],
580
+ };
581
+ }
582
+ function emitSuccessToolResult(result) {
583
+ return {
584
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
585
+ };
586
+ }
587
+ function randomContextId() {
588
+ // 16 random bytes → 32 hex chars; matches the spec's context_id format.
589
+ return randomBytes(16).toString('hex');
590
+ }
591
+ function hashRecord(record) {
592
+ return `sha256:${hexEncode(sha256(canonicalRecord(record)))}`;
593
+ }
594
+ function unsignedRecordBodyFromSigned(record) {
595
+ const { signature: _signature, ...body } = record;
596
+ return body;
597
+ }
598
+ function localSubstrateProducer(producerLabel, options) {
599
+ return {
600
+ name: producerLabel,
601
+ harness_class: options?.harnessClass ?? 'long-lived-agent',
602
+ pid: process.pid,
603
+ transport: options?.transport ?? 'emit-in-process',
604
+ creator_key_policy: 'explicit-single-creator',
605
+ };
606
+ }
607
+ function watcherWalLocalSubstrateProducer(producerLabel, options) {
608
+ return {
609
+ name: producerLabel,
610
+ harness_class: 'watcher-wal',
611
+ pid: process.pid,
612
+ transport: options?.transport ?? 'emit-in-process-wal',
613
+ creator_key_policy: 'explicit-watcher-creator',
614
+ };
615
+ }
616
+ function parsePositiveInt(value) {
617
+ if (value === undefined || value.trim().length === 0)
618
+ return undefined;
619
+ const n = Number(value);
620
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
621
+ }
622
+ export function resolveEmitLocalSubstrateShadowFromEnv(options = {}) {
623
+ const env = options.env ?? process.env;
624
+ const endpoint = env['ATRIB_LOCAL_SUBSTRATE_ENDPOINT'];
625
+ if (!endpoint)
626
+ return undefined;
627
+ const mode = env['ATRIB_LOCAL_SUBSTRATE_MODE'] ?? 'shadow';
628
+ if (mode === 'off' || mode === 'disabled' || mode === 'false')
629
+ return undefined;
630
+ if (mode !== 'shadow')
631
+ return undefined;
632
+ const timeoutMs = parsePositiveInt(env['ATRIB_LOCAL_SUBSTRATE_TIMEOUT_MS']);
633
+ return {
634
+ transport: createHttpLocalSubstrateTransport(endpoint, {
635
+ ...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
636
+ }),
637
+ producer: localSubstrateProducer(options.producer ?? 'atrib-emit', {
638
+ harnessClass: options.harnessClass,
639
+ transport: options.transport,
640
+ }),
641
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
642
+ ...(options.waitForAttempt !== undefined ? { waitForAttempt: options.waitForAttempt } : {}),
643
+ };
644
+ }
645
+ export function resolveEmitLocalSubstrateCommitFromEnv(options = {}) {
646
+ const env = options.env ?? process.env;
647
+ const endpoint = env['ATRIB_LOCAL_SUBSTRATE_ENDPOINT'];
648
+ if (!endpoint)
649
+ return undefined;
650
+ const mode = env['ATRIB_LOCAL_SUBSTRATE_MODE'];
651
+ if (mode !== 'commit')
652
+ return undefined;
653
+ const timeoutMs = parsePositiveInt(env['ATRIB_LOCAL_SUBSTRATE_TIMEOUT_MS']);
654
+ return {
655
+ transport: createHttpLocalSubstrateTransport(endpoint, {
656
+ ...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
657
+ }),
658
+ producer: localSubstrateProducer(options.producer ?? 'atrib-emit', {
659
+ harnessClass: options.harnessClass,
660
+ transport: options.transport,
661
+ }),
662
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
663
+ };
664
+ }
665
+ function resolveLocalSubstrateOption(option, defaults) {
666
+ if (option === false)
667
+ return undefined;
668
+ if (option) {
669
+ return {
670
+ ...option,
671
+ waitForAttempt: option.waitForAttempt ?? defaults.waitForAttempt,
672
+ producer: option.producer ??
673
+ localSubstrateProducer(defaults.producer, {
674
+ transport: defaults.transport,
675
+ harnessClass: defaults.harnessClass,
676
+ }),
677
+ };
678
+ }
679
+ return resolveEmitLocalSubstrateShadowFromEnv(defaults);
680
+ }
681
+ function resolveLocalSubstrateCommitOption(option, defaults) {
682
+ if (option === false)
683
+ return undefined;
684
+ if (option) {
685
+ return {
686
+ ...option,
687
+ producer: option.producer ??
688
+ (option.wal
689
+ ? watcherWalLocalSubstrateProducer(defaults.producer, {
690
+ transport: defaults.transport,
691
+ })
692
+ : localSubstrateProducer(defaults.producer, {
693
+ transport: defaults.transport,
694
+ harnessClass: defaults.harnessClass,
695
+ })),
696
+ };
697
+ }
698
+ return resolveEmitLocalSubstrateCommitFromEnv(defaults);
699
+ }
700
+ function dispatchEmitLocalSubstrateShadow(input) {
701
+ const producer = input.localSubstrate.producer ??
702
+ localSubstrateProducer(input.producerLabel, { transport: 'emit-in-process' });
703
+ const request = {
704
+ schema: LOCAL_SUBSTRATE_REQUEST_SCHEMA,
705
+ operation: 'sign_record',
706
+ mode: 'shadow_probe',
707
+ producer,
708
+ context: {
709
+ source: '@atrib/emit',
710
+ context_id: input.contextId,
711
+ chain_tail: input.chainRoot,
712
+ ...(input.parentRecordHash !== undefined
713
+ ? { parent_record_hash: input.parentRecordHash }
714
+ : {}),
715
+ },
716
+ record_body: input.recordBody,
717
+ degradation: input.localSubstrate.fallback ?? DEFAULT_LOCAL_SUBSTRATE_DEGRADATION,
718
+ };
719
+ return tryLocalSubstrateCoordinator(request, {
720
+ transport: input.localSubstrate.transport,
721
+ timeoutMs: input.localSubstrate.timeoutMs ?? LOCAL_SUBSTRATE_DEFAULT_TIMEOUT_MS,
722
+ expectedHarnessClass: producer.harness_class,
723
+ directRecordBody: input.recordBody,
724
+ })
725
+ .then((result) => {
726
+ const responseRecordHash = result.ok || result.status === 'rejected' ? result.response?.record_hash : undefined;
727
+ const recordHashMatches = responseRecordHash === input.expectedRecordHash;
728
+ if (result.ok && !recordHashMatches) {
729
+ notifyLocalSubstrateWarning(input.localSubstrate, 'local substrate shadow record_hash mismatch', {
730
+ expected_record_hash: input.expectedRecordHash,
731
+ response_record_hash: responseRecordHash ?? null,
732
+ });
733
+ }
734
+ if (input.localSubstrate.onAttempt) {
735
+ try {
736
+ const observed = input.localSubstrate.onAttempt({
737
+ result,
738
+ expectedRecordHash: input.expectedRecordHash,
739
+ ...(responseRecordHash !== undefined ? { responseRecordHash } : {}),
740
+ recordHashMatches,
741
+ });
742
+ void Promise.resolve(observed).catch((error) => {
743
+ notifyLocalSubstrateWarning(input.localSubstrate, 'local substrate shadow observer rejected', error);
744
+ });
745
+ }
746
+ catch (error) {
747
+ notifyLocalSubstrateWarning(input.localSubstrate, 'local substrate shadow observer threw', error);
748
+ }
749
+ }
750
+ })
751
+ .catch((error) => {
752
+ notifyLocalSubstrateWarning(input.localSubstrate, 'local substrate shadow probe failed unexpectedly', error);
753
+ });
754
+ }
755
+ async function dispatchEmitLocalSubstrateCommit(input) {
756
+ const warnings = [];
757
+ const wal = input.localSubstrate.wal;
758
+ const producer = input.localSubstrate.producer ??
759
+ (wal
760
+ ? watcherWalLocalSubstrateProducer(input.producerLabel, { transport: 'emit-in-process-wal' })
761
+ : localSubstrateProducer(input.producerLabel, { transport: 'emit-in-process' }));
762
+ const request = {
763
+ schema: LOCAL_SUBSTRATE_REQUEST_SCHEMA,
764
+ operation: wal ? 'enqueue_record_and_join_receipt' : 'sign_record',
765
+ mode: wal ? undefined : 'commit',
766
+ producer,
767
+ context: {
768
+ source: '@atrib/emit',
769
+ context_id: input.contextId,
770
+ chain_tail: input.chainRoot,
771
+ ...(input.parentRecordHash !== undefined
772
+ ? { parent_record_hash: input.parentRecordHash }
773
+ : {}),
774
+ ...(wal ? { join_back_target: wal.join_back_target } : {}),
775
+ },
776
+ record_body: input.recordBody,
777
+ ...(wal
778
+ ? {
779
+ wal: {
780
+ entry_id: wal.entry_id,
781
+ source_path: wal.source_path,
782
+ receipt_join_field: wal.receipt_join_field,
783
+ },
784
+ }
785
+ : {}),
786
+ degradation: input.localSubstrate.fallback ??
787
+ (wal
788
+ ? DEFAULT_LOCAL_SUBSTRATE_WATCHER_DEGRADATION
789
+ : DEFAULT_LOCAL_SUBSTRATE_COMMIT_DEGRADATION),
790
+ };
791
+ const result = await tryLocalSubstrateCoordinator(request, {
792
+ transport: input.localSubstrate.transport,
793
+ timeoutMs: input.localSubstrate.timeoutMs ?? LOCAL_SUBSTRATE_DEFAULT_TIMEOUT_MS,
794
+ expectedHarnessClass: producer.harness_class,
795
+ directRecordBody: input.recordBody,
796
+ });
797
+ const responseRecordHash = result.ok || result.status === 'rejected' ? result.response?.record_hash : undefined;
798
+ const recordHashMatches = responseRecordHash === input.expectedRecordHash;
799
+ if (input.localSubstrate.onAttempt) {
800
+ try {
801
+ const observed = input.localSubstrate.onAttempt({
802
+ result,
803
+ expectedRecordHash: input.expectedRecordHash,
804
+ ...(responseRecordHash !== undefined ? { responseRecordHash } : {}),
805
+ recordHashMatches,
806
+ });
807
+ await Promise.resolve(observed);
808
+ }
809
+ catch (error) {
810
+ notifyLocalSubstrateCommitWarning(input.localSubstrate, 'local substrate commit observer threw', error);
811
+ }
812
+ }
813
+ if (!result.ok) {
814
+ const reason = result.status === 'rejected'
815
+ ? (result.reason ?? 'rejected')
816
+ : result.status === 'unavailable'
817
+ ? result.reason
818
+ : result.issues.map((issue) => `${issue.path} ${issue.message}`).join('; ');
819
+ warnings.push(`local substrate ${wal ? 'watcher-WAL' : 'emit'} commit failed (${result.status}: ${reason}); signed locally`);
820
+ return { accepted: false, warnings };
821
+ }
822
+ if (!recordHashMatches) {
823
+ notifyLocalSubstrateCommitWarning(input.localSubstrate, 'local substrate commit record_hash mismatch', {
824
+ expected_record_hash: input.expectedRecordHash,
825
+ response_record_hash: responseRecordHash ?? null,
826
+ });
827
+ warnings.push(`local substrate ${wal ? 'watcher-WAL' : 'emit'} commit record_hash mismatch; signed locally`);
828
+ return { accepted: false, warnings };
829
+ }
830
+ return {
831
+ accepted: true,
832
+ ...(result.response.receipt_id !== undefined ? { receiptId: result.response.receipt_id } : {}),
833
+ warnings,
834
+ };
835
+ }
836
+ function notifyLocalSubstrateCommitWarning(localSubstrate, message, detail) {
837
+ if (!localSubstrate.onWarning)
838
+ return;
839
+ try {
840
+ localSubstrate.onWarning(`atrib-emit: ${message}`, detail);
841
+ }
842
+ catch {
843
+ // Warning observers must not affect the signed emit path.
844
+ }
845
+ }
846
+ function notifyLocalSubstrateWarning(localSubstrate, message, detail) {
847
+ if (!localSubstrate.onWarning)
848
+ return;
849
+ try {
850
+ localSubstrate.onWarning(`atrib-emit: ${message}`, detail);
851
+ }
852
+ catch {
853
+ // Warning observers must not affect the signed emit path.
854
+ }
855
+ }
856
+ /**
857
+ * `@atrib/mcp`'s submission queue caches proofs by *bare hex*, while
858
+ * everywhere else in atrib uses the spec §1.4.2 `sha256:<64-hex>` form.
859
+ * Strip the prefix when querying the cache. Without this bridge, every
860
+ * proof lookup returned undefined: handleEmit and emitInProcess both
861
+ * always reported `log_index: null` and a misleading "submission queued"
862
+ * warning, even when the record had already landed on the log.
863
+ */
864
+ function getProofFor(queue, recordHash) {
865
+ return queue.getProof(recordHash.startsWith('sha256:') ? recordHash.slice('sha256:'.length) : recordHash);
866
+ }
867
+ const DEFAULT_FLUSH_DEADLINE_MS = 5000;
868
+ /**
869
+ * Emit one cognitive event in-process, without an MCP transport.
870
+ *
871
+ * This is the canonical in-process entrypoint for callers that already
872
+ * run inside a short-lived Node process (lifecycle/PostToolUse hooks,
873
+ * watchers, batch jobs) and should NOT pay the cost of spawning the
874
+ * atrib-emit binary and running an MCP stdio handshake just to sign one
875
+ * record. It packages the recipe the D079 public-helpers block below
876
+ * documents: resolve key, build a submission queue, call handleEmit,
877
+ * and additionally flushes the queue before returning, because a hook
878
+ * process exits immediately afterward and a still-pending submission
879
+ * would be lost with it.
880
+ *
881
+ * Records are byte-identical to MCP-server-signed and wrapper-signed
882
+ * records: this routes through the same handleEmit path createAtribEmitServer
883
+ * uses. Refused writes return `signed: false`; signed-but-degraded
884
+ * submissions surface in EmitOutput.warnings.
885
+ *
886
+ * The flush is bounded by `flushDeadlineMs` (default 5s). If the log is
887
+ * unreachable, the submission queue's internal retry will overrun the
888
+ * deadline; emitInProcess then returns the record with a `flush-deadline`
889
+ * warning rather than blocking up to 30s. The record is still signed and
890
+ * mirrored locally; only the log-side confirmation is uncertain.
891
+ */
892
+ export async function emitInProcess(rawInput, options = {}) {
893
+ const parsed = EmitInput.safeParse(rawInput);
894
+ if (!parsed.success) {
895
+ return refusalOutput(contextIdFromRawInput(rawInput), zodRefusals(parsed.error));
896
+ }
897
+ const input = parsed.data;
898
+ const key = options.key ?? (await resolveKey());
899
+ const logEndpoint = options.logEndpoint ?? process.env['ATRIB_LOG_ENDPOINT'];
900
+ const flushDeadlineMs = options.flushDeadlineMs ?? DEFAULT_FLUSH_DEADLINE_MS;
901
+ const queue = createSubmissionQueue(logEndpoint);
902
+ const result = await handleEmit({
903
+ input,
904
+ key,
905
+ queue,
906
+ producer: options.producer,
907
+ logEndpoint,
908
+ recordReferenceResolver: options.recordReferenceResolver,
909
+ localSubstrate: resolveLocalSubstrateOption(options.localSubstrate, {
910
+ producer: options.producer ?? 'atrib-emit',
911
+ transport: 'emit-in-process',
912
+ waitForAttempt: true,
913
+ }),
914
+ localSubstrateCommit: resolveLocalSubstrateCommitOption(options.localSubstrateCommit !== undefined
915
+ ? options.localSubstrateCommit
916
+ : options.localSubstrate !== undefined
917
+ ? false
918
+ : undefined, {
919
+ producer: options.producer ?? 'atrib-emit',
920
+ transport: 'emit-in-process',
921
+ }),
922
+ });
923
+ if (!result.signed) {
924
+ return result;
925
+ }
926
+ // Drain before returning, bounded by flushDeadlineMs. The typical caller
927
+ // is a detached hook process that exits right after this resolves; we
928
+ // don't want the queue's 30s retry budget on an unreachable log to
929
+ // stall that process. Racing the flush against a timeout lets the
930
+ // caller fall through with a warning instead.
931
+ const flushed = await Promise.race([
932
+ queue.flush().then(() => true),
933
+ new Promise((resolve) => setTimeout(() => resolve(false), flushDeadlineMs)),
934
+ ]);
935
+ if (!flushed) {
936
+ result.warnings.push(`flush exceeded ${flushDeadlineMs}ms deadline; record signed and mirrored locally, log submission may still be in flight`);
937
+ return result;
938
+ }
939
+ // Flush completed within the deadline. handleEmit had to read the proof
940
+ // synchronously, before the submission promise could resolve, so its
941
+ // result carries `log_index: null` and a "submission queued; proof not
942
+ // yet available" warning. After flush the proof is in the queue's cache;
943
+ // re-read it and patch the result so callers see the proof they would
944
+ // get if they queried the log directly. The warning becomes misleading
945
+ // (the submission DID complete), so drop it.
946
+ const proof = getProofFor(queue, result.record_hash);
947
+ if (proof) {
948
+ result.log_index = proof.log_index;
949
+ result.inclusion_proof = proof.inclusion_proof;
950
+ result.warnings = result.warnings.filter((w) => !w.startsWith('submission queued; proof not yet available'));
951
+ }
952
+ return result;
953
+ }
954
+ /**
955
+ * The attest handler: map the declared relationship onto the legacy
956
+ * EmitInput shape, then delegate to handleEmit. Byte-identity with the
957
+ * legacy write names holds by construction (one funnel, one signer).
958
+ */
959
+ export async function handleAttest(args) {
960
+ const mapped = mapAttestInput(args.input);
961
+ if (isAttestMappingRefusal(mapped)) {
962
+ return refusalOutput(args.input.context_id ?? randomContextId(), mapped.refusals);
963
+ }
964
+ const result = await handleEmit({
965
+ input: mapped.emitInput,
966
+ key: args.key,
967
+ queue: args.queue,
968
+ producer: args.input.producer ?? args.producer ?? 'atrib-attest',
969
+ logEndpoint: args.logEndpoint,
970
+ recordReferenceResolver: args.recordReferenceResolver,
971
+ localSubstrate: args.localSubstrate,
972
+ localSubstrateCommit: args.localSubstrateCommit,
973
+ });
974
+ if (!result.signed)
975
+ return result;
976
+ return { ...result, event_type: mapped.event_type };
977
+ }
978
+ /**
979
+ * Attest one cognitive event in-process, without an MCP transport. The
980
+ * write-verb sibling of emitInProcess: same flush deadline, same
981
+ * degradation posture, same mirror conventions. Default sidecar producer
982
+ * label is 'atrib-attest' ('atrib-attest-cli' when spawned via the CLI
983
+ * binary); the input-level `producer` override wins per §5.9.3.
984
+ */
985
+ export async function attestInProcess(rawInput, options = {}) {
986
+ const parsed = AttestInput.safeParse(rawInput);
987
+ if (!parsed.success) {
988
+ return refusalOutput(contextIdFromRawInput(rawInput), zodRefusals(parsed.error));
989
+ }
990
+ const mapped = mapAttestInput(parsed.data);
991
+ if (isAttestMappingRefusal(mapped)) {
992
+ return refusalOutput(parsed.data.context_id ?? randomContextId(), mapped.refusals);
993
+ }
994
+ const result = await emitInProcess(mapped.emitInput, {
995
+ ...options,
996
+ producer: parsed.data.producer ?? options.producer ?? 'atrib-attest',
997
+ });
998
+ if (!result.signed)
999
+ return result;
1000
+ return { ...result, event_type: mapped.event_type };
1001
+ }
1002
+ // Test-only export of handleEmit. Mirrors the `__test_only__` pattern
1003
+ // used in sign.ts; lets unit tests assert on the validation paths
1004
+ // without going through the McpServer transport surface.
1005
+ export const __test_only__ = { createServerKeyResolver, handleEmit, keyResolveRetryMs };
1006
+ // ---- public helpers (D079, for atrib-annotate, atrib-revise, future specialized writers) ----
1007
+ //
1008
+ // These exports let downstream MCP packages depend on @atrib/emit as the
1009
+ // canonical record-signing surface and wrap it with a narrow schema. The
1010
+ // shape is: caller constructs a key + queue (or reuses atrib-emit's
1011
+ // resolveKey + createSubmissionQueue), parses its own narrow input into an
1012
+ // EmitInput shape, calls handleEmit, returns the EmitOutput.
1013
+ //
1014
+ // Stable as of @atrib/emit@0.8.0. Breaking changes here require a major bump.
1015
+ export { handleEmit, EmitInput };
1016
+ export { resolveKey } from './keys.js';
1017
+ // Session-checkpoint emission (§1.2.10 / D139): reads the ordered record
1018
+ // hashes for a context from the local mirror per §5.9 and emits the
1019
+ // checkpoint through the existing emit pipeline (no new signing path).
1020
+ // §5.8: failures are silent and atrib:-logged; a missed checkpoint just
1021
+ // widens the next interval.
1022
+ export { emitSessionCheckpoint } from './session-checkpoint.js';
1023
+ // The attest input surface (write verb).
1024
+ export { AttestInput, AttestRef, mapAttestInput, isAttestMappingRefusal } from './attest.js';
1025
+ // Legacy specialized writers, kept as permanent aliases over the attest
1026
+ // funnel. The @atrib/annotate and @atrib/revise packages re-export these.
1027
+ export { AnnotateInput, Importance, createAtribAnnotateServer, registerAnnotateTool, } from './annotate.js';
1028
+ export { ReviseInput, createAtribReviseServer, registerReviseTool, } from './revise.js';