Skip to content

Carry the decision's replay facts and decision failures on Trace - #275

Merged
rocklambros merged 9 commits into
integrationfrom
spec/228-decision-event-v2
Oct 15, 2026
Merged

rocklambros merged 9 commits into
integrationfrom
spec/228-decision-event-v2

Conversation

@rocklambros

@rocklambros rocklambros commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

What changed

The acs.decision Trace event now carries what a reader needs to replay a decision from the trace alone: reason codes, policy references, cited provenance ids, and the chain hash. Every step span carries the join pair, and decision failures get their own event. The OCSF decision class now uses the Security Control profile, with native fields a SIEM can read. Each of the ten proposals below was answered on issue #228, and this PR applies the answers as given.

Which issue does this implement

Closes #228
Closes #263
Refs #37
Refs #235
Refs #262

  • This is an editorial correction (typo, grammar, link, formatting) with no change in
    meaning, so it needs no issue

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)

Specification changes

Breaking for implementers? Yes. The Spec leads accepted these breaking changes, since ACS is pre-1.0. They ship in release 0.1.4, a patch release, and the specification version stays v0.1.0.

  • Every OTel step span now requires acs.request_id and acs.session.id.
  • The OCSF decision class now requires finding_info, finding_info.title, action_id, action, disposition_id, disposition, evidences and metadata.correlation_uid, and lists the security_control profile. It no longer uses verdict_id.
  • defer maps to disposition_id 99, not 14.
  • A late decision is now emitted on OCSF, flagged in finding_info.types. Before, it was suppressed.
  • policy_id, policy_version, policy_name, rule_id and Provenance source_id now reject the empty string.

The ten proposals and their answers

  1. Join keys on every step span. Accepted. acs.request_id and acs.session.id are required, and joins key on the pair.
  2. OCSF decision class. Accepted with changes:
    • action_id (allow 1, deny 2, modify 4, ask 99, defer 99) sits beside disposition_id, with defer at 99.
    • evidences stays required and carries the agent, the session, and the gated action.
    • authorizations carries the policies, and metadata.correlation_uid carries the join pair.
    • Guardians SHOULD NOT set verdict_id or status_id.
    • is_alert is true exactly when severity_id is 3 or higher. That makes it true for deny, ask and defer, and false for allow and modify.
  3. Provenance arrays. Accepted as written.
  4. What a decision event proves. Accepted, with one change. A late decision is emitted on OCSF and flagged, not suppressed. Its failure facts go under unmapped.acs.failure, and it alerts on any verdict other than allow under fail-open.
  5. Counting failures. Accepted. acs.failure.posture now reads "the deployment's failure posture".
  6. Unusable responses the spec already resolves. Accepted as written.
  7. Policy arrays emitted together. Accepted. The minLength: 1 from issue [Bug] Empty strings in policy_references and source_id collide with Trace padding #263 is pulled into this release.
  8. acs.chain_hash proves nothing on its own. Accepted, with one sentence added: different chain_hash values for one pair across the two parties' streams are an integrity event under §8.6.
  9. Trace values are untrusted. Accepted. The rule now covers OTel attributes and OCSF fields.
  10. OCSF joins. Accepted with changes. Every OCSF event sets metadata.correlation_uid, finding_info.desc SHOULD carry reasoning, and policies also go in authorizations.

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

From reference-implementations/agt/, bun run typecheck is clean and bun test passes. The deny example in extend_ocsf.md, and a late fail-open variant of it, pass the OCSF 1.9.0 validator at schema.ocsf.io with no errors or warnings.

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

It does, and it improves what a SOC can see. A late deny under fail-open used to produce no OCSF event, which left OCSF silent whenever someone slowed the Guardian down. It now produces a flagged, alerting event. ASK and DEFER decisions alert, because an ASK can time out to allow and Trace has no event for how either one ends. Trace values are marked as untrusted data for consumers.

The acs.decision event could not carry reason_codes, policy_references,
cited_provenance_ids or chain_hash, so a decision could not be replayed
from the trace alone. Bar decided on #228 to add them, with Maurits's
padding rule: acs.policy.ids, acs.policy.versions and acs.policy.rule_ids
have the same length and order as policy_references, with an empty string
where an entry lacks the field. acs.provenance.source_id follows the same
rule against acs.provenance.origin.

Every step span now carries acs.request_id, the step's own request_id,
through a new step_span_attributes list. acs.request_id_ref on a result
span had nothing to join to without it.

acs.decision.failure is defined next to decision_event with the names Bar
chose on #37, the values each enumerated attribute takes, and her
recording rule. The Guardian cases are the two she listed. The spec prose
in sections 4.1 and 6.4 and the sessionEnd count stay with #37.

On the OCSF side, decision_class carries the same facts under
unmapped.acs. verdict_id is an analyst's verdict on an incident, so it
gives way to disposition_id, with a mapping from each ACS disposition.
disposition_id comes from OCSF's Security Control profile, which decision
events now declare. evidences leaves required_fields, because OCSF only
recommends it and ACS had no rule for what fills it. The deny example
validates against OCSF 1.9.0.

trace-pillar.ts learns the six decision attributes and acs.request_id in
the same commit. Its decisionAttributeSite throws on an unknown decision
attribute, so the mapping change alone breaks the reference
implementation's tests. acs.decision.failure is deliberately not
measured, because its attributes describe what the recording party saw,
not a field of either envelope. A test pins that choice.

Signed-off-by: Rock Lambros <rock@rockcyber.com>
A review of this change found that the new fields read as stronger
evidence than they are.

A late deny was recorded as an enforced block. When a Guardian finishes
after the negotiated timeout, the Observed Agent has already applied its
failure posture, so the acs.decision event and an OCSF Blocked
disposition describe a verdict that never took effect. The decision event
now says it records the verdict, not the applied outcome, and a reader
must not count it as enforced when a failure event sits on the same span.
OCSF has no failure form yet, so a Guardian must not emit an OCSF
decision event for a late decision.

acs.chain_hash is a copy of a response field. The trace carries neither
the signature nor the ContextEntries a verifier needs, so the text now
says so, and requirement 3 of the ACS-Trace bar no longer claims replay
without Guardian state for provenance joins and the chain head.

The decision failure event now carries the caveat Bar gave on #37: the
Observed Agent reports on its own conduct, so a count is a lower bound.
It also gains a counting rule, a bound on acs.failure.detail, and a note
that a response breaking sections 6.3 or 6.5 is a DENY, not a failure.

Smaller fixes: the three policy arrays are emitted together or not at
all, provenance attributes are always arrays, request_id joins stay
within a session, the OCSF decision event gains request_id, decision and
metadata so it can be joined and carries the same facts as OTel, and
attacker-influenced attributes are marked as untrusted. The checker's
policy rows now resolve to policy_references itself, and its pin test
covers every list and value Bar fixed.

Signed-off-by: Rock Lambros <rock@rockcyber.com>
A second review found that some of the rules added in the previous
commit could not be applied as written.

The Observed Agent and the Guardian each emit their own step span, so a
rule keyed to "the same step span" never matched, and the rule calling a
repeated request_id an integrity finding fired on every step. Every step
span now also carries acs.session.id, joins key on session and request id
together, the integrity finding is limited to two spans from one emitter,
and a retried step carries its final request_id.

The counting rule counted only the Observed Agent's events, so the party
being watched could zero the count. A reader now counts distinct steps
from either side, and a step only the Guardian reported is still counted.

The note on unusable responses misstated section 6.5, which keeps the
original summary at postCompact, and missed a section 6.3 violation that
also fails schema validation. The OCSF late-decision rule now names the
clock it uses and says a Blocked disposition is a verdict only.

The OCSF side gains reasoning and session_id so it carries what the OTel
event carries, the deny example uses metadata as acs_fields says, and the
disposition and severity tables must key exactly the five dispositions.
New tests catch a typo in either table, an acs_fields entry that is not a
response field, and a renamed policy_references member field, each of
which passed every check before. Attacker-influenced text is now a
SHOULD for every attribute value, stated once on the Trace Events page.

Signed-off-by: Rock Lambros <rock@rockcyber.com>
A third review found that the retry rule from the previous commit
detached failures from the attempts they belong to, and that the
integrity rule rested on a resource attribute nothing requires. Each
attempt is now its own request, with its own step span, so failures stay
on the attempt they happened to and a count counts failed attempts. The
integrity rule is gone: section 10.3 already rejects a reused request_id
at the Guardian, and a Guardian must not record that rejection as a
failure of the earlier request.

The OCSF join key now has a machine-readable home, join_fields, because
session_id is not a response field and could not sit in acs_fields.

The OCSF disposition, severity and class-entry schemas are open again.
Closing them changed what a deployment may publish without anyone
deciding it. Tests now catch the same typos instead. The examples use
UUID session ids, as the request envelope requires.

Signed-off-by: Rock Lambros <rock@rockcyber.com>
The OCSF decision class now requires action_id, action, disposition_id,
disposition, evidences and metadata.correlation_uid beside finding_info.
Defer maps to disposition_id 99, not 14, and modify gets action_id 4.
is_alert is true for deny and false for allow, and evidences, authorizations
and correlation_uid carry the agent, the gated action, the policies and the
join pair in native fields. A Guardian SHOULD NOT set verdict_id or status_id.

A late decision is no longer suppressed on OCSF. It keeps the verdict in
action_id and disposition_id, adds a late_decision finding type, and carries
the failure facts under unmapped.acs.failure.

policy_references fields and provenance source_id now reject the empty
string, so a "" in a padded Trace array always means absent (#263).

Signed-off-by: rocklambros <rock@rockcyber.com>
is_alert is now true exactly when severity_id is 3 or higher. ask and defer
alert because an ASK can time out to allow and Trace has no event for how
either one ends. modify does not, because the action ran in the form the
policy required. finding_info.title stays required, as finding.title was.

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

rocklambros commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

@afogel @bar-capsule, this is the issue #228 change with all ten of your answers applied, plus the is_alert call I posted on issue #228 and issue #263's minLength. The body lists each question with the answer it got. PR #258 and PR #261 are in, so it's rebased on today's integration, and it lands cleanly before or after PR #244. Either of your approvals merges it.

@github-actions

Copy link
Copy Markdown

This pull request's description says it closes #228 #263, 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.

@acs-board-reconciler acs-board-reconciler Bot moved this from Needs triage to In progress in ACS Project Tracker Oct 14, 2026
- is_alert equals severity 3 or higher only on an on-time event. A late
  decision under fail-open is the one case it can be true below 3, and
  severity_id keeps the table's value.
- A reader MUST NOT count an OCSF decision flagged late as enforced.
- api.operation goes in evidences only for the two tool-call steps.
- A policy reference with neither policy_id nor policy_name becomes an
  authorizations entry with no policy object, which OCSF accepts.
- Producers SHOULD omit a member rather than send "", which the schema
  now rejects.
- The AgBOM example carries correlation_uid, the deny example no longer
  joins to the unrelated tool call, and the OTel page names both join keys.
- The reference Guardian reads an empty AGT reason as no rule, so it can't
  send a rule_id the new minLength rejects.

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

Copy link
Copy Markdown
Collaborator Author

@afogel @bar-capsule, I ran this through the same review PR #244 got before asking you to approve it, and it found real gaps. I fixed them in 04ea097, and everything is still green (pytest 916, bun 1122, the strict docs build, and the OCSF 1.9.0 validator on every example).

Here's what changed, all inside the rules you already answered:

  • is_alert equals "severity 3 or higher" only on an on-time event. A late decision under fail-open is the one case it can be true below 3 (a late modify), and severity_id keeps the table's value. Before, the two rules contradicted each other.
  • A reader MUST NOT count an OCSF decision flagged late_decision_fail_open or late_decision_fail_closed as enforced. That's your Q4 rule, applied to the only failure signal OCSF has.
  • api.operation goes in evidences only for the two tool-call steps. The mapping had it unconditional, which wouldn't work for a userMessage decision.
  • A policy reference with neither policy_id nor policy_name becomes an authorizations entry with no policy object. OCSF rejects a policy that has only a version or a rule id, and I checked both cases against the validator.
  • Producers SHOULD omit a field rather than send "", since the new minLength rejects it. The reference Guardian also had a path that could send rule_id: "", which is fixed, with a test.
  • The OCSF examples line up now. The AgBOM one carries correlation_uid, and the deny no longer joins to the unrelated tool-call example.

Five things came up that I didn't change, because each one is a new rule and that's your call:

  1. OCSF has no form for most decision failures (that's issue [Bug] §6.4's mandatory fail-open audit event has no conformant shape, so the decision-failure rate is unmeasurable #37 and issue [Bug] ACS events can't fill the fields their OCSF classes require #235). So a deployment that emits only OCSF can show "Blocked" for a deny that never took effect when the network, not the Guardian, made it late. Should the Observed Agent be required to emit an OCSF failure finding now, or does that stay with issue [Bug] ACS events can't fill the fields their OCSF classes require #235?
  2. Only REPLAY_DETECTED is exempt from the failure event. A request the Guardian rejects before it has authenticated it (a bad signature, a stale timestamp) is still logged against its pair, so anyone who knows a pair can plant a failure on a real decision. Should the exemption cover every pre-authentication rejection?
  3. A deny that fails schema validation somewhere else in the response currently takes the failure posture, which under proceed means the action runs. Should a readable non-allow decision fail closed?
  4. finding_info.uid is required by OCSF, but nothing says where it comes from. I'd use metadata.correlation_uid.
  5. "In either party's stream" doesn't say how a reader knows whose stream it's reading. Something like "attributed by collector authentication, not by reported_by" would close that.

None of these block the merge as far as I'm concerned. If you agree, I'll file them as one issue and we can sort them out after Friday.

…n issue #37

signature_invalid keeps a tampered response apart from organic failures.
handshake_failure counts a session that started without a completed
handshake, and its event goes on the acs.session span, not a step span.
PR #273's sessionEnd count keys on this set of eight classes.

Signed-off-by: rocklambros <rock@rockcyber.com>
The test pins the class list so a change has to be deliberate, and adding
signature_invalid and handshake_failure is that change.

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

@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.

LGTM

@rocklambros
rocklambros merged commit 405d82d into integration Oct 15, 2026
9 checks passed
@github-project-automation github-project-automation Bot moved this from In progress to Done in ACS Project Tracker Oct 15, 2026
President added a commit to disciplinedware/agent-control-standard that referenced this pull request Oct 16, 2026
- Regenerate the embedded schema copy and re-record the TypeScript answers
  after merging integration; GenAI-Security-Project#275 changed four schemas and the Guardian
  sources. No recorded answer changed.
- An empty string in an AGT verdict field is read as absent, as the
  TypeScript mapVerdict now does: no policy_references, no reason_codes,
  and a warn without a rule is an evaluation failure (GenAI-Security-Project#263).
- One test signer for the agtbridge tests instead of five copies.

Claude-Session: https://claude.ai/code/session_01DFdYnoBitHhPcUSq7wMWs1
Signed-off-by: Konstantin Trunin <konstantin@trunin.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

2 participants