@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/LICENSE +216 -0
- package/README.md +187 -0
- package/dist/annotate.d.ts +56 -0
- package/dist/annotate.js +135 -0
- package/dist/attest.d.ts +69 -0
- package/dist/attest.js +214 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +729 -0
- package/dist/index.d.ts +312 -0
- package/dist/index.js +1028 -0
- package/dist/keys.d.ts +21 -0
- package/dist/keys.js +134 -0
- package/dist/local-substrate-host.d.ts +93 -0
- package/dist/local-substrate-host.js +467 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +18 -0
- package/dist/reference-resolution.d.ts +12 -0
- package/dist/reference-resolution.js +31 -0
- package/dist/revise.d.ts +43 -0
- package/dist/revise.js +133 -0
- package/dist/session-checkpoint.d.ts +103 -0
- package/dist/session-checkpoint.js +285 -0
- package/dist/sign.d.ts +69 -0
- package/dist/sign.js +74 -0
- package/dist/storage.d.ts +27 -0
- package/dist/storage.js +59 -0
- package/package.json +72 -0
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';
|