Skip to content

feat(dotnet): content safety adapter and managed stream accounting - #38

Closed
liamcrumm wants to merge 3 commits into
mainfrom
liamcrumm/contentsafety-adapter
Closed

feat(dotnet): content safety adapter and managed stream accounting#38
liamcrumm wants to merge 3 commits into
mainfrom
liamcrumm/contentsafety-adapter

Conversation

@liamcrumm

@liamcrumm liamcrumm commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds AgentControlSpec.ContentSafety, an adapter that maps content safety observations onto release decisions, and the managed StreamSession it depends on. Both were written in July against the pre review engine and are refactored here onto the semantics that shipped in 0.4.0-alpha.2.

What the adapter does

BlockingCriterion.Matches takes a TaskObservation of severity, detected or score and decides whether that task blocks. Criteria are severity threshold, risk level bucket, detected flag and score threshold.

ContentSafetySession owns the offsets and watermarks. A host reports a payload, then reports a segment with one observation per task, and gets back a ContentSafetyOutcome saying whether the segment may be released, which action the session collapsed to, and which tasks matched. Actions carry through as flags so a block and a filter do not become the same answer.

ContentSafetySourceMapping splits two questions the front door answers differently. The raw source as it arrived decides which track counts the offsets. The resolved source, after context folds into model generated text and concatenated history folds into the request, decides which tasks apply. A payload carrying concatenated history evaluates as a request but has its runes counted on the completion track. The adapter preserves that disagreement rather than correcting it, because an offset the front door does not agree with releases the wrong text.

State

The accounting is a second implementation of specification section 18.1. The C ABI exposes no streaming entry points, so the .NET SDK cannot bind the Rust engine for this and reimplements it. Nothing structural keeps the two in step, so StreamSessionDifferential replays traces the engine exports and fails on any divergence in either direction.

That replay is doing real work. The July implementation carried the pre review design and the differential caught three divergences against the shipped engine on first run, all of which released text no task had evaluated. Refactored here:

Behavior Was Now
transform cleared its span terminal, reports StreamEndReason.Rewritten
transform guard Confirmed > span.Start any released rune
SafeOffset after a deny returned the frozen offset returns null
payload after EOF refused, session stayed live fails the session
resume offset one shared value per track
empty task set rejected track is unmediated
decision mapping five decisions the three agent-hooks decisions

The offset a track reached stays readable through Watermark for an audit record.

Tests

40 .NET tests pass, 8 of them ported here. 115 rego tests pass. cargo test --workspace --all-features --locked, cargo clippy --workspace --all-features --locked -- -D warnings, cargo fmt --all --check and scripts/check-version-consistency.py are clean.

The .NET tests need the native library on the loader path, which the NuGet package does not carry. Build it with cargo build -p agent-control-spec-ffi --release and run with LD_LIBRARY_PATH=$PWD/target/release. Without it the pre existing interceptor and manifest tests fail to load, which is unrelated to this change.

Known gaps

The adapter's vocabulary is front door wire vocabulary. ContentSafetySource mirrors the proto and the source mapping reproduces one service's payload processor. That argues for the adapter living with the consumer rather than in this repo, and it is worth deciding before this merges. The project is marked unpackable until that is settled, so nothing publishes under a package id that a later move would break.

The managed accounting is a maintenance liability for as long as it exists. Streaming entry points in the C ABI would remove it and the drift class with it.

examples/streaming_frontdoor/, a localhost demo, is not included. It depends on an Azure Content Safety credential.

liamcrumm and others added 3 commits August 10, 2026 21:50
Adds AgentControlSpec.ContentSafety, which maps content safety observations
onto release decisions, and the managed StreamSession the adapter needs.

BlockingCriterion.Matches takes a TaskObservation of severity, detected or
score and decides whether a task blocks. ContentSafetySession carries the
per track offsets and watermarks and collapses the per task actions into one
outcome per segment. ContentSafetySourceMapping reproduces the front door's
split between the raw source, which decides the track counting offsets, and
the resolved source, which decides which tasks apply. The two disagree and
the adapter preserves that rather than correcting it, because an offset the
front door does not agree with releases the wrong text.

The accounting is a second implementation of section 18.1, since the C ABI
exposes no streaming entry points. It was written in July against the
pre review engine and is refactored here onto the shipped semantics. A
transform is terminal and reports StreamEndReason.Rewritten rather than
clearing its span, the transform guard bounds on any released rune rather
than the span start, SafeOffset returns null once the session has ended,
resume offsets are per track, an empty task set means the track is
unmediated, payload after the payload stream closes fails the session, and
the decision mapping collapses to the three agent-hooks decisions.

The trace exporter is ported alongside so the differential replays traces
generated by the shipped engine. It caught three of those divergences on
first run.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
Pin the transform bound. The refactor narrowed the guard from the span
start to any released rune, but the existing test transformed a span
overlapping released text, which fails under both readings. A span
starting exactly at the watermark distinguishes them, and nothing
covered it, so the fail-open could have been reintroduced silently.

Correct three doc comments that described the withdrawn design: that a
transform clears its span rather than ending the session, that
StreamCompletion.Transformed tracks a cleared span rather than a
terminal rewrite, and that RecordVerdict maps warn and escalate, which
agent-hooks no longer defines.

Mark the ContentSafety project unpackable. It carried full package
metadata while the release workflow packs only the core project, so the
file claimed an intent that nothing performed. Publishing it would fix
the package id here while its home is still open.

Cover the new csproj in the version gate, which read only the core
project and would not have caught the new one drifting.

Restore the solution file to a minimal edit. Adding the project through
the CLI also wrote x64 and x86 configurations for every existing
project, which is 51 lines of unrelated churn.

Note in the rego library that it and the C# answer to the same
committed oracle and that only the C# is checked against it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
The exporter builds the expected trace with LF endings and compares it
byte for byte against the committed file, and the .NET differential
replays the same bytes. On a Windows checkout autocrlf rewrote the file
to CRLF, so the comparison failed for a reason unrelated to the behavior
under test.

Mark it -text, as the SSE conformance vectors already are for the same
reason.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
@liamcrumm

Copy link
Copy Markdown
Collaborator Author

Superseded by #39, which fixes the underlying gap instead. The managed reimplementation this PR added exists only because streaming was never exposed to .NET; #39 exposes it through the C ABI so no second implementation is needed.

@liamcrumm liamcrumm closed this Aug 11, 2026
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