Skip to content

[AAASM-5750] ✨ (core): Forward the hook-layer audit record to the runtime - #319

Merged
Chisanan232 merged 16 commits into
mainfrom
v0.0.1/AAASM-5750/feat/interceptor_audit_sink
Aug 14, 2026
Merged

[AAASM-5750] ✨ (core): Forward the hook-layer audit record to the runtime#319
Chisanan232 merged 16 commits into
mainfrom
v0.0.1/AAASM-5750/feat/interceptor_audit_sink

Conversation

@Chisanan232

@Chisanan232 Chisanan232 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Description

The SDK's own interceptor now resolves an audit hook and forwards a governed call's outcome to the runtime.

Before this, RuntimeQueryInterceptor.__getattr__ delegated record_result / on_tool_end to GatewayClient, which has neither — so the adapters' getattr guard found nothing and no record was emitted on any path, allowed or denied. RuntimeQueryInterceptor now defines both hooks and ships the outcome over RuntimeClient.send_event, the same native primitive register_agent already uses.

Design, and the code that decided it

native/aa-ffi-python/src/lib.rs:188 (RuntimeClient.send_eventaa_sdk_client::AssemblyClient::report_event) was already exported, already benchmarked, and had zero call sites in agent_assembly/. It is the only durable event-publish path reachable from Python: the gRPC stubs expose CheckAction / BatchCheck / OpControlStream (inbound only), and there is no HTTP audit route.

GovernanceEvent validates its argument as aa_core::AuditEntry JSON, so agent_assembly/core/runtime_audit.py encodes that shape. The identity and hash-chain fields are zero-filled, deliberately and with the reason written down: only event_type is read from the entry, the whole JSON travels as an opaque details label, and the authoritative attribution is the runtime's — derived from the verified identity of the IPC connection. The agent id the SDK does know travels inside payload, where it is plainly a claim by the SDK.

on_tool_end is defined as well as record_result, for the one caller that looks up the second name specifically: AssemblyCallbackHandler forwards its own on_tool_end to the interceptor's. Without it, the record built on LangChain's callback path was accepted by the handler and dropped one hop later.

§6 term earned

  • Over a connected runtime → Observed (AUDIT_SINK_FORWARDED).
  • _FailClosedInterceptor, or any run with no reachable runtime → AUDIT_SINK_ABSENTDegraded. Not Unmeasured: where the record stops has been measured.
  • LangChain handler over a runtime-less interceptor → AUDIT_SINK_DISCARDED.

Type of Change

  • ✨ New feature

Breaking Changes

  • No — AuditSinkDisposition gains a value; the two hooks are additive.

Related Issues

Testing

  • Unit tests added/updated
  • Integration tests added/updated
  • Manual testing performed

pytest test/ (unit) — 1297 passed, 4 skipped. ruff check / ruff format clean. pre-commit run --all-files — all hooks pass (mypy included).

Proven end to end at the downstream boundary, with a reachability control (AC2). test_the_shipped_path_forwards_the_record_across_the_native_boundary[allow|deny] drives the real run_governed_async_tool chain against a recording native client. The positive control asserts a policy query carrying the probe crossed; the finding asserts the send_event channel specifically carries the record — kept separate so the control cannot satisfy the finding.

The payload is validated against the real native constructor, not only a double. I built the extension locally (maturin develop --release) and confirmed GovernanceEvent accepts the builder's output on both branches. That is pinned as test_the_audit_payload_builder_is_accepted_by_the_real_governance_event behind the existing native gate, with a negative control. The built .so is not committed. In the unit suite the constructor is a double that validates the required AuditEntry field set — a replica of the contract, not the validator, and its docstring says so.

Proven able to fail (AC3). Replacing record_result's body with return False:

FAILED ...::test_the_shipped_path_forwards_the_record_across_the_native_boundary[allow]
  the allow branch sent no audit record carrying 'AUDIT-PROBE-AAASM-5731-RESULT'
  across the native boundary; send_event crossings were []
FAILED ...::test_the_shipped_path_forwards_the_record_across_the_native_boundary[deny]
FAILED ...::test_the_langchain_handler_forwards_or_drops_with_its_interceptor

No test here injects a sink into the SDK's own path.

Checklist

  • Code follows project style guidelines
  • Self-review completed
  • Comments added for complex logic
  • Documentation updated if needed

Known limitation, stated rather than papered over

Seven adapters (CrewAI, LlamaIndex, Haystack, Agno, Smolagents, Microsoft Agent Framework, MCP) construct no record at all on their denied path — they return or raise before their record helper. That is a separate defect of the AAASM-5665 class (an event that is never built), not a sink that drops one, and it is out of this ticket's scope. Those denied paths remain Evaluated, not Observed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XWLmA8FgULT9e6ntdCo1H2

…ntime

Encodes a governed call's outcome as the aa_core::AuditEntry JSON the native
GovernanceEvent validates, and hands it to RuntimeClient.send_event. Failures
are swallowed: the adapters call the audit hook from inside the governed tool
path, so a degraded audit channel must not fail the call.

Refs AAASM-5750
The vocabulary had no value for a hook that carries the record onward, so
wiring one would have had to declare a retention the SDK cannot observe.
Adds it and maps each disposition onto the ADR 0033 section 6 term it earns.

Refs AAASM-5750
Neither audit hook resolved on any interceptor this SDK shipped: __getattr__
handed both names to a GatewayClient that has neither, so the adapters found
nothing to call and every governed call, allowed as well as denied, produced
no evidence. Both now resolve and forward to the runtime. audit_sink is
computed from the runtime client so an interceptor without one still declares
honestly.

Refs AAASM-5750
init_assembly warned for every disposition that was not the caller's own,
which silently included forwarded the moment that value existed. Enumerates
the two that leave no evidence, and corrects the surrounding claims.

Refs AAASM-5750
…t has

The fake _core exposed neither, so an interceptor built over it declared
absent and the forwarding path was off in every test using it. The event
wrapper validates the AuditEntry field set, because a double that accepts any
string makes "the record crossed" pass over a payload the real one rejects.

Refs AAASM-5750
Inverts the measurement: the SDK's own governed-tool chain over the same
boundary, same positive control, now has to find the record on the far side
on both branches. Adds the no-runtime input that shows the computed
disposition moving, and a control that the boundary double can reject.

Refs AAASM-5750
5750 built the sink, so a claim still calling recording Planned under it
describes shipped behaviour as unbuilt. Collapses the guarded-site tier into
one repository-wide rule and adds a positive control over synthetic input,
since a rule expecting no findings is green when the scan is broken.

Refs AAASM-5750
The unit suite substitutes a double for the native constructor and can only
catch an obviously wrong payload. This asserts the real one accepts the
builder's output on both branches, with a negative control, behind the
existing native gate. Also rescopes a control comment to what its fixture proves.

Refs AAASM-5750
Twelve sites across the docs tree, the README and the repo guide asserted
that governed calls leave nothing behind. Each now states what the record's
fate depends on, and names the runtime-absent case rather than dropping it.

Refs AAASM-5750
read_text raises for a file removed between the directory listing and the
read, aborting the whole scan — turning a gate whose verdict is "no findings"
into one that produced no verdict. The node SDK's equivalent walk hit exactly
that in CI. Skips it instead; a file that no longer exists carries no claim.

Refs AAASM-5750
"Every tool call" is on ADR 0033 fd-7's banned list — it overstates coverage,
since only a governed tool's call reaches the gate. Corrected while the line
was being edited rather than left because it predated the change.

Refs AAASM-5750
The first sweep enumerated by phrase and missed sites whose false clause never
used the word "audit": the architecture page still said nothing in
agent_assembly calls send_event and nothing constructs GovernanceEvent, both
of which runtime_audit.py now does. Also two adapter module docstrings, a
deny-path comment contradicting its own module docstring, the runnable
example, and two summaries in assembly.py contradicted by code 200 lines
below them — including the field comment that documented the warning
predicate this branch had already fixed.

Refs AAASM-5750
@Chisanan232

Copy link
Copy Markdown
Contributor Author

Second sweep — the first one's method was wrong, and it missed nine sites.

The first pass enumerated by phrase (grep for audit|discard|retain|attribut|evidence over Markdown, then by file). An independent audit against what each sentence asserts found nine more, committed in 45e9400. Four of them were unreachable by that method, which is the point worth recording:

  • docs/concepts/architecture.md:82-83 — asserted that nothing in agent_assembly calls send_event and that GovernanceEvent is "never constructed by it". Both are now false (core/runtime_audit.py), and neither false clause contains the word "audit", so no audit-keyword grep could reach them.
  • agent_assembly/core/assembly.py:127-130 — the audit_sink field comment still said anything other than "caller-supplied" means no evidence. That is the exact predicate the warning call site was corrected away from earlier in this same branch, so the comment documented the bug the branch had already fixed.
  • agent_assembly/core/assembly.py:407"""Emit a loud, unconditional stderr warning…""", when the same function's body now says it fires only for the evidence-free dispositions.
  • agent_assembly/adapters/_shared/tool_governance.py:296 — a deny-path comment contradicting its own module docstring 285 lines above, which the first pass had corrected.

Plus two adapter module docstrings (haystack, llamaindex) and examples/basic_usage.py.

One judgement call, flagged rather than changed silently: docs/guides/authoring-adapters.md's record row. The literal claim is still true — only record_result / on_tool_end were wired, and neither RuntimeQueryInterceptor nor GatewayClient has record — but the word "either" back-referenced the row above, which this branch rewrote. The parallel it asserted no longer exists, so the row now says so explicitly instead.

Full unit suite (1297 passed), ruff, and pre-commit run --all-files are green after these.

@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.17949% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
agent_assembly/core/runtime_audit.py 81.48% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

send_event is unacknowledged, so nothing observable from this side
establishes a durable event attributed to the action — ADR 0033 section 6
Observed is not earned. Also corrects the event-type comment: the runtime
does not key off that tag, it keys off action_type/detail, and report_event
leaves those at proto3 zero, so the collapse the comment warned against is
what actually happens. Drops the cross-SDK line that goes stale on merge.

Refs AAASM-5750
Three of twelve adapters build a record on the denied path. haystack and
llamaindex each said otherwise in their own module docstring, at the very
adapters that raise before their record helper.

Refs AAASM-5750
Six shipped sites said audit covers "allowed calls as much as denied ones".
It covers three adapters on the denied path — google_adk, pydantic_ai,
openai_agents — and the other eight, LangChain among them, return or raise
first. The exception was disclosed in the PR and reached no user-facing text,
which is the ADR 0034 failure: an upper layer may simplify, never broaden.

Refs AAASM-5750
@Chisanan232

Copy link
Copy Markdown
Contributor Author

Review round 2 — Observed withdrawn, and AC1 scoped honestly. Head 76c8faa.

You were right that it is eight, and that LangChain is among them. I measured it rather than taking the number: 3 of 12 adapters build a record on the denied path — google_adk and pydantic_ai via run_governed_async_tool, openai_agents directly. The other eight (crewai, llamaindex, haystack, agno, smolagents, microsoft_agent_framework, mcp, langchain) return or raise first. callback_handler.py raises ToolExecutionBlockedError at :176, :187 and :197 with nothing recorded, and on_tool_end only runs after a tool has run — so the hook this PR added is never reached on a LangChain deny.

The [deny] parametrisation gave zero coverage of the eight, exactly as you said: it drives run_governed_async_tool, which is one of the three that do record. It still earns its place as the AC3 mutation target, but it was never evidence for the general claim, and the prose no longer implies it was.

All six shipped sites scoped, and the eight named in user-facing text. README.md, docs/index.md (both sites), docs/concepts/architecture.md, docs/guides/authoring-adapters.md, _shared/tool_governance.py, plus .claude/CLAUDE.md. The authoring-adapters row now tells a new adapter author "your adapter must call this on the denied path too — most do not", which is where that fact is actually actionable. haystack/patch.py and llamaindex/adapter.py each said the opposite in their own module docstring, at the very adapters that raise first; both corrected.

That the disclosure lived only in the PR body was the ADR 0034 failure, and I accept the characterisation — an upper layer may simplify, never broaden, and "allowed calls as much as denied ones" broadened.

Observed withdrawn. send_event is unacknowledged, so nothing observable from this side establishes a durable event attributed to the action. No substitute §6 term; this layer makes no recording claim. Not referencing the durability defect by ticket since you are filing it.

The event-type comment is corrected to the opposite of what it said. It claimed the runtime "keys its own handling off this tag". It keys off action_type / detail, and report_event leaves those at proto3 zero while putting the tag in labels — so is_policy_violation is false for every hook-layer record and the collapse the comment warned against is what actually happens. It now says the distinction is carried, not honoured — do not build a claim on it.

audit_sink.py:78 fixed — the cross-SDK line is dropped rather than re-pointed, since it would go stale again the moment #203 and #370 merge.

Noted, not fixed: python's claim gate scans only docs/quick-start.md, which carries no audit claim, so this new prose is ungated — unlike go's and node's. Worth a ticket; I have not widened the gate here as it is beyond this change.

Gates: 1297 passed / 4 skipped, ruff check + format clean, pre-commit run --all-files all hooks pass.

The gap was described in eight places with no referent, which reads as an
explained limitation rather than a tracked one. Points at what 5783 will
change — report_event payloads reaching the live stream and the durable entry
— and states that no SDK can claim Observed until it lands.

Refs AAASM-5750, AAASM-5783
@sonarqubecloud

Copy link
Copy Markdown

@Chisanan232
Chisanan232 merged commit c02f7bb into main Aug 14, 2026
29 checks passed
@Chisanan232
Chisanan232 deleted the v0.0.1/AAASM-5750/feat/interceptor_audit_sink branch August 14, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant