Skip to content
Open
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
6 changes: 3 additions & 3 deletions docs/spec/trace/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Some unusable responses are not decision failures, because the spec already defi

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` when populated. Both are string arrays, also for a single Provenance object. `acs.provenance.source_id` is emitted whenever at least one attached object has a `source_id`. It then has the same length and order as `acs.provenance.origin`, with `""` where an object has none. 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.ids` and `acs.provenance.origin` as attributes, and SHOULD carry `acs.provenance.source_id` when populated. All three are parallel string arrays, also for a single Provenance object. Argument keys are visited in code-point order, outputs by index, and a Provenance object reached more than once is listed once at its first occurrence (deduplicated by `provenance_id`). `acs.provenance.source_id` is emitted whenever at least one attached object has a `source_id`. It then has the same length and order as `acs.provenance.ids` and `acs.provenance.origin`, with `""` where an object has none. A reader joins `acs.cited_provenance_ids` on an `acs.decision` event directly to `acs.provenance.ids` on the step span. 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 @@ -114,7 +114,7 @@ SIEM content reads native fields, not `unmapped`, so each decision event also ca
- `authorizations` has one entry per `policy_references` entry, in the same order, whenever `policy_references` is non-empty. Each entry's `policy` carries `uid`, `version` and `name` from `policy_id`, `policy_version` and `policy_name`. An entry with neither `policy_id` nor `policy_name` MUST be an `authorizations` entry with no `policy` object, so positions still line up with `policy_references`: OCSF requires a `policy` to carry `uid` or `name`.
- `finding_info.desc` SHOULD carry `reasoning`.

The decision's `request_id`, `decision`, `reasoning`, `reason_codes`, `policy_references`, `cited_provenance_ids`, `chain_hash`, and `metadata` also ride under `unmapped.acs` with the response's field names, as the exact copy for replay. Every OCSF step event carries `unmapped.acs.request_id` and `unmapped.acs.session_id`. The decision event carries the gated step's pair (`join_fields` in the mapping). A `steps/toolCallResult` event also carries `unmapped.acs.request_id_ref` when the payload has it. OCSF does not validate `unmapped`, so each field takes the type of its definition in the response envelope.
The decision's `request_id`, `decision`, `reasoning`, `reason_codes`, `policy_references`, `cited_provenance_ids`, `chain_hash`, and `metadata` also ride under `unmapped.acs` with the response's field names, as the exact copy for replay. Every OCSF step event carries `unmapped.acs.request_id` and `unmapped.acs.session_id`. When Provenance is attached to the originating hook, the OCSF step event MUST carry each Provenance object under `unmapped.acs.provenance` as `{provenance_id, origin, source_id when present}`, visiting argument keys in code-point order and outputs by index and listing each `provenance_id` once at its first occurrence. The decision event carries the gated step's pair (`join_fields` in the mapping), and its `unmapped.acs.cited_provenance_ids` joins directly to `unmapped.acs.provenance[].provenance_id` on the step event. A `steps/toolCallResult` event also carries `unmapped.acs.request_id_ref` when the payload has it. OCSF does not validate `unmapped`, so each field takes the type of its definition in the response envelope.

A Guardian that finishes a decision after the negotiated timeout still emits the decision event. A late deny under fail-open means the policy said no and the action ran, so suppressing it would leave OCSF silent exactly when someone slows the Guardian down. `action_id` and `disposition_id` stay the verdict. `finding_info.types` includes `late_decision_fail_open` or `late_decision_fail_closed`, from the `on_decision_failure` posture the Guardian sent in its ServerHello. `unmapped.acs.failure` carries `class`, `posture`, `timeout_ms`, `elapsed_ms` and `payload_bytes`, with the values of the matching `acs.failure.*` attributes. `is_alert` follows the table above, and is also true for any verdict other than `allow` under fail-open, because the action ran without what the policy required. `severity_id` stays the value the table gives the verdict, so a late decision is the one case where `is_alert` can be true below severity 3. A reader MUST NOT count a decision event whose `finding_info.types` includes `late_decision_fail_open` or `late_decision_fail_closed` as an enforced outcome. The Guardian measures its own processing time, not network time, so a decision can still arrive late and look on time. This is the first piece of an OCSF form for decision failures. v0.1.0 defines none for the other failure classes.

Expand All @@ -124,6 +124,6 @@ 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 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. Carry the decision's reason codes, policy references, cited provenance ids, and chain hash onto the decision event when the response carries them. Arrays follow the padding rules above. A reader can then tell which policies, rules, and reason codes produced a decision without consulting Guardian state. Joining a cited provenance id to its provenance facts, and verifying the chain head, still need the Guardian's ContextEntries and the signed response.
3. Carry provenance facts forward from the hook payload onto the Trace event. Carry the decision's reason codes, policy references, cited provenance ids, and chain hash onto the decision event when the response carries them. Arrays follow the padding rules above. A reader can then tell which policies, rules, reason codes, and provenance facts produced a decision without consulting Guardian state. Verifying the chain head still needs the Guardian's ContextEntries and the signed response.

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.
2 changes: 1 addition & 1 deletion docs/spec/trace/extend_ocsf.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,6 @@ ACS events map to OCSF 1.9.0 event classes. The normative class assignments and
- **`actor.user.type_id: 99`** is the OCSF "Other" sentinel; pair with `actor.user.type: "AI Agent"` so SIEM filters can distinguish agent identities from human ones.
- **Native fields first.** SIEM content reads native fields, not `unmapped`. A decision event puts the join pair in `metadata.correlation_uid`, the agent and the gated action in `evidences`, and the policies in `authorizations`. The example above shows each, and the [Trace Events](./events.md#ocsf-event-classes) page has the rules, including what a late decision carries.
- **`unmapped.acs`** carries ACS-specific facts that don't have direct OCSF equivalents in OCSF 1.9.0. On a step event it carries `request_id`, `session_id`, and facts from the request that have no OCSF attribute of their own. On a decision event it carries the fields that `join_fields` and `acs_fields` in `ocsf-mapping.json` list. Backends that index `unmapped` keep this fully searchable.
- **Provenance** flows onto OCSF events under `unmapped.acs.provenance`. Trust classification is omitted on the wire for v0.1; backends that compute trust apply it as an enrichment step.
- **Provenance** flows onto OCSF events under `unmapped.acs.provenance` as `{provenance_id, origin, source_id when present}` in code-point argument-key / output-index order, deduplicated by `provenance_id`, so `unmapped.acs.cited_provenance_ids` on a decision event resolves to its provenance facts on the step event. Trust classification is omitted on the wire for v0.1; backends that compute trust apply it as an enrichment step.

For more worked examples — including A2A and MCP wrapped events — see [Implementation examples](./OCSF/implementation_examples.md).
25 changes: 20 additions & 5 deletions docs/spec/trace/extend_opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool

```json
{
"traceId": "13717f291e2368066d2fa3f2434ed2f6",
"spanId": "2a7efedbb7ee3881",
"traceId": "a2537e45b466e51a3937209d079d59e9",
"spanId": "27a07742699f3508",
"name": "gen_ai.tool.call",
"kind": 1,
"startTimeUnixNano": "1792534253716796645",
"endTimeUnixNano": "1792534253780000000",
"startTimeUnixNano": "1793284859067311142",
"endTimeUnixNano": "1793284859246000000",
"attributes": [
{
"key": "acs.session.id",
Expand All @@ -49,6 +49,21 @@ The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool
"stringValue": "Bash"
}
},
{
"key": "acs.provenance.ids",
"value": {
"arrayValue": {
"values": [
{
"stringValue": "prov-sample-1"
},
{
"stringValue": "prov-sample-2"
}
]
}
}
},
{
"key": "acs.provenance.origin",
"value": {
Expand Down Expand Up @@ -146,7 +161,7 @@ The span carries the join pair (`acs.session.id` and `acs.request_id`), the tool
}
],
"name": "acs.decision",
"timeUnixNano": "1792534253780000000",
"timeUnixNano": "1793284859246000000",
"droppedAttributesCount": 0
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ export function checkTracePillar(): TraceRow[] {
// narrower rule".
const PROVENANCE_SPAN = "(every step span, when Provenance is attached)";
for (const attribute of otelMapping.properties.provenance_attributes.properties.required.default) {
const field = attribute.slice("acs.provenance.".length);
const field = attribute === "acs.provenance.ids" ? "provenance_id" : attribute.slice("acs.provenance.".length);
rows.push(
buildRow(attribute, PROVENANCE_SPAN, "required", {
file: "provenance.json",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,12 @@ describe("checkTracePillar resolves every row against the v0.1.0 schema files at
expect(rows.some((r) => r.span === "acs.decision.failure" || r.attribute.startsWith("acs.failure."))).toBe(false);
});

it("acs.provenance.origin is emittable -- provenance.json's own `required` list includes `origin`", () => {
const row = only("acs.provenance.origin");
expect(row.emittableByWireConsumer).toBe(true);
expect(row.wireSource).toContain("provenance.json");
it("acs.provenance.ids and acs.provenance.origin are emittable -- provenance.json's own `required` list includes `provenance_id` and `origin`", () => {
for (const attr of ["acs.provenance.ids", "acs.provenance.origin"]) {
const row = only(attr);
expect(row.emittableByWireConsumer).toBe(true);
expect(row.wireSource).toContain("provenance.json");
}
});

it("emittableByWireConsumer is false wherever wireSource is null -- an invariant of buildRow, currently unexercised by real data", () => {
Expand Down Expand Up @@ -223,9 +225,9 @@ describe("checkTracePillar resolves every row against the v0.1.0 schema files at
"acs.message.agent": 2, // steps/agentResponse: acs.session.id, acs.agent.id
"acs.decision": 12, // decision_event: 1 required + 11 conditional
"(every step span)": 2, // step_span_attributes: acs.request_id, acs.session.id
"(every step span, when Provenance is attached)": 1, // provenance_attributes.required
"(every step span, when Provenance is attached)": 2, // provenance_attributes.required: acs.provenance.ids, acs.provenance.origin
});
expect(rows).toHaveLength(26);
expect(rows).toHaveLength(27);
});

it("reads both obligation lists for each in-scope step span -- the rows are exactly required_attributes plus conditional_attributes, and never optional_attributes", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -361,31 +361,39 @@ export const DECISION_DETAIL_NAMES = [
* `derived_from`, say) would be a value nobody sent.
*/
export const PROVENANCE_ATTRIBUTE_SOURCES: Record<string, (provenance: JsonObject) => string | undefined> = {
"acs.provenance.ids": (p) => asString(p.provenance_id),
"acs.provenance.origin": (p) => asString(p.origin),
"acs.provenance.source_id": (p) => asString(p.source_id),
};

/** The Provenance objects a payload carries, in wire order: one per
* argument that has one, in the order the parsed object holds them, then one
* per output that has one, in array order. "Parsed" order rather than the
* bytes' order because `JSON.parse` has already hoisted integer-like
* argument names ahead of the rest (envelope-log-sink.ts notes `{"0": ...}`
* is a real shape), so this is the order every reader of the envelope log
* sees too. The two never meet on one payload (the request gate carries
* `arguments`, the result gate `outputs`), and an argument or output
* without one contributes nothing. */
/** The Provenance objects a payload carries, in deterministic order:
* arguments visited by key in code-point order, then outputs by array index,
* with a Provenance object reached more than once emitted once at its first
* occurrence (deduplicated by `provenance_id`). */
export function provenanceObjects(payload: JsonObject): JsonObject[] {
const args = asObject(payload.arguments) ?? {};
const argCarriers = Object.keys(args)
.sort()
.map((key) => args[key]);
const carriers: unknown[] = [
...Object.values(asObject(payload.arguments) ?? {}),
...argCarriers,
...(Array.isArray(payload.outputs) ? payload.outputs : []),
];
const found: JsonObject[] = [];
const seenIds = new Set<string>();
for (const carrier of carriers) {
if (found.length === PROVENANCE_COLUMN_LIMIT) {
break;
}
const provenance = asObject(asObject(carrier)?.provenance);
if (provenance !== undefined) {
const id = asString(provenance.provenance_id);
if (id !== undefined) {
if (seenIds.has(id)) {
continue;
}
seenIds.add(id);
}
found.push(provenance);
}
}
Expand Down
Loading
Loading