diff --git a/README.md b/README.md index e33527b..b0009d9 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,12 @@ Verb routes return a JSON object with a signed `receipt` and optional unsigned ` ```json { + "trace_id": "cltrace_...", + "steps": [{ "step": 1, "receipt": { "...": "signed receipt" } }], + "final_receipt": { "...": "same signed receipt" }, "receipt": { "...": "signed receipt" }, "runtime_metadata": { - "trace": { "...": "optional" }, + "trace": { "trace_id": "cltrace_...", "...": "optional" }, "actor": { "...": "optional" }, "delegation_result": { "...": "optional" } } @@ -60,6 +63,8 @@ Verb routes return a JSON object with a signed `receipt` and optional unsigned ` The signed receipt is produced by `@commandlayer/runtime-core`. The runtime sets proof fields under `receipt.metadata.proof`, including: +- `trace_id` +- `receipt_id` - `alg` - `canonical` - `signer_id` diff --git a/runtime/tests/runtime-signing.test.mjs b/runtime/tests/runtime-signing.test.mjs index 1d70a66..75dd202 100644 --- a/runtime/tests/runtime-signing.test.mjs +++ b/runtime/tests/runtime-signing.test.mjs @@ -170,6 +170,14 @@ test("makeReceipt production path emits signed receipts with runtime kid and can const { receipt, runtimeMetadata } = unwrapReceiptResponse(response); assert.equal(runtimeMetadata?.trace?.provider, "runtime"); + assert.match(response?.trace_id || "", /^cltrace_[a-f0-9]{32}$/); + assert.equal(response?.trace_id, runtimeMetadata?.trace?.trace_id); + assert.equal(response?.final_receipt?.metadata?.receipt_id, receipt.metadata?.receipt_id); + assert.equal(response?.steps?.[0]?.receipt?.metadata?.receipt_id, receipt.metadata?.receipt_id); + assert.equal(receipt.metadata?.trace_id, response?.trace_id); + assert.equal(receipt.metadata?.proof?.trace_id, response?.trace_id); + assert.match(receipt.metadata?.receipt_id || "", /^clrcpt_[a-f0-9]{32}$/); + assert.equal(receipt.metadata?.proof?.receipt_id, receipt.metadata?.receipt_id); assert.equal(receipt.metadata?.proof?.alg, "ed25519-sha256"); assert.equal(receipt.metadata?.proof?.signer_id, "runtime.commandlayer.eth"); assert.equal(receipt.metadata?.proof?.kid, keys.kid); @@ -442,7 +450,7 @@ test("full chain clean -> summarize -> classify verifies with schema using parti SCHEMA_VALIDATE_BUDGET_MS: "3000", }); - async function runVerb(base, verb, content) { + async function runVerb(base, verb, content, traceId = null) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); @@ -453,6 +461,7 @@ test("full chain clean -> summarize -> classify verifies with schema using parti body: JSON.stringify({ x402: { verb, version: "1.1.0" }, input: { content }, + ...(traceId ? { trace: { trace_id: traceId } } : {}), }), signal: controller.signal, }); @@ -508,19 +517,36 @@ test("full chain clean -> summarize -> classify verifies with schema using parti const clean = await runVerb(srv.base, "clean", source); console.log("[chain] after clean response"); assert.ok(clean?.receipt?.result?.cleaned_content, "clean step missing cleaned_content"); + assert.match(clean?.trace_id || "", /^cltrace_[a-f0-9]{32}$/); + assert.match(clean?.receipt?.metadata?.receipt_id || "", /^clrcpt_[a-f0-9]{32}$/); const cleanText = clean.receipt.result.cleaned_content; + const traceId = clean.trace_id; + const receiptIds = new Set([clean.receipt.metadata.receipt_id]); console.log("[chain] before summarize request"); - const summarize = await runVerb(srv.base, "summarize", cleanText); + const summarize = await runVerb(srv.base, "summarize", cleanText, traceId); console.log("[chain] after summarize response"); assert.ok(summarize?.receipt?.result?.summary, "summarize step missing summary"); + assert.equal(summarize?.trace_id, traceId); + assert.equal(summarize?.receipt?.metadata?.trace_id, traceId); + assert.equal(summarize?.receipt?.metadata?.proof?.trace_id, traceId); + assert.match(summarize?.receipt?.metadata?.receipt_id || "", /^clrcpt_[a-f0-9]{32}$/); + receiptIds.add(summarize.receipt.metadata.receipt_id); const summary = summarize.receipt.result.summary; console.log("[chain] before classify request"); - const classify = await runVerb(srv.base, "classify", summary); + const classify = await runVerb(srv.base, "classify", summary, traceId); console.log("[chain] after classify response"); assert.ok(classify?.receipt, "classify step missing receipt"); + assert.equal(classify?.trace_id, traceId); + assert.equal(classify?.receipt?.metadata?.trace_id, traceId); + assert.equal(classify?.receipt?.metadata?.proof?.trace_id, traceId); + assert.match(classify?.receipt?.metadata?.receipt_id || "", /^clrcpt_[a-f0-9]{32}$/); + receiptIds.add(classify.receipt.metadata.receipt_id); const finalReceipt = classify.receipt; + assert.equal(receiptIds.size, 3, "each chain step must have a unique receipt_id"); + assert.equal(classify?.steps?.[0]?.receipt?.metadata?.receipt_id, finalReceipt.metadata?.receipt_id); + assert.equal(classify?.final_receipt?.metadata?.receipt_id, finalReceipt.metadata?.receipt_id); assert.equal(finalReceipt.x402.entry, "x402://classifyagent.eth/classify/v1.1.0"); @@ -570,6 +596,9 @@ test("full chain clean -> summarize -> classify verifies with schema using parti assert.equal(verifyJson.checks.signature_valid, true); assert.equal(verifyJson.checks.hash_matches, true); assert.equal(verifyJson.checks.schema_valid, true); + assert.equal(finalReceipt.metadata?.proof?.signer_id, "runtime.commandlayer.eth"); + assert.ok(finalReceipt.metadata?.proof?.hash_sha256); + assert.ok(finalReceipt.metadata?.proof?.signature_b64); } catch (err) { console.error("CHAIN FAILURE DEBUG:"); console.error(err); diff --git a/server.mjs b/server.mjs index 26cd905..f06d7bb 100644 --- a/server.mjs +++ b/server.mjs @@ -1007,22 +1007,33 @@ function startWarmWorker() { // ----------------------- // receipts (runtime-core: single source of truth) // ----------------------- -function makeReceipt({ x402, result, status = "success", error = null }) { +function makeTraceId() { + return `cltrace_${crypto.randomUUID().replace(/-/g, "")}`; +} + +function makeFlowReceiptId() { + return `clrcpt_${crypto.randomUUID().replace(/-/g, "")}`; +} + +function makeReceipt({ x402, result, status = "success", error = null, traceId, receiptId }) { let receipt = { status, x402, ...(error ? { error } : {}), ...(status === "success" ? { result } : {}), metadata: { + ...(traceId ? { trace_id: traceId } : {}), proof: { alg: "ed25519-sha256", canonical: runtimeConfig.canonicalId, signer_id: runtimeConfig.signerId, kid: runtimeConfig.kid, + ...(traceId ? { trace_id: traceId } : {}), + ...(receiptId ? { receipt_id: receiptId } : {}), hash_sha256: null, signature_b64: null, }, - receipt_id: "", + receipt_id: receiptId || "", }, }; @@ -1042,10 +1053,25 @@ function makeReceipt({ x402, result, status = "success", error = null }) { receipt.metadata.proof.canonical_id = receipt.metadata.proof.canonical; } + if (receiptId) { + receipt.metadata.receipt_id = receiptId; + if (receipt.metadata?.proof && !receipt.metadata.proof.receipt_id) { + receipt.metadata.proof.receipt_id = receiptId; + } + } + + if (traceId) { + receipt.metadata.trace_id = traceId; + if (receipt.metadata?.proof && !receipt.metadata.proof.trace_id) { + receipt.metadata.proof.trace_id = traceId; + } + } + return receipt; } function wrapReceiptResponse(receipt, { trace = null, actor = null, delegation_result = null } = {}) { + const traceId = trace?.trace_id || receipt?.metadata?.trace_id || receipt?.metadata?.proof?.trace_id || null; const runtime_metadata = { ...(trace ? { trace } : {}), ...(actor ? { actor } : {}), @@ -1053,6 +1079,13 @@ function wrapReceiptResponse(receipt, { trace = null, actor = null, delegation_r }; return { + ...(traceId ? { trace_id: traceId } : {}), + ...(traceId + ? { + steps: [{ step: 1, receipt }], + final_receipt: receipt, + } + : {}), receipt, ...(Object.keys(runtime_metadata).length ? { runtime_metadata } : {}), }; @@ -1496,8 +1529,12 @@ async function handleVerb(verb, req, res) { const rawParent = req.body?.trace?.parent_trace_id ?? req.body?.x402?.extras?.parent_trace_id ?? null; const parentTraceId = typeof rawParent === "string" && rawParent.trim().length ? rawParent.trim() : null; + const rawTraceId = + req.body?.trace_id ?? req.body?.trace?.trace_id ?? req.body?.x402?.extras?.trace_id ?? req.body?.metadata?.trace_id ?? null; + const traceId = typeof rawTraceId === "string" && rawTraceId.trim().length ? rawTraceId.trim() : makeTraceId(); const trace = { + trace_id: traceId, provider: process.env.RAILWAY_SERVICE_NAME || "runtime", ...(parentTraceId ? { parent_trace_id: parentTraceId } : {}), }; @@ -1522,7 +1559,7 @@ async function handleVerb(verb, req, res) { try { - const receipt = makeReceipt({ x402, result, status: "success" }); + const receipt = makeReceipt({ x402, result, status: "success", traceId, receiptId: makeFlowReceiptId() }); return res.json(wrapReceiptResponse(receipt, { trace, actor })); } catch (signErr) { return respondSigningError(res, signErr); @@ -1548,7 +1585,7 @@ async function handleVerb(verb, req, res) { try { - const receipt = makeReceipt({ x402, status: "error", error: err }); + const receipt = makeReceipt({ x402, status: "error", error: err, traceId, receiptId: makeFlowReceiptId() }); return res.status(500).json(wrapReceiptResponse(receipt, { trace, actor })); } catch (signErr) { return respondSigningError(res, signErr); diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 263f72f..f4fb416 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -202,6 +202,14 @@ async function main() { const { receipt, runtimeMetadata } = extractReceiptEnvelope(describe.json); assert.equal(receipt.status, "success", `describe receipt status must be success`); assert.equal(runtimeMetadata?.trace?.provider, "runtime", `runtime_metadata.trace.provider must be runtime`); + assert.equal(describe.json?.trace_id, runtimeMetadata?.trace?.trace_id, `top-level trace_id must match runtime metadata`); + assert.ok(/^cltrace_[a-f0-9]{32}$/.test(String(describe.json?.trace_id || "")), `trace_id must use cltrace_ format`); + assert.ok(/^clrcpt_[a-f0-9]{32}$/.test(String(receipt?.metadata?.receipt_id || "")), `receipt_id must use clrcpt_ format`); + assert.equal(receipt?.metadata?.trace_id, describe.json?.trace_id, `receipt metadata.trace_id must match top-level trace_id`); + assert.equal(receipt?.metadata?.proof?.trace_id, describe.json?.trace_id, `receipt proof.trace_id must match top-level trace_id`); + assert.equal(receipt?.metadata?.proof?.receipt_id, receipt?.metadata?.receipt_id, `receipt proof.receipt_id must match metadata.receipt_id`); + assert.equal(describe.json?.steps?.[0]?.receipt?.metadata?.receipt_id, receipt?.metadata?.receipt_id, `steps[0] receipt must mirror signed receipt`); + assert.equal(describe.json?.final_receipt?.metadata?.receipt_id, receipt?.metadata?.receipt_id, `final_receipt must mirror signed receipt`); const proof = extractProof(receipt);