Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/reference-implementation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,25 @@
# concurrency group are an integer pull request number and a ref name GitHub validates,
# and a concurrency group is not a shell context.
#
# Scoped by path. A documentation or schema change should not pay for a Bun install.
# Scoped by path. A documentation change should not pay for a Bun install. A schema change
# does, because this tree reads the schemas at runtime.
name: Reference implementation

on:
pull_request:
paths:
- "reference-implementations/**"
# The conformance checker and the trace exporter read the schemas and
# the Trace mappings at runtime, so a spec-only change can break them.
- "specification/**"
- ".github/workflows/reference-implementation.yml"
push:
branches: ["main", "integration"]
paths:
- "reference-implementations/**"
# The conformance checker and the trace exporter read the schemas and
# the Trace mappings at runtime, so a spec-only change can break them.
- "specification/**"
- ".github/workflows/reference-implementation.yml"
workflow_dispatch:

Expand Down
2 changes: 1 addition & 1 deletion docs/spec/conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Who verifies a conformance claim: nobody, in v0.1.0. `profiles_supported` and `p

Adds deterministic Trace event emission per [Trace Events](./trace/events.md). A deployment claiming ACS-Trace MUST:

1. Emit at least one of {OTel, OCSF} for every supported ACS step, with the required attributes populated.
1. Emit at least one of {OTel, OCSF} for every supported ACS step, with the required attributes populated and each conditional attribute populated whenever its source field is present.
2. Record decisions as Trace events.
3. Carry provenance facts forward onto Trace events.

Expand Down
28 changes: 16 additions & 12 deletions docs/spec/trace/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ flowchart LR

Each ACS step produces a span whose `name` and required attributes are fixed by the table below. Decisions are recorded as span events on the parent step span, not as separate spans, so the enforcement verdict and the action it gates share a parent.

An attribute marked *conditional* comes from a field the hook payload or response may omit. An exporter MUST emit it whenever the source field is present and MUST omit it when the field is absent. An empty source array counts as absent.

The full mapping lives in [`trace/otel-mapping.json`](https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/trace/otel-mapping.json). See [Extending OpenTelemetry](./extend_opentelemetry.md) for a deeper integration guide.

| ACS step | OTel span name | Required attributes |
Expand All @@ -24,24 +26,26 @@ The full mapping lives in [`trace/otel-mapping.json`](https://genai-security-pro
| `steps/agentTrigger` | `acs.agent.trigger` | `acs.agent.id`, `acs.trigger.type` |
| `steps/userMessage` | `acs.message.user` | `acs.session.id`, `acs.content.types[]` |
| `steps/agentResponse` | `acs.message.agent` | `acs.session.id`, `acs.agent.id` |
| `steps/toolCallRequest` | `gen_ai.tool.call` | `gen_ai.tool.name`, `acs.capability`; `acs.tool.provider` (optional) |
| `steps/toolCallResult` | `gen_ai.tool.result` | `gen_ai.tool.name`, `acs.exit_status`; `acs.duration_ms` (optional) |
| `steps/knowledgeRetrieval` | `acs.knowledge.retrieval` | `acs.source.type`, `acs.results.count` |
| `steps/memoryStore` | `acs.memory.store` | `acs.memory_store.name`, `acs.operation` |
| `steps/memoryContextRetrieval` | `acs.memory.retrieval` | `acs.memory_store.name`, `acs.results.count` |
| `steps/toolCallRequest` | `gen_ai.tool.call` | `gen_ai.tool.name`; `acs.capability` (conditional); `acs.tool.provider` (optional) |
| `steps/toolCallResult` | `gen_ai.tool.result` | `gen_ai.tool.name`, `acs.exit_status`; `acs.request_id_ref` (conditional); `acs.duration_ms` (optional) |
| `steps/knowledgeRetrieval` | `acs.knowledge.retrieval` | `acs.results.count`; `acs.source.type` (conditional) |
| `steps/memoryStore` | `acs.memory.store` | `acs.memory_store.name`, `acs.operation` (both conditional) |
| `steps/memoryContextRetrieval` | `acs.memory.retrieval` | `acs.results.count`; `acs.memory_store.name` (conditional) |
| `steps/sessionEnd` | `acs.session.end` | `acs.session.reason` |
| `steps/turnStart`, `steps/turnEnd` | `acs.turn`, `acs.turn.end` | `acs.turn.id`, `acs.turn.triggered_by` / `acs.turn.outcome` |
| `steps/preCompact` | `acs.compact` | `acs.compact.entry_count`, `acs.compact.triggered_by` |
| `steps/postCompact` | `acs.compact.complete` | `acs.compact.entry_count`; `acs.compact.lineage_depth_after` (optional) |
| `steps/subagentStart`, `steps/subagentStop` | `acs.subagent`, `acs.subagent.end` | `acs.subagent.session_id`, `acs.subagent.parent_session_id`, `acs.subagent.intent_derivation` / `acs.subagent.outcome`, `acs.subagent.final_chain_hash` |
| `steps/subagentStart`, `steps/subagentStop` | `acs.subagent`, `acs.subagent.end` | `acs.subagent.session_id`, `acs.subagent.parent_session_id`, `acs.subagent.intent_derivation` / `acs.subagent.outcome`; `acs.subagent.final_chain_hash` (conditional) |
| `steps/skillRegister` | `acs.skill.register` | `acs.skill.id`, `acs.skill.digest.algorithm`, `acs.skill.digest.value`, `acs.skill.declared_capabilities` |
| `steps/skillLoad` | `acs.skill.load` | `acs.skill.id`, `acs.skill.digest.algorithm`, `acs.skill.digest.value`, `acs.skill.load_trigger`, `acs.skill.load_path` (skill ids, root first); `acs.skill.digest_verified`, `acs.skill.registration_ref` (optional) |
| `steps/skillUnload` | `acs.skill.unload` | `acs.skill.id`, `acs.skill.reason`; `acs.skill.replaced_by` (optional) |
| Decision (allow/deny/modify/ask/defer) | `acs.decision` (span event) | `acs.decision`, `acs.evaluator`, `acs.reasoning` (when present), `acs.confidence` (when present) |
| `agbom/snapshot` | `acs.agbom` | `acs.agbom.format` (`canonical`/`cyclonedx`/`spdx`/`swid`), `acs.agbom.component_count` |
| `agbom/changed` | `acs.agbom` | `acs.agbom.format` (`canonical`/`cyclonedx`/`spdx`/`swid`), `acs.agbom.change_reason` |
| Decision (allow/deny/modify/ask/defer) | `acs.decision` (span event) | `acs.decision`; `acs.evaluator`, `acs.reasoning`, `acs.confidence` (conditional) |
| `agbom/snapshot` | `acs.agbom` | `acs.agbom.format`, `acs.agbom.component_count` |
| `agbom/changed` | `acs.agbom` | `acs.agbom.format`; `acs.agbom.change_reason` (conditional) |

No AgBOM payload carries a format field, because the Observed Agent always sends the canonical form. An exporter recording the payload sets `acs.agbom.format` to `canonical`. A Guardian recording a serialization it rendered sets the value it advertises in the handshake's `agbom_serializations_supported` (`cyclonedx-1.6`, `spdx-3.0`, or `swid`).

When Provenance is attached to a hook payload, the resulting span MUST carry `acs.provenance.origin` as an attribute, and SHOULD carry `acs.provenance.source_id` and `acs.provenance.lineage_depth` when populated. v0.1 emits factual provenance attributes; trust classification is computed by the Guardian against local policy and is not a v0.1 span attribute (see [Specification §7](../instrument/specification.md#7-provenance)). Provenance lineage edges MAY be linked via OTel span links keyed by `provenance_id`.
When Provenance is attached to a hook payload, the resulting span MUST carry `acs.provenance.origin` as an attribute, and SHOULD carry `acs.provenance.source_id` when populated. v0.1 emits factual provenance attributes; trust classification is computed by the Guardian against local policy and is not a v0.1 span attribute (see [Specification §7](../instrument/specification.md#7-provenance)). Provenance lineage edges MAY be linked via OTel span links keyed by `provenance_id`.

## OCSF event classes

Expand Down Expand Up @@ -78,8 +82,8 @@ OCSF `severity_id` for decision events is set from the disposition:

A deployment claiming **ACS-Trace** MUST:

1. Emit at least one of {OTel, OCSF} for every ACS step the deployment supports, with the required attributes populated.
2. Record every decision as a Trace event carrying the disposition, evaluator identity, and reasoning when present.
1. Emit at least one of {OTel, OCSF} for every ACS step the deployment supports, with the required attributes populated and each conditional attribute populated whenever its source field is present.
2. Record every decision as a Trace event carrying the disposition, plus the evaluator identity and reasoning when the response carries them.
3. Carry provenance facts forward from the hook payload onto the Trace event so audit replay reconstructs the policy decision without consulting Guardian state separately.

Trace events MUST NOT block enforcement — failure of the Trace sink MUST NOT change the disposition returned to the Observed Agent. Deployments that do not claim ACS-Trace SHOULD still emit Trace events where feasible; the vocabulary is normative regardless of profile claim.
4 changes: 2 additions & 2 deletions docs/spec/trace/extend_opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ A typical agent session produces a tree of spans:
- A root **session** span (`acs.session`) opened by `steps/sessionStart` and closed by `steps/sessionEnd`. Carries `acs.session.id` and (when set) `acs.tenant_id`.
- One **turn** span (`acs.turn`) per agent turn, opened by `steps/turnStart` and closed by `steps/turnEnd`. Carries `acs.turn.id`. Nested turns (e.g. subagent turns inside a parent turn awaiting `subagentStop`) record `acs.turn.parent_id`.
- One **step** span per `steps/*` hook, parented to the enclosing turn (or session when the step is session-level: `agentTrigger`, `agbom/*` outside a turn).
- Each **decision** is a span event on the step span carrying `acs.decision`, `acs.evaluator` (`deterministic` / `agent` / `composite`), `acs.reasoning` (when present), and `acs.confidence` (when present). Decisions are not separate spans because the verdict and the action it gates share a parent.
- Each **decision** is a span event on the step span carrying `acs.decision`, plus `acs.evaluator` (`deterministic` / `agent` / `composite`), `acs.reasoning`, and `acs.confidence` when the response carries them. Decisions are not separate spans because the verdict and the action it gates share a parent.

This hierarchy is deterministic — implementations that follow the mapping produce comparable traces across vendors and Guardians.

## Provenance attributes

When a hook payload carries Provenance, the resulting span MUST carry `acs.provenance.origin` and SHOULD carry `acs.provenance.source_id` and `acs.provenance.lineage_depth`. The set of provenance ids cited by a Guardian decision (the response envelope's `cited_provenance_ids`) MAY be expressed as OTel span links keyed by `provenance_id`, letting tools that index span links reconstruct the lineage graph without parsing payloads.
When a hook payload carries Provenance, the resulting span MUST carry `acs.provenance.origin` and SHOULD carry `acs.provenance.source_id` when populated. The set of provenance ids cited by a Guardian decision (the response envelope's `cited_provenance_ids`) MAY be expressed as OTel span links keyed by `provenance_id`, letting tools that index span links reconstruct the lineage graph without parsing payloads.

v0.1 emits factual provenance attributes only. Trust classification is computed by the Guardian against local policy and is not a v0.1 span attribute.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ The runner starts its own Guardian on an OS-assigned port for the one check that

**The trace-pillar rows** list, for each of the six methods in scope, which OpenTelemetry attributes a consumer could emit from the envelopes alone. This tree does not claim the Trace profile, and the rows measure that non-claim.

Each row comes from the mapping's required or conditional list for that span. ✔ means a consumer can always emit the attribute. ○ means the attribute is conditional, so the envelope may omit its source and the exporter emits it only when present. ✖ means the mapping requires an attribute the wire doesn't guarantee, which is a mapping defect.

## The checks

| Check | What it measures |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,8 @@ export function renderCoverageMatrix(cells: CoverageMatrix, options: RenderOptio
}

/**
* Renders `checkTracePillar`'s `TraceRow[]` -- one line per required OTel
* attribute, against its v0.1.0 wire source and whether a downstream
* Renders `checkTracePillar`'s `TraceRow[]` -- one line per required or
* conditional OTel attribute, against its v0.1.0 wire source and whether a downstream
* consumer of the ACS wire (not this Guardian) could emit it from that
* source alone. A
* trace row pairs an attribute with a wire source; a coverage cell pairs an
Expand All @@ -303,10 +303,10 @@ export function renderCoverageMatrix(cells: CoverageMatrix, options: RenderOptio
* row (`acs.capability`) is a smaller gap than an attribute with no wire
* source at all, and the plain/coloured split is what keeps that
* distinction visible at a glance rather than collapsing both into one
* undifferentiated ✖. None of the checker's own 17 rows takes this branch
* undifferentiated ✖. None of the checker's own rows takes this branch
* today (`trace-pillar.ts`'s own test suite asserts and explains why -- every
* attribute currently in scope turned out to be a declared, merely optional
* property, not an absent one), so this split is exercised by a synthetic
* attribute currently in scope is a declared property, required or
* optional, never an absent one), so this split is exercised by a synthetic
* fixture rather than by `checkTracePillar()`'s real output; it stays
* general-purpose code because `resolveField` can still resolve a future
* attribute to genuinely absent, and this renderer should not stop telling
Expand All @@ -323,21 +323,27 @@ export function renderTraceRows(rows: TraceRow[], options: RenderOptions = {}):
const spanWidth = Math.max(0, ...rows.map((row) => row.span.length));

const lines = rows.map((row) => {
const symbol = row.emittableByWireConsumer ? "✔" : "✖";
// A conditional row that is not emittable is the list working as
// defined, so it gets its own symbol. ✖ stays reserved for a required
// attribute the wire need not carry, which is a mapping defect.
// A conditional row with no wire source at all is a defect, not the
// list working as defined, so only a present-but-optional one gets ○.
const symbol = row.emittableByWireConsumer ? "✔" : row.obligation === "conditional" && row.wireSource !== null ? "○" : "✖";
const painted = paint(symbol, row.wireSource === null ? YELLOW : null, color);
const source = row.wireSource ?? "(no wire source)";
const suffix = row.reason !== undefined ? ` ${row.reason}` : "";
return ` ${painted} ${row.attribute.padEnd(attributeWidth)} on ${row.span.padEnd(spanWidth)} <- ${source}${suffix}`;
});

return [
"Trace pillar (trace/otel-mapping.json): each required OTel attribute against its v0.1.0 wire source, and " +
"Trace pillar (trace/otel-mapping.json): each required or conditional OTel attribute against its v0.1.0 wire source, and " +
"whether a downstream consumer of the ACS wire -- not this Guardian -- could emit it from that source " +
"alone. This implementation does NOT claim the Trace pillar; this table is what that declaration is " +
"measured against.",
"",
...lines,
"",
"Legend: ✔ emittable by a wire consumer alone ✖ not emittable (reason inline)",
"Legend: ✔ emittable by a wire consumer alone ○ conditional: emitted when the envelope carries it " +
"✖ required but not emittable (reason inline)",
].join("\n");
}
Loading
Loading