From fabc52b357ba593945efc48b41f369b2917e66b1 Mon Sep 17 00:00:00 2001 From: Raghuvar Karthik Durga <177662415+drk-ram@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:40:34 -0500 Subject: [PATCH] fix(trace): align OCSF provenance to unmapped.acs.provenance and add acs.provenance.ids Update ocsf-mapping.json provenance_metadata to place Provenance objects under unmapped.acs.provenance as {provenance_id, origin, source_id when present}, add acs.provenance.ids to otel-mapping.json provenance_attributes, and define deterministic ordering (argument keys in code-point order, outputs by index, deduplicated by provenance_id) so cited_provenance_ids on a decision event joins to its provenance facts on the step event. Fixes #260 Fixes #262 Signed-off-by: Raghuvar Karthik Durga <177662415+drk-ram@users.noreply.github.com> --- docs/spec/trace/events.md | 6 ++-- docs/spec/trace/extend_ocsf.md | 2 +- docs/spec/trace/extend_opentelemetry.md | 25 +++++++++++++---- .../packages/conformance/src/trace-pillar.ts | 2 +- .../conformance/test/trace-pillar.test.ts | 14 ++++++---- .../agt/packages/guardian/src/otel-mapping.ts | 28 ++++++++++++------- .../agt/samples/trace/acs-trace-sample.jsonl | 4 +-- specification/v0.1.0/trace/ocsf-mapping.json | 10 +++---- specification/v0.1.0/trace/otel-mapping.json | 4 +-- tests/test_trace_mapping_defaults.py | 11 ++++++++ 10 files changed, 71 insertions(+), 35 deletions(-) diff --git a/docs/spec/trace/events.md b/docs/spec/trace/events.md index c23d6440..de75ed58 100644 --- a/docs/spec/trace/events.md +++ b/docs/spec/trace/events.md @@ -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 @@ -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. @@ -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. diff --git a/docs/spec/trace/extend_ocsf.md b/docs/spec/trace/extend_ocsf.md index b8efdfe1..c6781736 100644 --- a/docs/spec/trace/extend_ocsf.md +++ b/docs/spec/trace/extend_ocsf.md @@ -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). diff --git a/docs/spec/trace/extend_opentelemetry.md b/docs/spec/trace/extend_opentelemetry.md index 16c460a4..c8fe51cc 100644 --- a/docs/spec/trace/extend_opentelemetry.md +++ b/docs/spec/trace/extend_opentelemetry.md @@ -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", @@ -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": { @@ -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 } ], diff --git a/reference-implementations/agt/packages/conformance/src/trace-pillar.ts b/reference-implementations/agt/packages/conformance/src/trace-pillar.ts index 76edee91..eab0cb77 100644 --- a/reference-implementations/agt/packages/conformance/src/trace-pillar.ts +++ b/reference-implementations/agt/packages/conformance/src/trace-pillar.ts @@ -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", diff --git a/reference-implementations/agt/packages/conformance/test/trace-pillar.test.ts b/reference-implementations/agt/packages/conformance/test/trace-pillar.test.ts index 2f0cb139..3b5a99d0 100644 --- a/reference-implementations/agt/packages/conformance/test/trace-pillar.test.ts +++ b/reference-implementations/agt/packages/conformance/test/trace-pillar.test.ts @@ -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", () => { @@ -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", () => { diff --git a/reference-implementations/agt/packages/guardian/src/otel-mapping.ts b/reference-implementations/agt/packages/guardian/src/otel-mapping.ts index 0d9b900b..690d67e4 100644 --- a/reference-implementations/agt/packages/guardian/src/otel-mapping.ts +++ b/reference-implementations/agt/packages/guardian/src/otel-mapping.ts @@ -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 | 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(); 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); } } diff --git a/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl b/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl index bb226510..7644cfe6 100644 --- a/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl +++ b/reference-implementations/agt/samples/trace/acs-trace-sample.jsonl @@ -1,2 +1,2 @@ -{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"acs-reference-guardian"}}],"droppedAttributesCount":0},"scopeSpans":[{"scope":{"name":"acs-reference-guardian"},"spans":[{"traceId":"7752aef7c63150fcc88f856974d22f5b","spanId":"7bd24ca6a5dd31f0","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792534253539454676","endTimeUnixNano":"1792534253649000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.capability","value":{"stringValue":"process.exec"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}}],"name":"acs.decision","timeUnixNano":"1792534253649000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"180a78a6f4b17aa91f6a952195321255","spanId":"003c1193df7f552a","name":"gen_ai.tool.result","kind":1,"startTimeUnixNano":"1792534253650630391","endTimeUnixNano":"1792534253716000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b02"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.exit_status","value":{"stringValue":"success"}},{"key":"acs.request_id_ref","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}}],"name":"acs.decision","timeUnixNano":"1792534253716000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"13717f291e2368066d2fa3f2434ed2f6","spanId":"2a7efedbb7ee3881","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792534253716796645","endTimeUnixNano":"1792534253780000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b03"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.provenance.origin","value":{"arrayValue":{"values":[{"stringValue":"user_input"},{"stringValue":"agent_generated"}]}}},{"key":"acs.provenance.source_id","value":{"arrayValue":{"values":[{"stringValue":"sample-user-1"},{"stringValue":""}]}}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"deny"}},{"key":"acs.reasoning","value":{"stringValue":"This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0."}},{"key":"acs.reason_codes","value":{"arrayValue":{"values":[{"stringValue":"destructive_shell_command_blocked"}]}}},{"key":"acs.policy.ids","value":{"arrayValue":{"values":[{"stringValue":"agt_stock"}]}}},{"key":"acs.policy.versions","value":{"arrayValue":{"values":[{"stringValue":""}]}}},{"key":"acs.policy.rule_ids","value":{"arrayValue":{"values":[{"stringValue":"destructive_shell_command_blocked"}]}}}],"name":"acs.decision","timeUnixNano":"1792534253780000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"2cadee9c2b0236f4cf64ecf54933d071","spanId":"d2666e080ce88014","name":"guardian.rejected_request","kind":1,"startTimeUnixNano":"1792534253779859236","endTimeUnixNano":"1792534253781000000","attributes":[{"key":"guardian.rejected.reason","value":{"stringValue":"envelope_invalid"}},{"key":"guardian.rejected.claimed_session_id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"guardian.rejected.claimed_request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01"}},{"key":"guardian.rejected.error_code","value":{"intValue":-32010}}],"droppedAttributesCount":0,"events":[],"droppedEventsCount":0,"status":{"code":2,"message":"The Guardian could not vouch for this request, so it carries no ACS ids or events."},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"f92f9aa4700acfd946b59a2fe19e489e","spanId":"2e4aa2bc8b522e06","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792534253780950247","endTimeUnixNano":"1792534255851000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b05"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}}],"name":"acs.decision","timeUnixNano":"1792534255851000000","droppedAttributesCount":0},{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"timeout"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"guardian"}},{"key":"acs.failure.timeout_ms","value":{"intValue":1500}},{"key":"acs.failure.elapsed_ms","value":{"intValue":2070}},{"key":"acs.failure.payload_bytes","value":{"intValue":367}}],"name":"acs.decision.failure","timeUnixNano":"1792534255851000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257}]}]}]} -{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"acs-reference-audit-converter"}}],"droppedAttributesCount":0},"scopeSpans":[{"scope":{"name":"acs-reference-audit-converter"},"spans":[{"traceId":"8ad7d2634fb9d194b1414a0033d90467","spanId":"8572a4b873e3e58e","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1792534255853000000","endTimeUnixNano":"1792534255853000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b06"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"unreachable"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"observed_agent"}},{"key":"acs.failure.detail","value":{"stringValue":"The host could not reach the Guardian."}}],"name":"acs.decision.failure","timeUnixNano":"1792534255853000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":2,"message":"The host could not reach the Guardian."},"links":[],"droppedLinksCount":0,"flags":257}]}]}]} +{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"acs-reference-guardian"}}],"droppedAttributesCount":0},"scopeSpans":[{"scope":{"name":"acs-reference-guardian"},"spans":[{"traceId":"f295fdcb55c96b9f7dbc652a58fa2e5a","spanId":"d7b3d6e333e9274c","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1793284858479053666","endTimeUnixNano":"1793284858859000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.capability","value":{"stringValue":"process.exec"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}}],"name":"acs.decision","timeUnixNano":"1793284858859000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"e249124555d747f193a8a2d836d70cf2","spanId":"7690d76af30d9133","name":"gen_ai.tool.result","kind":1,"startTimeUnixNano":"1793284858860888292","endTimeUnixNano":"1793284859067000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b02"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.exit_status","value":{"stringValue":"success"}},{"key":"acs.request_id_ref","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}}],"name":"acs.decision","timeUnixNano":"1793284859067000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"a2537e45b466e51a3937209d079d59e9","spanId":"27a07742699f3508","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1793284859067311142","endTimeUnixNano":"1793284859246000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b03"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}},{"key":"acs.provenance.ids","value":{"arrayValue":{"values":[{"stringValue":"prov-sample-1"},{"stringValue":"prov-sample-2"}]}}},{"key":"acs.provenance.origin","value":{"arrayValue":{"values":[{"stringValue":"user_input"},{"stringValue":"agent_generated"}]}}},{"key":"acs.provenance.source_id","value":{"arrayValue":{"values":[{"stringValue":"sample-user-1"},{"stringValue":""}]}}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"deny"}},{"key":"acs.reasoning","value":{"stringValue":"This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0."}},{"key":"acs.reason_codes","value":{"arrayValue":{"values":[{"stringValue":"destructive_shell_command_blocked"}]}}},{"key":"acs.policy.ids","value":{"arrayValue":{"values":[{"stringValue":"agt_stock"}]}}},{"key":"acs.policy.versions","value":{"arrayValue":{"values":[{"stringValue":""}]}}},{"key":"acs.policy.rule_ids","value":{"arrayValue":{"values":[{"stringValue":"destructive_shell_command_blocked"}]}}}],"name":"acs.decision","timeUnixNano":"1793284859246000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"46895260acdb5f9c387b09acdbbe20cf","spanId":"9175fd5380fdb734","name":"guardian.rejected_request","kind":1,"startTimeUnixNano":"1793284859247579955","endTimeUnixNano":"1793284859248000000","attributes":[{"key":"guardian.rejected.reason","value":{"stringValue":"envelope_invalid"}},{"key":"guardian.rejected.claimed_session_id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"guardian.rejected.claimed_request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b01"}},{"key":"guardian.rejected.error_code","value":{"intValue":-32010}}],"droppedAttributesCount":0,"events":[],"droppedEventsCount":0,"status":{"code":2,"message":"The Guardian could not vouch for this request, so it carries no ACS ids or events."},"links":[],"droppedLinksCount":0,"flags":257},{"traceId":"0ebf36d2b2c07ddd5de22a207b4a24ae","spanId":"8995c81ca7ef3b58","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1793284859248806699","endTimeUnixNano":"1793284861467000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b05"}},{"key":"gen_ai.tool.name","value":{"stringValue":"Bash"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.decision","value":{"stringValue":"allow"}}],"name":"acs.decision","timeUnixNano":"1793284861466000000","droppedAttributesCount":0},{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"timeout"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"guardian"}},{"key":"acs.failure.timeout_ms","value":{"intValue":1500}},{"key":"acs.failure.elapsed_ms","value":{"intValue":2218}},{"key":"acs.failure.payload_bytes","value":{"intValue":367}}],"name":"acs.decision.failure","timeUnixNano":"1793284861467000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0,"flags":257}]}]}]} +{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"acs-reference-audit-converter"}}],"droppedAttributesCount":0},"scopeSpans":[{"scope":{"name":"acs-reference-audit-converter"},"spans":[{"traceId":"5e02bdb556f920d5dc2b67c4120900ab","spanId":"0e8712e2f9b3ec12","name":"gen_ai.tool.call","kind":1,"startTimeUnixNano":"1793284861479000000","endTimeUnixNano":"1793284861479000000","attributes":[{"key":"acs.session.id","value":{"stringValue":"5a3c1e7e-0b1d-4c6a-9e2f-7d4b8c0a1f01"}},{"key":"acs.request_id","value":{"stringValue":"1b6f0c2a-3d4e-4f5a-8b6c-7d8e9f0a1b06"}}],"droppedAttributesCount":0,"events":[{"attributes":[{"key":"acs.failure.class","value":{"stringValue":"unreachable"}},{"key":"acs.failure.posture","value":{"stringValue":"fail_open"}},{"key":"acs.failure.reported_by","value":{"stringValue":"observed_agent"}},{"key":"acs.failure.detail","value":{"stringValue":"The host could not reach the Guardian."}}],"name":"acs.decision.failure","timeUnixNano":"1793284861479000000","droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":2,"message":"The host could not reach the Guardian."},"links":[],"droppedLinksCount":0,"flags":257}]}]}]} diff --git a/specification/v0.1.0/trace/ocsf-mapping.json b/specification/v0.1.0/trace/ocsf-mapping.json index 879d4b29..b9e59d01 100644 --- a/specification/v0.1.0/trace/ocsf-mapping.json +++ b/specification/v0.1.0/trace/ocsf-mapping.json @@ -160,17 +160,17 @@ }, "provenance_metadata": { "type": "object", - "description": "When Provenance is attached to the originating hook, the OCSF event MUST carry provenance facts in 'enrichments' (an OCSF-recognized extensibility field). Each enrichment carries name='acs_provenance_*' and value={origin, source_id, ...}. v0.1 carries factual provenance in enrichments; trust classification is computed by the Guardian against local policy and is not a v0.1 enrichment (see §7.1).", + "description": "When Provenance is attached to the originating hook, the OCSF event MUST carry each Provenance object under unmapped.acs.provenance as {provenance_id, origin, source_id when present}. Argument keys are visited in code-point order, outputs by index, and a Provenance object reached more than once is emitted once at its first occurrence (deduplicated by provenance_id). Trust classification is computed by the Guardian against local policy and is not a v0.1 field (see §7.1).", "properties": { - "required_enrichments": { + "required_fields": { "type": "array", "items": { "type": "string" }, - "default": ["acs_provenance_origin"] + "default": ["provenance_id", "origin"] }, - "optional_enrichments": { + "optional_fields": { "type": "array", "items": { "type": "string" }, - "default": ["acs_provenance_source_id"] + "default": ["source_id"] } } } diff --git a/specification/v0.1.0/trace/otel-mapping.json b/specification/v0.1.0/trace/otel-mapping.json index f513fe6a..5c60f3e3 100644 --- a/specification/v0.1.0/trace/otel-mapping.json +++ b/specification/v0.1.0/trace/otel-mapping.json @@ -201,12 +201,12 @@ }, "provenance_attributes": { "type": "object", - "description": "When Provenance is attached to a hook payload, the resulting span MUST carry provenance facts as attributes. Lineage edges MAY be linked via OTel span links keyed by provenance_id. v0.1 emits the factual provenance fields (origin, source_id) on the wire and on the span. Both are string arrays, also for a single Provenance object. acs.provenance.source_id is emitted whenever at least one attached Provenance object has a source_id, and omitted otherwise. When emitted, it has the same length and order as acs.provenance.origin, with an empty string where an object has none. Trust classification is computed by the Guardian against local policy and is not a v0.1 span attribute (see §7.1).", + "description": "When Provenance is attached to a hook payload, the resulting span MUST carry provenance facts as attributes. Lineage edges MAY be linked via OTel span links keyed by provenance_id. v0.1 emits the factual provenance fields (provenance_id, origin, source_id) on the wire and on the span as 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 emitted once at its first occurrence (deduplicated by provenance_id). acs.provenance.source_id is emitted whenever at least one attached Provenance object has a source_id, and omitted otherwise. When emitted, it has the same length and order as acs.provenance.ids and acs.provenance.origin, with an empty string where an object has none. Trust classification is computed by the Guardian against local policy and is not a v0.1 span attribute (see §7.1).", "properties": { "required": { "type": "array", "items": { "type": "string" }, - "default": ["acs.provenance.origin"] + "default": ["acs.provenance.ids", "acs.provenance.origin"] }, "optional": { "type": "array", diff --git a/tests/test_trace_mapping_defaults.py b/tests/test_trace_mapping_defaults.py index b99314dc..a1428f07 100644 --- a/tests/test_trace_mapping_defaults.py +++ b/tests/test_trace_mapping_defaults.py @@ -172,3 +172,14 @@ def test_failure_classes_are_the_ones_decided_on_issue_37(): "timeout", "unreachable", "malformed_response", "guardian_error", "observed_agent_error", "signature_invalid", "handshake_failure", "other", } + + +def test_provenance_join_fields_match_issues_260_and_262(): + otel_prov = json.loads((TRACE / "otel-mapping.json").read_text(encoding="utf-8"))["properties"]["provenance_attributes"]["properties"] + assert otel_prov["required"]["default"] == ["acs.provenance.ids", "acs.provenance.origin"] + assert otel_prov["optional"]["default"] == ["acs.provenance.source_id"] + + ocsf_prov = json.loads((TRACE / "ocsf-mapping.json").read_text(encoding="utf-8"))["properties"]["provenance_metadata"]["properties"] + assert ocsf_prov["required_fields"]["default"] == ["provenance_id", "origin"] + assert ocsf_prov["optional_fields"]["default"] == ["source_id"] +