Skip to content

Export Guardian traces as OTLP to a file and over HTTP - #244

Merged
rocklambros merged 22 commits into
GenAI-Security-Project:integrationfrom
sabasiddique1:refimpl/otel-exporter
Oct 15, 2026
Merged

rocklambros merged 22 commits into
GenAI-Security-Project:integrationfrom
sabasiddique1:refimpl/otel-exporter

Conversation

@sabasiddique1

@sabasiddique1 sabasiddique1 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

What changed

The reference Guardian exports a span for every step it decides, as OTLP/JSON to a file (ACS_OTEL_FILE) and over OTLP/HTTP (ACS_OTEL_ENDPOINT). Both are off by default, and with neither set the Guardian behaves exactly as before. The exporter sits next to the envelope log sink in server.ts and follows the same rule: the decision path never waits on it or sees an error from it. Each output fails on its own, so a collector that is down costs the endpoint and nothing else. The file keeps every span, which is what the lab reads.

Which names come from where. Span names, the attributes listed per span (required_attributes, conditional_attributes, optional_attributes), the acs.decision event name and its two attribute lists, and the provenance attribute names are read from otel-mapping.json at startup. A test checks, against the real file, that every name the mapping lists for an evaluated method has a source in the exporter, so a mapping change cannot silently drop an attribute. The names that live in code until #228 adds them to the mapping are acs.request_id, the six decision extras (acs.reason_codes, acs.policy.ids, acs.policy.versions, acs.policy.rule_ids, acs.cited_provenance_ids, acs.chain_hash), and the acs.decision.failure event with its acs.failure.* attributes. Each set is declared in one place in otel-trace-exporter.ts.

What a span carries. gen_ai.tool.call and gen_ai.tool.result for the two methods this Guardian evaluates, read from the ServerHello it sends. METHODS_WITHOUT_SPAN names any evaluated method that has no mapping entry yet, and a test fails for a method that is neither mapped nor listed. Every span carries acs.session.id and acs.request_id. The acs.decision event carries acs.decision, acs.reasoning, and acs.evaluator only when the response carries metadata.evaluator (this Guardian sends it once #229 lands; the exporter no longer fills it in). The three policy arrays are always the length of policy_references, in its order, with "" where a value is missing, and acs.provenance.source_id is padded to the length of acs.provenance.origin the same way, and left out when no Provenance object has one. Both provenance columns stop at 64 objects, and every string attribute stops at 4096 characters, so one large request can't grow the export queue without bound. The sampler and span limits are set in code, so no OTEL_* environment variable can sample spans away or drop events.

acs.decision.failure (#37). A step that got no usable decision gets this event on its own span, with acs.failure.class, acs.failure.posture (from the ServerHello this Guardian sends) and acs.failure.reported_by, plus the error code, the negotiated timeout, the elapsed time and the request size when known. The Guardian emits it for the three cases it can see itself: guardian_error for a JSON-RPC error instead of a decision, timeout for a decision finished after the negotiated timeout (the host had already applied its posture), and other for a step still open at shutdown, which it now closes rather than loses. It carries the JSON-RPC error code but never the error text, which can quote the request (#228 forbids copying it), and a replay rejection (-32005) gets no event, because its pair belongs to the earlier request. The six decision extras are emitted whether or not the mapping lists them, so this PR and #228 can land in either order.

The converter. packages/guardian/src/audit-to-otlp.ts turns the host adapter's audit log (.acs/audit.jsonl) into the same OTLP/JSON file: one span per posture entry, with an acs.decision.failure event reported by observed_agent and the class read off the host's failure.kind through the table below. Each span carries the same acs.session.id (the host's derived UUID) and acs.request_id as the Guardian's span for that request, so the two files join. acs.failure.posture is the deployment's posture from the entry, the detail is a fixed sentence per kind and never the host's message, and the error code is parsed out as an integer. Ungoverned entries are skipped, since they are steps no gate asked about. Entries with no method, unmapped methods, replays and unreadable lines are skipped and counted. Spans are exported one at a time, and the command exits non-zero if fewer reach the file than it built. It reads the log with plain JSON.parse and imports nothing from the adapter. The test drives a real hook through the adapter against a closed port and converts the transport entry it leaves into one unreachable span.

Host failure.kind acs.failure.class
timeout timeout
transport unreachable
error_without_decision guardian_error
refused guardian_error
host_configuration observed_agent_error
decision_unrenderable other
unknown, anything else other

Writing and shutdown. The file is appended asynchronously, created 0600 in a 0700 directory. Spans go through a BatchSpanProcessor, the HTTP exporter gives up on a dead collector after 2 s, a second shutdown() waits for the first, and close() drains in-flight requests for the negotiated timeout before it shuts the exporter down, so a step whose bridge never answers still gets its span. The exporter's own shutdown is bounded at 3 s, so a stalled output can't hold the process open.

How I checked the output: besides the tests, I ran the Guardian with ACS_OTEL_FILE and read the file back with the OTel Collector's otlpjsonfile receiver (otelcol-contrib 0.162.0). Every span, event and attribute parsed. I also sent live traffic over OTLP/HTTP into the committed otel-collector.yaml and got the same spans out.

Which issue does this implement

Closes #91
Refs #37
Refs #228

Open, not blocking

  • A provenance id column. provenance_id is required in provenance.json, so an acs.provenance.ids column would always be complete and would let a reader join acs.cited_provenance_ids to an origin and a source. It needs a name from the Spec leads.
  • Resolved in review: refused stays guardian_error, because §6.4 counts an error in place of a decision as a decision failure, and decision_unrenderable is now other, because a decision did arrive.

Base branch

  • integration, because this touches the specification, schemas, a reference
    implementation, an adapter, tests, or CI
  • main, because every changed path is on the documentation lane allowlist in
    CONTRIBUTING.md

Type of change

  • Specification change (schema, hooks, events, AgBOM)
  • Reference implementation or adapter
  • Documentation
  • Tooling or CI
  • Governance (licensing, security policy, contributor docs)

I tested this

  • I synced my branch with the base branch before opening this
  • uv run pytest -v passes on my machine
  • uv run mkdocs build --strict passes on my machine

Also: bun install --frozen-lockfile clean, bun run typecheck clean, bun test 1172 pass / 1 skip / 0 fail across 70 files, on Bun 1.3.14 and on 1.3.11, the version CI pins. The timing tests (dead endpoint, timeout, open-at-shutdown, second shutdown) each passed five runs in a row.

Checklist

  • Commits are signed off with git commit -s (required by the DCO)
  • Prose follows STYLE.md
  • No secrets, tokens, or internal URLs in the diff

Security

  • This change has no security impact

When enabled, the file and the HTTP stream carry decision reasoning text, tool names, capabilities, and provenance source_id values, which by provenance.json can be URLs or file paths and go out as the host sent them. Raw tool arguments are not exported. The file is created readable by its owner only. Export is off by default, and the collector config's debug exporter prints at basic verbosity so attributes do not reach stdout. For the lab run I would use synthetic sessions only, since #71 is still open.

No new dependencies beyond the six OpenTelemetry pins already in the lockfile.

Six packages, each at an exact version: @opentelemetry/api 1.9.1, core
2.12.0, resources 2.12.0, sdk-trace-base 2.12.0, otlp-transformer
0.223.0 and exporter-trace-otlp-http 0.223.0. Exact rather than a range
for the same reason ajv is: CI installs from the lockfile and a floating
range would let the installed SDK drift from the one the exporter was
written against. sdk-trace-base 2.12.0 is a shim over
@opentelemetry/sdk-trace, which the lockfile therefore carries too.

Signed-off-by: Saba Siddique <sabasiddiqdev@gmail.com>
One OpenTelemetry span per step the Guardian decides, so a session can be
rebuilt offline from a trace file. Span names and attribute lists come
from specification/v0.1.0/trace/otel-mapping.json at startup; the one
table the mapping cannot supply, which envelope field feeds which
attribute, lives in otel-trace-exporter.ts. The span opens when the
request is recorded and closes when the response is, at the envelope
log's own two call sites, paired by JSON-RPC id. The decision rides on
it as the mapping's acs.decision event, with acs.evaluator set to
deterministic and the reason codes, policy references, cited provenance
ids and chain hash added only when the response carries them.

Two outputs, both off by default: ACS_OTEL_FILE writes OTLP/JSON through
the official serializer, one export request per line, and
ACS_OTEL_ENDPOINT sends to an OTLP/HTTP collector. With neither set the
Guardian is handed a null exporter and behaves as before, banner
included. Spans go through a BatchSpanProcessor and are flushed when
the Guardian closes, which main.ts now does on SIGINT and SIGTERM when
export is on.

Total the way the envelope log sink is: any failure disables export for
the process, reports once, and the response goes out unchanged. The
tests drive both a failing exporter and an unwritable file through a
real Guardian to hold that line. METHODS_EVALUATED is exported from
handshake.ts so the exporter emits spans only for methods this Guardian
dispatches, without a second copy of that list.

Signed-off-by: Saba Siddique <sabasiddiqdev@gmail.com>
The Guardian README gains the two variables, a section on what a span
and its decision event carry, where acs.chain_hash does and does not
appear, and the note that lab runs should use synthetic sessions because
raw envelopes reach disk before any redaction. otel-collector.yaml at
the tree root receives the endpoint output and writes the same OTLP/JSON
file shape, for a deployment that prefers a Collector in the path.

Signed-off-by: Saba Siddique <sabasiddiqdev@gmail.com>
The exporter kept open spans in a map keyed by JSON-RPC id and closed
the first one it found when a response arrived. A JSON-RPC id is the
client's: hosts run hooks in parallel and number requests per session,
so two sessions can be in flight with the same id, and a lookup keyed on
it could close one session's span with the other's decision.

`start` now returns the span it opened and `end` takes that handle back.
handleAcsRequest holds it between the envelope log's two writes, so a
span can only close on its own response and a request with no id at all
still gets one. The map, its queues and the id extraction are gone. A
test drives two sessions through a real Guardian concurrently with the
same id, one denied and one allowed, and checks each decision landed on
its own span.

Signed-off-by: Saba Siddique <sabasiddiqdev@gmail.com>
otel-mapping.json says a span whose payload has a Provenance object
attached must carry acs.provenance.origin, and may carry
acs.provenance.source_id and acs.provenance.lineage_depth. A payload can
carry several such objects, one per argument at the request gate and one
per output at the result gate, and a span has one value per attribute,
so each attribute becomes a column: an array with one element per
Provenance object, in wire order, the i-th element describing the i-th
object. A column is emitted only when every object carries the field,
the rule the policy columns already follow, so columns line up by
position and no placeholder is invented. The names come from the
mapping's own provenance_attributes block.

acs.provenance.lineage_depth is not emitted. provenance.json defines no
such field, so there is nothing on the wire to read it from; the source
table says so where the entry would be.

Signed-off-by: Saba Siddique <sabasiddiqdev@gmail.com>
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

This pull request's description says it closes #91, which is on the project roadmap. When it merges, GitHub closes that issue and the roadmap counts it as delivered. Please confirm the change delivers the whole issue before merging.

…le asynchronously

Signed-off-by: Saba Siddique <sabasiddiqdev@gmail.com>
@rocklambros

Copy link
Copy Markdown
Collaborator

Hi @sabasiddique1, thank you for this. I read the whole diff, ran it, and had a few reviewers attack it from different sides. The core design is good. Pairing the request and the response by passing the span handle is the right call. I checked that your two-sessions-same-id test really fails if someone pairs by JSON-RPC id instead. I also ran the file through the collector the way you did (otelcol-contrib 0.162.0, otlpjsonfile receiver). Every line parsed, and every span had acs.session.id and acs.request_id. Install, typecheck and bun test packages/guardian all pass on my machine (282 pass). So the base is solid.

There are changes I need before I can approve. I won't push anything to your branch. I'd like you to make these changes yourself, so I wrote this comment as a step-by-step guide. Each task says why it matters, what to change (with file, line, and code where it helps), and how to test it. If you use an AI coding assistant, the last section has a prompt you can paste in as it is. If anything is unclear, ask me here and I'll answer.

Line numbers below refer to your current head, 34e1ee7.


Task 1. Policy and provenance arrays: same length, "" for gaps

Why. On #228, Bar and Maurits agreed on this rule (Bar's comment): "the three policy arrays always have the same length and order as policy_references, with an empty string where a value is missing." Right now policyColumn drops the whole column when one reference is missing a field. I tested it. With {P1, V1, R1} and {P2, R2}, you get acs.policy.ids = ["P1","P2"] and no acs.policy.versions at all, so even V1 is lost. provenanceColumn has the same problem for acs.provenance.source_id. events.md:41 says the span SHOULD carry source_id "when populated", and today one argument without a source_id hides the source_id of every other argument.

Change in otel-trace-exporter.ts. Replace the body of policyColumn (lines 140-149):

function policyColumn(references: unknown, field: string): string[] | undefined {
  if (!Array.isArray(references) || references.length === 0) return undefined;
  // Same length and order as policy_references, "" where a reference lacks
  // the field -- the rule agreed on #228, so a reader can line up the three
  // columns by position.
  return references.map((reference) => asString(asObject(reference)?.[field]) ?? "");
}

Then replace the body of provenanceColumn (lines 294-304) the same way:

function provenanceColumn(objects: JsonObject[], read: (provenance: JsonObject) => string | undefined): string[] {
  return objects.map((object) => read(object) ?? "");
}

Please update the two doc comments above these functions too, because they explain the old rule.

Test. Flip the two tests that assert a column is dropped:

  • otel-trace-exporter.test.ts:152 should expect acs.policy.versions to equal ["v1", ""].
  • otel-trace-exporter.test.ts:292 should expect acs.provenance.source_id to equal ["src-1", ""].

Please also add one more case: a reference missing rule_id, which should give acs.policy.rule_ids with "" in that position.


Task 2. A dead collector must not stop the file

Why. fail sets one shared disabled flag (lines 428-435), and start returns null once it is set. I ran the Guardian with ACS_OTEL_FILE and with ACS_OTEL_ENDPOINT pointing at a closed port. Decisions were byte-identical to export off, which is good. But after the ECONNREFUSED line (about 13 seconds in), the file stopped too. Only 19 of 45 spans reached it. The file is what the lab reads, and a collector outage is exactly when you want the local copy.

Change. Each output should turn off only itself. Replace reportingFailures (lines 398-418) with a version that keeps its own "dead" flag and reports once per output:

function reportingFailures(exporter: SpanExporter, output: string, report: (output: string, error: unknown) => void): SpanExporter {
  let dead = false;
  const markDead = (error: unknown): void => {
    if (!dead) {
      dead = true;
      report(output, error);
    }
  };
  return {
    export(spans, resultCallback): void {
      if (dead) {
        // This output failed before. Drop the batch quietly; the other
        // outputs keep working.
        resultCallback({ code: ExportResultCode.SUCCESS });
        return;
      }
      try {
        exporter.export(spans, (result) => {
          if (result.code === ExportResultCode.FAILED) {
            markDead(result.error ?? new Error("span export failed"));
          }
          resultCallback(result);
        });
      } catch (error) {
        markDead(error);
        resultCallback({ code: ExportResultCode.FAILED, error: error instanceof Error ? error : new Error(String(error)) });
      }
    },
    shutdown: () => exporter.shutdown(),
    async forceFlush(): Promise<void> {
      await exporter.forceFlush?.();
    },
  };
}

Then:

  • In createOtelTraceExporter, keep a name next to each exporter ("file", "endpoint", "test") and pass it in, for example new BatchSpanProcessor(reportingFailures(exporter, name, reportOutput)).
  • reportOutput prints one line per output, for example otel trace exporter: file output disabled after failure: <message>, or calls onError when one is given.
  • Keep the shared disabled flag only for failures in your own code (a throw inside begin or finish, or a failure while building the provider).
  • Update the onError doc comment from "at most once" to "at most once per output".

Test. Please add a test with the real OTLPTraceExporter, not the throwing stub. The current "failing exporter" test uses a stub that throws right away, but a real collector fails late, after retries, so the stub does not test the real path. The test:

  1. Starts a Guardian with otelTraceFile set to a temp file and otelTraceEndpoint: "http://127.0.0.1:1/v1/traces". Port 1 is closed on any normal machine.
  2. Sends a few decisions and checks the answers are the same as a Guardian with export off.
  3. Waits until onError has been called for the endpoint, then sends a few more decisions.
  4. Calls close() and checks it finishes within a bounded time.
  5. Checks that the file has spans for the decisions sent after the endpoint failed. This is the assertion that fails today.

You'll need a way to pass onError through startGuardian for step 3, or a short timeoutMillis (see Task 7) so the failure comes quickly.


Task 3. Write the file without blocking the event loop

Why. createFileSpanExporter calls appendFileSync (line 380). The batch processor runs that on the same event loop that answers decisions. One reviewer tested a file target that blocks (a FIFO with no reader). After the 5-second batch timer fired, the next decision never got an answer. With the default proceed posture, the host then lets the tool call through ungoverned. That breaks the fourth requirement in events.md ("Trace events MUST NOT block enforcement").

Change. Use the async version and call the callback when it finishes. At the top of the file:

import { mkdirSync, readFileSync } from "node:fs";
import { appendFile } from "node:fs/promises";

And replace createFileSpanExporter:

export function createFileSpanExporter(path: string): SpanExporter {
  // 0o700 / 0o600: the file holds reasoning text and provenance source ids,
  // so other local users should not read it (#71 is the same problem in the
  // envelope log).
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
  return {
    export(spans, resultCallback): void {
      let line: string;
      try {
        const bytes = JsonTraceSerializer.serializeRequest(spans);
        if (bytes === undefined) {
          throw new Error("JsonTraceSerializer produced no request");
        }
        line = `${new TextDecoder().decode(bytes)}\n`;
      } catch (error) {
        resultCallback({ code: ExportResultCode.FAILED, error: error instanceof Error ? error : new Error(String(error)) });
        return;
      }
      // Async on purpose: a slow or blocked file must never stall the event
      // loop that answers decisions.
      appendFile(path, line, { mode: 0o600 }).then(
        () => resultCallback({ code: ExportResultCode.SUCCESS }),
        (error: unknown) =>
          resultCallback({ code: ExportResultCode.FAILED, error: error instanceof Error ? error : new Error(String(error)) }),
      );
    },
    async shutdown(): Promise<void> {},
    async forceFlush(): Promise<void> {},
  };
}

The batch processor waits for one export to finish before it starts the next one, so the lines stay in order.

Test. The existing "flushes the file on close" test should still pass. If you want a direct test, a unit test can call export twice and check both lines arrive in order. A FIFO test is optional.


Task 4. Read the method list, timeout and posture from the ServerHello, and leave handshake.ts alone

Why. Two reasons:

  • fix(agt): implement ACS-Core wire safeguards #229 changes the exact line you edited in handshake.ts (METHODS_EVALUATED). If you read the list from buildServerHello() instead, your PR doesn't touch handshake.ts at all, and that conflict goes away.
  • Task 5 needs the negotiated timeout and the failure posture, and the ServerHello already has both.

Change.

  1. Revert handshake.ts to the base. METHODS_EVALUATED goes back to const.
  2. In server.ts, remove METHODS_EVALUATED from the import.
  3. In startGuardian, build the exporter like this:
const traceExporter =
  traceExporterOverride ??
  (otelTraceFile !== undefined || otelTraceEndpoint !== undefined
    ? (() => {
        // The same ServerHello the handshake sends, so the trace agrees with
        // what this Guardian told the host.
        const hello = buildServerHello(
          onDecisionFailure === undefined ? undefined : { ACS_ON_DECISION_FAILURE: onDecisionFailure },
        );
        return createOtelTraceExporter({
          filePath: otelTraceFile,
          endpoint: otelTraceEndpoint,
          methodsEvaluated: hello.methods_evaluated,
          timeoutConfig: hello.timeout_config,
          posture: hello.on_decision_failure === "deny" ? "fail_closed" : "fail_open",
        });
      })()
    : NULL_OTEL_TRACE_EXPORTER);
  1. Add timeoutConfig and posture to CreateOtelTraceExporterOptions, with doc comments.

Task 5. Emit acs.decision.failure from the Guardian

You committed to this one, so here is the exact shape. Bar defined the event on #37, in this comment. Please use those names exactly.

The event is a span event named acs.decision.failure, on the step span. Its attributes:

  • Required.
    • acs.failure.class: one of timeout, unreachable, malformed_response, guardian_error, observed_agent_error, other.
    • acs.failure.posture: fail_open or fail_closed.
    • acs.failure.reported_by: guardian or observed_agent.
  • When known. acs.failure.detail (required when the class is other), acs.failure.error_code, acs.failure.timeout_ms, acs.failure.elapsed_ms, acs.failure.payload_bytes.

Bar's decision says a Guardian SHOULD emit the event for two cases it can see itself: "an error it returned (guardian_error) and a decision it finished after the negotiated timeout (timeout)". There is also a third case, which fixes a gap I found: a span that never gets a response.

Why the timeout case matters. The host gives up after the timeout (5 seconds by default) and applies its posture. The Guardian keeps working and answers later. Today the span then records deny, but the host already let the call through. A lab reader would believe the call was blocked. The failure event tells them the truth.

Why the "no response" case matters. When the bridge never answers, or when SIGINT/SIGTERM arrives during traffic, the span is started but never ended, so nothing is exported. I tested it, and the step leaves no trace at all.

Change in otel-trace-exporter.ts.

(a) The event isn't in otel-mapping.json yet. I'll add it to the mapping in my #228 PR, next to decision_event. Until then, put the names in one place in code, the same way you did for DECISION_DETAIL_SOURCES:

// acs.decision.failure is not in otel-mapping.json yet. The names follow
// the decision on #37. When the mapping gains the event, read the event
// name from it the way decision_event is read.
const DECISION_FAILURE_EVENT = "acs.decision.failure";

(b) Remember when each span started and how big the request was:

export type StepSpan = { readonly span: Span; readonly startedAt: number; readonly payloadBytes?: number };

In begin, use startedAt: performance.now(). Add a third parameter to start, start(envelope, method, payloadBytes?). In server.ts, call it as traceExporter.start(raw, method, Buffer.byteLength(body.text)).

(c) Keep a set of open spans, so shutdown can close them:

const openSpans = new Set<StepSpan>();

In begin, add the new StepSpan to the set before returning it. In end, always remove it first, even when export is disabled:

end(stepSpan, response): void {
  if (stepSpan === null) return;
  openSpans.delete(stepSpan);
  if (disabled || stopped || mapping === undefined) return;
  // ...existing try / finish / catch...
}

(d) A helper for the attributes every failure event carries. Put it inside createOtelTraceExporter, so it can see posture:

function failureAttributes(stepSpan: StepSpan, failureClass: string, extra: Attributes): Attributes {
  const attributes: Attributes = {
    "acs.failure.class": failureClass,
    "acs.failure.posture": posture,
    "acs.failure.reported_by": "guardian",
    "acs.failure.elapsed_ms": Math.round(performance.now() - stepSpan.startedAt),
    ...extra,
  };
  if (stepSpan.payloadBytes !== undefined) {
    attributes["acs.failure.payload_bytes"] = stepSpan.payloadBytes;
  }
  return attributes;
}

(e) In finish, add the two Guardian-side cases:

if (decision === undefined) {
  const error = asObject(asObject(envelope)?.error);
  span.setStatus({ code: SpanStatusCode.ERROR, message: asString(error?.message) });
  const extra: Attributes = {};
  const code = asNumber(error?.code);
  if (code !== undefined) extra["acs.failure.error_code"] = code;
  const message = asString(error?.message);
  if (message !== undefined) extra["acs.failure.detail"] = message;
  span.addEvent(DECISION_FAILURE_EVENT, failureAttributes(stepSpan, "guardian_error", extra));
} else {
  // ...existing acs.decision event...
  const timeoutMs = timeoutConfig?.per_method_ms?.[method] ?? timeoutConfig?.default_ms;
  const elapsedMs = performance.now() - stepSpan.startedAt;
  if (timeoutMs !== undefined && elapsedMs > timeoutMs) {
    // The host stopped waiting at timeoutMs and applied its posture, so the
    // decision above probably never took effect.
    span.addEvent(DECISION_FAILURE_EVENT, failureAttributes(stepSpan, "timeout", { "acs.failure.timeout_ms": timeoutMs }));
  }
}

finish needs the method for the per-method timeout. Either store method in the StepSpan or pass it in.

(f) In shutdown(), before provider.shutdown(), close every span that is still open:

for (const stepSpan of openSpans) {
  stepSpan.span.addEvent(
    DECISION_FAILURE_EVENT,
    failureAttributes(stepSpan, "other", { "acs.failure.detail": "the Guardian shut down before it answered this step" }),
  );
  stepSpan.span.setStatus({ code: SpanStatusCode.ERROR, message: "no response before shutdown" });
  stepSpan.span.end();
}
openSpans.clear();

Tests.

  • A JSON-RPC error response produces one acs.decision.failure event with acs.failure.class = "guardian_error", the error code, and acs.failure.reported_by = "guardian".
  • A slow decision produces a timeout event. Start the Guardian with a bridge override that waits about 200 ms, and pass a traceExporter you built with createOtelTraceExporter({ spanExporter, methodsEvaluated, timeoutConfig: { default_ms: 50 }, posture: "fail_open" }), so the test is fast.
  • A bridge that never answers, then close(), produces one span with an other failure event and ERROR status.
  • A normal fast decision produces no failure event.

Task 6. Convert the host audit log into the same OTLP file

This is the second half of what you committed to. Bar's decision says the Observed Agent MUST record every decision failure in its own audit log, and SHOULD emit it on Trace. The host adapter already writes that audit log (.acs/audit.jsonl, see packages/host-adapter/src/audit-sink.ts).

Where to put it. Please write a small converter script at packages/guardian/src/audit-to-otlp.ts. Please don't edit the host adapter itself:

The guardian package already has the OTel dependencies and your createFileSpanExporter.

What it does.

  • It runs as bun run packages/guardian/src/audit-to-otlp.ts <audit.jsonl> <otel-file.jsonl>.
  • It reads the audit log line by line, using plain JSON.parse with loose reads like your asObject/asString. Please don't import the host adapter's types.
  • For each entry whose outcome is "proceeded" or "blocked" (the posture entries), it writes one span:
    • Span name: mapping.stepToSpan[entry.method]?.span_name. If there is no mapping entry (or method is null), skip the entry and count it. Print the count of skipped entries at the end.
    • Start time and end time: new Date(entry.recorded_at).
    • Span attributes: acs.session.id from entry.session_id.
    • One acs.decision.failure event with acs.failure.reported_by = "observed_agent", acs.failure.posture from outcome (proceeded gives fail_open, blocked gives fail_closed), acs.failure.detail from entry.failure.message, and acs.failure.class from entry.failure.kind using this table:
failure.kind in the audit log acs.failure.class
timeout timeout
transport unreachable
error_without_decision guardian_error
refused guardian_error
host_configuration observed_agent_error
decision_unrenderable observed_agent_error
unknown, or anything else other

The kinds are defined in packages/host-adapter/src/failure-kinds.ts. Please read the comments there and tell me in the PR if you think any row is wrong.

  • It skips entries whose outcome is "ungoverned". Those are steps no gate asked about, not decision failures, so Bar's classes don't cover them. Please say this in the README.
  • It flushes and shuts down the provider at the end, so every span is written.

The audit entries don't carry elapsed time, timeout or payload size, so leave those attributes out. They are "when known" attributes.

Running it. Please tell people in the README to run it after the Guardian has stopped. If both write to the same file at the same time, the lines could mix. Running it twice on the same audit log writes the spans twice, so the README should say that too.

Test (with the Guardian down). Please put this test in reference-implementations/agt/test/, because it uses two packages.

  • Drive one hook through the host adapter with the Guardian URL pointing at a closed port, so the host writes a transport audit entry. packages/host-adapter/test/govern-step.test.ts already has this kind of setup, and test/audit-sink-roundtrip.test.ts shows how audit entries look.
  • Run the converter on that audit log.
  • Check that the OTLP file has one span with an acs.decision.failure event where acs.failure.class = "unreachable", acs.failure.reported_by = "observed_agent", and acs.failure.posture = "fail_open".

Task 7. Shutdown: wait for the first call, and don't wait too long

Why.

  • shutdown() returns at once on a second call (line 560), instead of waiting for the first one. So SIGINT followed by SIGTERM can exit in the middle of a flush.
  • With a dead endpoint, shutdown took about 9 seconds in my run, because the OTLP exporter retries for up to 10 seconds by default.

Change.

  • Store the shutdown promise and return it on every call:
let shutdownPromise: Promise<void> | undefined;
// ...
shutdown(): Promise<void> {
  shutdownPromise ??= (async () => {
    if (provider === undefined) return;
    stopped = true;
    // close open spans (Task 5f), then:
    try {
      await provider.shutdown();
    } catch (error) {
      fail(error);
    }
  })();
  return shutdownPromise;
},
  • Create the HTTP exporter with a short timeout: new OTLPTraceExporter({ url: endpoint, timeoutMillis: 2000 }).

Task 8. A test that every span name comes from the mapping

Why. In #91 I asked for "a test that every emitted span name is a key in the mapping". I can't find it. The tests compare against two specific entries, which is not the same thing. It matters soon. #229 adds protocols/MCP/tools/call to the evaluated methods, and the mapping has no entry for it, so those tool calls would get no span and nothing would report it.

Change.

  • In otel-trace-exporter.ts, export a small list of methods that are evaluated on purpose without a span, each with a reason. Start it empty:
/** Methods this Guardian evaluates that have no step_to_span entry in
 * otel-mapping.json yet, and why. A method here gets no span. The test
 * fails for an evaluated method that is neither mapped nor listed here. */
export const METHODS_WITHOUT_SPAN: Readonly<Record<string, string>> = {};
  • In begin, nothing changes. A method with no mapping entry already returns null.

Tests.

  • For every method in buildServerHello().methods_evaluated, either MAPPING.stepToSpan[method] is defined or METHODS_WITHOUT_SPAN[method] is defined.
  • In the wiring test, every emitted span name is one of the span_name values in step_to_span.

I'm asking #229 to add its MCP method to METHODS_WITHOUT_SPAN with a reason when the two PRs meet.


Task 9. Make the docs match the code

Guardian README (packages/guardian/README.md). Please replace these sentences.

  • Line 59, "It carries the attributes the mapping lists for it, read from the envelope," becomes:

    It carries each attribute the mapping lists for it when the envelope supplies a value. A required attribute the envelope omits, such as acs.capability ([Bug]: otel-mapping.json requires acs.capability on gen_ai.tool.call, but capability is optional in tool-call-request.json #227), is absent from the span.

    Please add a test for this: a request with no capability gives a span without acs.capability, and nothing breaks.

  • Line 61, the sentence about acs.chain_hash appearing after a ContextEntry was written, becomes:

    This Guardian does not send chain_hash, cited_provenance_ids, or policy_version on its responses yet, so those attributes are absent from its spans today. The three policy arrays always have the same length as policy_references, with "" where a reference has no value.

    I checked the collector output: none of those three ever appear in a real run.

  • Line 63, "stop it with Ctrl-C rather than killing it", becomes:

    Spans are flushed when the Guardian closes on SIGINT or SIGTERM. A SIGKILL loses the last batch.

  • Add a short paragraph about acs.decision.failure: the Guardian emits it for errors, late decisions and shutdown, and the converter from Task 6 produces it from the host audit log.

  • In the security paragraph, name acs.provenance.source_id next to acs.reasoning. By provenance.json it can be a URL or a file path, and a signed URL or a home-directory path would go out as is.

  • Add one sentence:

    This is an exporter for the two live methods, not an ACS-Trace conformance claim.

    The lab will read this README first.

  • STYLE.md asks for 2 to 4 sentences per paragraph. Lines 59 and 61 have 6 and 5, so please split each into two paragraphs.

Top-level README.md, around line 292. It still says "No OpenTelemetry collector or exporter exists yet." Please replace that paragraph with:

Two gaps are named and open, not vague. The Guardian writes its envelope and audit logs as synchronous file appends on the decision path, with no batching. Trace export is partial: set ACS_OTEL_FILE or ACS_OTEL_ENDPOINT and the Guardian exports OTLP spans for the two live methods, steps/toolCallRequest and steps/toolCallResult, as the Guardian README describes.

That exporter does not make the reference ACS-Trace conformant, and this tree claims no Trace profile. It covers two methods, emits no OCSF, and leaves out a required attribute when the request omits it. The conformance harness still measures which attributes a consumer could emit from the wire, not what the exporter ships. Start in reference-implementations/agt/packages/.

otel-collector.yaml. Please change the debug exporter to verbosity: basic. With detailed, every attribute (including reasoning and source_id) goes to stdout, and stdout often ends up in a log system nobody planned for.


Task 10. Fix the PR description

The squash commit message is the PR description, so these sentences become permanent history.

  1. "Span names and attribute names come from otel-mapping.json" is true for the span names and the mapped attributes. It isn't true for acs.request_id, the six decision extras (acs.reason_codes, the three acs.policy.*, acs.cited_provenance_ids, acs.chain_hash), and the acs.decision.failure names. Those live in code until [Bug]: acs.decision trace event cannot carry reason_codes, policy_references, cited_provenance_ids, or chain_hash, so replay from the trace alone is not possible #228 adds them to the mapping. Please say which names come from where.
  2. The closing-choice check fails because "([Bug] §6.4's mandatory fail-open audit event has no conformant shape, so the decision-failure rate is unmeasurable #37)" sits in the issue section without a keyword. When Tasks 5 and 6 are done, please delete the "Still to come" line and add #37 to the Refs line, so it reads Refs #37, #228. Please don't use Closes #37. [Bug] §6.4's mandatory fail-open audit event has no conformant shape, so the decision-failure rate is unmeasurable #37 also covers the spec text and the sessionEnd count, which this PR doesn't do.
  3. Please describe the new acs.decision.failure work and the converter in "What changed".

Your five questions

Q1. Should the Guardian set metadata.evaluator on the response?
Yes, but not in this PR, because #229 owns acs-result.ts. I've asked for the one-line change on #229. Until it lands, keep your deterministic fallback, and add one sentence to the README saying the trace has an evaluator that the envelope log doesn't. When the wire carries it, delete DEFAULT_EVALUATOR.

Q2. Is acs.request_id fine, or should it go into the mapping?
Keep the name, and it should go into the mapping. Both envelopes require request_id, and acs.request_id_ref on the result span has nothing to join to without it. I'll add it to the mapping in my #228 PR. You don't need to change anything.

Q3. lineage_depth: drop it, or define how to compute it?
I'll drop it in my #227 PR. provenance.json has no such field, and depth is a property of the whole derived_from graph, not of one object. Defining a graph algorithm in a patch release is the wrong move. It appears in four places, and I'll remove it from all of them: otel-mapping.json (the description at line 121 and the list at line 131), ocsf-mapping.json:78, events.md:41, and extend_opentelemetry.md:20. Your exporter already does the right thing by not emitting it.

Q4. Are positional provenance arrays enough, or do we need argument names?
Positional is fine for the lab once the README says it plainly, and Task 1 keeps the positions lined up. Argument names aren't the missing piece. provenance_id is. The decision event can carry acs.cited_provenance_ids, but no span carries provenance ids, so a reader can't join a cited id to its origin and source_id. provenance_id is required in provenance.json, so an acs.provenance.ids column would always be complete. That needs a name from the Spec leads, so it's a follow-up and doesn't block this PR.

Q5. Does the trace-pillar.ts update go in #228 or here?
It goes in #228, and you don't carry it. decisionAttributeSite in trace-pillar.ts:444 throws on any decision attribute it doesn't know. So the PR that adds names to the mapping has to update the checker in the same commit, or integration breaks. There's also a CI reason. The reference implementation job only runs on reference-implementations/**, so a spec-only #228 PR would pass CI and break things later. Touching trace-pillar.ts in #228 makes CI run.


Merge notes

  • Dependency age. Five of the six OpenTelemetry pins were published on 2026-10-06. Our dependabot.yml waits 7 days before taking a new release, so I won't merge before 2026-10-13. That still fits the 16th, and you don't need to change the pins. Give the AGT reference implementation Dependabot coverage #126 (Dependabot for this tree) is still open, so nothing watches these packages yet. That's on me.
  • Batch Guardian log writes and drain them on shutdown #112. It also adds SIGINT/SIGTERM handlers and a draining close(). Whichever PR lands second should keep one signal handler and one close() that drains the logs and then calls traceExporter.shutdown(). I left the same note on Batch Guardian log writes and drain them on shutdown #112.
  • fix(agt): implement ACS-Core wire safeguards #229. With Task 4, you no longer touch handshake.ts. server.ts and main.ts will still conflict at the startGuardian call and in handleAcsRequest. Whoever lands second resolves it, and I'm happy to help.
  • After approval. I'll mark the PR ready, squash it with Closes #91, and add the sign-offs on the squash commit. Your commits already carry Signed-off-by, so DCO is fine.

If you use an AI coding assistant

Paste this in as it is. It points the assistant at this comment, so you don't have to retype anything.

I am working on PR #244 in GenAI-Security-Project/agent-control-standard
(branch refimpl/otel-exporter). The maintainer left a review comment with
Tasks 1-10. Read it first:

  gh pr view 244 --comments

Then do the tasks in order, in reference-implementations/agt/. Rules:
- Make one commit per task, signed off with `git commit -s`. Do not add
  any AI co-author or "generated by" lines.
- Follow the code in the comment, but read the surrounding code first and
  match its style, its comment density and its "total by construction"
  rule: nothing in the exporter may throw into, or wait on, the decision
  path.
- Do not edit packages/guardian/src/acs-result.ts,
  packages/guardian/src/handshake.ts (Task 4 reverts it to the base), or
  anything under packages/host-adapter/src/.
- After each task run, from reference-implementations/agt:
    bun install --frozen-lockfile
    bun run typecheck
    bun test
  and fix anything that fails before moving on.
- From the repository root, also run `uv run pytest -v` and
  `uv run mkdocs build --strict`.
- At the end, show me `git diff origin/integration...HEAD --stat` and a
  one-line summary per task, and list anything you could not do.

Thanks again. This is close, and the lab will get a file they can use.

@rocklambros

Copy link
Copy Markdown
Collaborator

One more thing I found after posting, @sabasiddique1. It's small, but please add it to Task 8.

On #227, Bar and Maurits decided that each step_to_span entry gets a conditional_attributes list, and acs.capability moves into it. Your exporter reads only required_attributes and optional_attributes (StepSpanEntry at line 62, and the collect call in begin at line 493). So when #227 lands, acs.capability would quietly stop appearing on spans.

The fix is two lines. Add conditional_attributes?: string[] to StepSpanEntry, and include it in the list begin passes to collect:

collect(attributes, [...entry.required_attributes, ...(entry.optional_attributes ?? []), ...(entry.conditional_attributes ?? [])], STEP_ATTRIBUTE_SOURCES, parts);

It does nothing today, and it keeps working after #227. Please add a unit test that uses a small mapping file (your mappingPath option) with a conditional_attributes entry, and checks that the attribute is emitted.

jrcrittenden added a commit to jrcrittenden/ACS that referenced this pull request Oct 12, 2026
The HMAC signing block in handleAcsRequest ran outside any try, on
unauthenticated input. A session_id over 1024 bytes makes HKDF throw, and
a deeply nested body overflows the stack in JCS canonicalization. Either
escaped handleAcsRequest as an HTML 500, which a host reads as a hook
that never fired. The block is now wrapped. A request that throws there
cannot carry a valid signature, so its response goes out unsigned, like
any other unauthenticated request's, and the failure is reported on
stderr. The envelope log records the same response it did before, minus
the signature it could never have had.

Every AcsResult now carries metadata.evaluator "deterministic", so the
trace exporter in GenAI-Security-Project#244 reads the evaluator from the wire instead of
assuming it.

Both raised by @rocklambros on GenAI-Security-Project#229.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
@rocklambros

Copy link
Copy Markdown
Collaborator

@sabasiddique1, a short update. The spec PR for #227 is now open as #261, and the one for #228 is ready on a branch. Checking them against your exporter turned up four things. None of them is new work beyond the review, but two of them change what I asked earlier, so I'm writing them down.

  1. Read conditional_attributes (the addendum), and test it against the real mapping. I merged your head 34e1ee7 with Add per-hook conditional attributes to the OTel mapping #261 and ran everything. All tests pass, yet spans lose acs.capability, acs.memory_store.name, acs.operation, acs.source.type, acs.subagent.final_chain_hash and acs.agbom.change_reason, plus acs.request_id_ref, which Add per-hook conditional attributes to the OTel mapping #261 also makes conditional. A test with a small made-up mapping proves the code path works, but it can't catch a gap between your code and the real file. Please also add this test: load the real mapping with loadOtelMapping(), and for every method in buildServerHello().methods_evaluated, check that every name in required_attributes, conditional_attributes and optional_attributes has an entry in STEP_ATTRIBUTE_SOURCES. Do the same for the decision event's lists against your decision sources.
  2. Task 1 (padding) is now a merge blocker. The [Bug]: acs.decision trace event cannot carry reason_codes, policy_references, cited_provenance_ids, or chain_hash, so replay from the trace alone is not possible #228 change makes the rule normative. The three acs.policy.* arrays are all emitted whenever policy_references is non-empty, with "" for a missing value, and acs.provenance.source_id is padded to the length of acs.provenance.origin. Please add a case where policy_references is [{policy_id: "a", rule_id: "r"}, {policy_id: "b"}] and check that you get acs.policy.rule_ids equal to ["r", ""] and acs.policy.versions equal to ["", ""].
  3. This changes my answer to your Q1. Add per-hook conditional attributes to the OTel mapping #261 makes acs.evaluator conditional, which means an exporter MUST omit it when the response has no metadata.evaluator. Once Add per-hook conditional attributes to the OTel mapping #261 is on integration, please delete DEFAULT_EVALUATOR and expect acs.evaluator to be absent in those two tests. If your PR merges first, keep the fallback for now and remove it in whichever PR lands second.
  4. The failure event names are in the [Bug]: acs.decision trace event cannot carry reason_codes, policy_references, cited_provenance_ids, or chain_hash, so replay from the trace alone is not possible #228 mapping. When that PR merges, read acs.decision.failure and its class and posture values from decision_failure_event instead of DECISION_FAILURE_EVENT and the string constants. A test there pins the values, so your code and the mapping can't drift apart. If you merge first, keep the constants as Task 5 says.

The merge order note on #261 explains how to check, before merging, that the exporter reads the new list.

@rocklambros

Copy link
Copy Markdown
Collaborator

@sabasiddique1, one decision to settle item 3 of my earlier comment today, so you aren't guessing about the evaluator.

Please remove the deterministic fallback in the exporter. The spec keeps acs.evaluator conditional: it's on the span when the response carries metadata.evaluator, and absent when it doesn't. The value deterministic is correct for this Guardian. The problem is only where it comes from. It has to come from the Guardian's response, not be filled in by the exporter. Otherwise the trace says something the wire never said, and a reader can't tell the two apart.

The Guardian side is already done. #229 (6e6942d) sets metadata: { evaluator: "deterministic" } on every response this Guardian sends, fail-closed denies included. Once #229 merges, your exporter copies the value through and the span has it again.

What this means for your PR:

  1. Delete DEFAULT_EVALUATOR, and change the evaluator source to (d) => asString(d.metadata.evaluator).
  2. In the two tests that expect "deterministic", expect acs.evaluator to be absent. Add one test where the response carries metadata.evaluator and check that it appears on the span.
  3. In the README, replace the sentence I asked for in Q1 (that the trace has an evaluator the envelope log doesn't) with: "acs.evaluator appears when the Guardian's response carries metadata.evaluator. This Guardian sends it once fix(agt): implement ACS-Core wire safeguards #229 is merged."

On order: #261 will most likely merge before your PR, so please do the conditional_attributes read from item 1 of my earlier comment in this push too. After that, it doesn't matter which order #261, #229 and your PR land in.

…-exporter

Signed-off-by: Saba Siddique <sabasiddiqdev@gmail.com>
A column was dropped whenever one reference or one Provenance object
lacked the field, so a single reference without a version hid the
versions every other reference carried, and one argument without a
source_id hid the source_ids of all the others. The rule agreed on GenAI-Security-Project#228
is the opposite: the three policy arrays are always the length of
policy_references, in its order, with "" where a value is missing, and
acs.provenance.source_id is padded to the length of
acs.provenance.origin. A reader lines the columns up by position and
loses nothing that was sent.

Signed-off-by: Saba Siddique <sabasiddiqdev@gmail.com>
Each output already fails on its own: a collector that is down disables
the endpoint and nothing else, and the file keeps every span. What was
unbounded was the wait. A refused connection is retryable to the OTLP
exporter, so it kept retrying for its 10 s default before reporting, and
a flush or a shutdown behind a dead collector held the process that
long. The endpoint now gives up after 2 s.

The test drives the real OTLPTraceExporter against port 1 through a
Guardian: decisions match a Guardian with export off before and after
the endpoint dies, the file holds the spans sent after it died, the
endpoint is reported once, and both the flush that reports it and the
close that follows finish within a bound.

Signed-off-by: Saba Siddique <sabasiddiqdev@gmail.com>
The file holds reasoning text and provenance source ids, which by
provenance.json can be a URL or a file path, so other local users should
not read it: GenAI-Security-Project#71 is the same problem in the envelope log. The exporter
now creates its directory 0700 and the file 0600. The append itself was
already asynchronous and chained, so a blocked file cannot hold the
event loop that answers decisions; a test now pins that two exports
handed over back to back land as two lines in export order.

Signed-off-by: Saba Siddique <sabasiddiqdev@gmail.com>
@rocklambros

rocklambros commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

@sabasiddique1, good catch on both rows, and the second one exposed a mistake in my own Task 6 table, so we've got it from here.

refused stays guardian_error, because §6.4 counts "the Guardian returns an error instead of a decision" as a decision failure, and the Guardian's own stream already reports these as guardian_error. decision_unrenderable moves to other, since a decision did arrive. Two more fixes are mine: acs.failure.detail won't copy failure.message anymore (it carries the Guardian's own error text, and issue #228 says the detail has to be text the emitter writes), and acs.failure.posture will come from entry.posture, not outcome.

I'm running the full review now and will push the fixes to your branch, with a note here on what changed.

… into traces

- Span limits and an always-on sampler are set explicitly, so a large
  request can't grow the export queue without bound and no OTEL_*
  environment variable can drop spans or decision events.
- Provenance columns stop at 64 objects, both cut at the same index.
- A JSON-RPC error carries only its code. The message can quote the
  request, and GenAI-Security-Project#228 forbids copying error bytes into the trace.
- REPLAY_DETECTED gets no failure event, because the pair belongs to the
  earlier request (GenAI-Security-Project#228).
- acs.provenance.source_id is emitted only when some object has one.
- Shutdown is bounded, and a stalled output is reported, not waited on.
- The decision extras are emitted whether or not the mapping lists them,
  so this and GenAI-Security-Project#228 can land in either order.

Signed-off-by: rocklambros <rock@rockcyber.com>
…ctly

- Spans carry the derived session UUID and the request id, the same pair
  the Guardian's spans carry, so the two files can be joined.
- Posture comes from the entry's own posture field. Deriving it from the
  outcome reported a refusal under proceed as fail_closed.
- The detail is a fixed sentence per failure kind. The host's message
  quotes the Guardian's error text, which GenAI-Security-Project#228 forbids copying. The error
  code is parsed out as an integer, and nothing else is.
- decision_unrenderable is other, because a decision did arrive.
- One span per export. The batch queue overflowed in the synchronous
  loop, so 5000 entries wrote 2560 spans while the summary claimed 5000.
  The summary now reports what reached the file, and the command exits
  non-zero when that differs.
- Entries with no method are counted by kind, apart from unmapped
  methods. Replays, and entries with no posture, are skipped and counted.
- A host-adapter test pins the request id being the JSON-RPC id, which
  the join depends on.

Signed-off-by: rocklambros <rock@rockcyber.com>
…eate its directory

The Guardian README now says when source_id is left out, that values stop
at 4096 characters and provenance at 64 objects, that the environment can't
change sampling, that errors carry a code and no text, and how shutdown is
bounded. The converter section covers the join pair, what posture means on
a refused entry, the fixed detail, and every count. otel-collector.yaml
creates .acs/, without which the file exporter refused to start on a fresh
checkout.

Signed-off-by: rocklambros <rock@rockcyber.com>
The Guardian and the audit converter worded the same failure differently,
so a query on status text found only one side. The README no longer
promises acs.request_id on a span whose envelope has none.

Signed-off-by: rocklambros <rock@rockcyber.com>
@rocklambros

rocklambros commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

@sabasiddique1, I pushed four commits on top of yours, 53fb051..8058752. Your commits are untouched, and your Task 2 and Task 5 choices stay. Task 2 was right, too: passing FAILED through would have taken the file down along with a dead endpoint, which my snippet would have done.

Here's what each one does and why:

  • 4688d66 bounds the exporter. Span limits and an always-on sampler are now set in code, so a big request can't grow memory without limit and no OTEL_* variable can drop spans. It also stops copying JSON-RPC error text into the trace (only the code goes), skips the failure event for a replay, leaves out acs.provenance.source_id when no object has one, and bounds shutdown at 3 s. The six decision extras are emitted whether or not the mapping lists them, so this PR and issue [Bug]: acs.decision trace event cannot carry reason_codes, policy_references, cited_provenance_ids, or chain_hash, so replay from the trace alone is not possible #228 can land in either order.
  • 7f40c0a fixes the converter, including the two rows you questioned. It also stops dropping spans: the batch queue overflowed inside the synchronous loop, and 5,000 entries wrote 2,560. Spans now carry the same session UUID and request id as the Guardian's, so the two files join.
  • 9b9babd updates the README and lets otel-collector.yaml create .acs/ on a fresh checkout.
  • 8058752 uses one status sentence for a Guardian error in both writers.

All of this is validated end to end: the collector parsed every span live, decisions with a dead endpoint match an export-off run, and bun test passes 1,172 on both Bun 1.3.14 and 1.3.11. Thanks for the solid base, and for catching my Task 6 table.

@rocklambros

rocklambros commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

@afogel @bar-capsule, this is ready for your review, and it's the last piece the trace work needs this week. Saba built the exporter and the audit-log converter. I pushed four fixes on top after a review, and the comment above lists each one with its reason.

The parts worth your eyes are the converter's class table (refused stays guardian_error per §6.4, and decision_unrenderable is now other), and the acs.decision.failure rules it follows from issue #228: posture is the deployment's, the detail is a fixed sentence, and only the error code is copied. It was validated live through otelcol-contrib 0.162.0, bun test passes on Bun 1.3.11, and it merges cleanly with issue #228 in either order. Either of your approvals lands it, because I pushed last and mine no longer counts.

@bar-capsule bar-capsule left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving - nothing blocking. Follow-ups are in #274; the four worth doing before the lab handoff to RAI are in #276.

@rocklambros
rocklambros merged commit 8ab7e1e into GenAI-Security-Project:integration Oct 15, 2026
13 checks passed
@github-project-automation github-project-automation Bot moved this from In progress to Done in ACS Project Tracker Oct 15, 2026
jrcrittenden added a commit to jrcrittenden/ACS that referenced this pull request Oct 16, 2026
The HMAC signing block in handleAcsRequest ran outside any try, on
unauthenticated input. A session_id over 1024 bytes makes HKDF throw, and
a deeply nested body overflows the stack in JCS canonicalization. Either
escaped handleAcsRequest as an HTML 500, which a host reads as a hook
that never fired. The block is now wrapped. A request that throws there
cannot carry a valid signature, so its response goes out unsigned, like
any other unauthenticated request's, and the failure is reported on
stderr. The envelope log records the same response it did before, minus
the signature it could never have had.

Every AcsResult now carries metadata.evaluator "deterministic", so the
trace exporter in GenAI-Security-Project#244 reads the evaluator from the wire instead of
assuming it.

Both raised by @rocklambros on GenAI-Security-Project#229.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
jrcrittenden added a commit to jrcrittenden/ACS that referenced this pull request Oct 16, 2026
…he MCP method

GenAI-Security-Project#244's wiring tests and the sample-trace generator posted steps with no
handshake/hello, which GenAI-Security-Project#229's Guardian refuses with -32003. postRaw and
the generator now send one first; the hello gets no span.

protocols/MCP/tools/call is evaluated but otel-mapping.json maps only
steps/* and agbom/* methods, so it is named in METHODS_WITHOUT_SPAN with
that reason until the spec gives it a step_to_span entry.

The decision event now carries acs.evaluator, and the response carries a
chain_hash that covers each entry's recorded time. The two tests that
compare a Guardian with export on and off give both the same clock, and
the sample comparison sets the hash's value aside with the timestamps.
The committed sample is regenerated.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
jrcrittenden added a commit to jrcrittenden/ACS that referenced this pull request Oct 16, 2026
The HMAC signing block in handleAcsRequest ran outside any try, on
unauthenticated input. A session_id over 1024 bytes makes HKDF throw, and
a deeply nested body overflows the stack in JCS canonicalization. Either
escaped handleAcsRequest as an HTML 500, which a host reads as a hook
that never fired. The block is now wrapped. A request that throws there
cannot carry a valid signature, so its response goes out unsigned, like
any other unauthenticated request's, and the failure is reported on
stderr. The envelope log records the same response it did before, minus
the signature it could never have had.

Every AcsResult now carries metadata.evaluator "deterministic", so the
trace exporter in GenAI-Security-Project#244 reads the evaluator from the wire instead of
assuming it.

Both raised by @rocklambros on GenAI-Security-Project#229.

Signed-off-by: Jason Crittenden <jrcrittenden@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Configure OpenTelemetry collection and export in the reference implementation

3 participants