diff --git a/CHANGELOG.md b/CHANGELOG.md index 8550533..771275d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ User-visible changes to the spec and SDKs. Versioning rules: ## Unreleased +- **SDKs: `record_host_failure` — a host projection failure is no + longer recordless.** When the host's own to-wire projection fails + before a valid `AgentContext` exists (e.g. a tool-call argument + getter throws at the chat seam), the emitter can now synthesize and + deliver the fail-closed record itself: a `deny + host_error:context_invalid` in the §10.3 rejection shape (null + identities under the declared provider, payload-free type-name/path + detail, envelope facts the host still knows). All five SDKs + (`record_host_failure` / `recordHostFailure` / `RecordHostFailure`); + §10.3 gains the "Host projection failure" host obligation and §11 + lists it as a synthesis site. No new reserved reason and no record + shape change — additive. - **Microsoft Agent Framework: full §13.1 conformance claim.** The MAF row upgrades from partial cross-validation (33/47) to a conformance claim — 47/47 applicable vectors pass on the declared surface (`tool_seam_host_error: terminate`, `buffered_output: true` → 4 capability-gated skips); report and harness updated in `conformance/claims/maf/`. - **CTK: declared `tool_seam_host_error` posture (§13.1).** A harness declares `continue` (default) or `terminate`, and `expect.run_outcome_by_posture` resolves the 13 tool-seam `host_error:*` vectors to the single outcome that declared surface must produce — the §6.2 terminate clause is now claimable (#68). - **CTK: AH-CTK-100 asserts §6.1 substance, not transcript cosmetics.** New `context_must_contain`/`context_must_not_contain` interception assertions pin non-incorporation of the denied tool result and the deny surfacing to the model in some form, leaving message layout and payload shape to the host (#69). diff --git a/sdk/dotnet/src/AgentHooks/InterceptionEmitter.cs b/sdk/dotnet/src/AgentHooks/InterceptionEmitter.cs index 9e12524..89d3f71 100644 --- a/sdk/dotnet/src/AgentHooks/InterceptionEmitter.cs +++ b/sdk/dotnet/src/AgentHooks/InterceptionEmitter.cs @@ -295,6 +295,74 @@ public async ValueTask EmitUncheckedAsync( _mode == EnforcementMode.Enforce ? "enforce" : "evaluate_only", options.ToJsonString(Compact)); var record = RecordFromCore((JsonObject)JsonNode.Parse(recordJson)!); + return Deliver(record); + } + + /// §10.3/§11 host projection failure: synthesize and deliver + /// the fail-closed record for an emission whose + /// the host could not construct at all — its own projection to the + /// wire failed before anything existed to emit (e.g. a tool-call + /// argument property getter threw during to-wire conversion at the + /// chat seam). Without this the host can only fail the action closed + /// recordless; with it the trail stays complete under host-side + /// faults. + /// + /// The record is the §10.3 rejection shape: the payload-free + /// projection of a deny host_error:context_invalid carrying + /// (payload-free: an exception type + /// name or a path, never the content that failed to project — + /// §14 data minimization) as its message; null identities + /// under the declared provider; decided_by: null; no + /// per-interceptor summaries (no interceptor ran). The optional + /// parameters carry the envelope facts the host still knows; + /// SHOULD be the number the failed + /// emission would have carried (consume the next one from the + /// context source so records stay totally ordered); absent members + /// record the §10.3 unknown values (""/-1). The record + /// takes the next slot in the record stream (sink, then buffer) like + /// any emission. In enforce mode the host MUST still fail the + /// action closed; in evaluate_only the record documents the + /// host fault without implying enforcement (§8). + public InterceptionRecord RecordHostFailure( + InterceptionPoint point, + string? detail = null, + string? sessionId = null, + long? sequence = null, + string? timestamp = null) + { + // Deliberately partial basis: only the envelope facts the host + // still knows. It never passes §4 validation (`spec` is absent), + // so the core's finalize always yields the §10.3 rejection shape + // — null identities under the declared provider — and keeps the + // synthesized `context_invalid` deny (with the host's detail) + // instead of substituting its own. + var basis = new JsonObject { ["interception_point"] = point.ToWireName() }; + if (sessionId is not null) basis["session"] = new JsonObject { ["id"] = sessionId }; + if (sequence is { } seq) basis["sequence"] = seq; + if (timestamp is not null) basis["timestamp"] = timestamp; + var options = new JsonObject + { + ["input_identity"] = null, + ["identity_provider"] = _identity.Name, + ["enforced_identity"] = null, + ["decided_by"] = null, + ["composition"] = _composition.ToWire(), + ["verdicts"] = new JsonArray(), + ["fold_truncated"] = null, + ["resolved_by"] = null, + ["interceptors_registered"] = _interceptors.Count, + }; + var recordJson = Native.Finalize( + basis.ToJsonString(Compact), + Verdict.FromHostError(HostError.ContextInvalid, detail).ToWire().ToJsonString(Compact), + _mode == EnforcementMode.Enforce ? "enforce" : "evaluate_only", + options.ToJsonString(Compact)); + return Deliver(RecordFromCore((JsonObject)JsonNode.Parse(recordJson)!)); + } + + /// Deliver a record to the sink and the bounded buffer (§10.3). + private InterceptionRecord Deliver(InterceptionRecord record) + { if (_recordSink is { } sink) { // Audit delivery must not take down the control plane (§10.3). diff --git a/sdk/dotnet/test/AgentHooks.Tests/RecordSemanticsTests.cs b/sdk/dotnet/test/AgentHooks.Tests/RecordSemanticsTests.cs index 8772a33..b265e1e 100644 --- a/sdk/dotnet/test/AgentHooks.Tests/RecordSemanticsTests.cs +++ b/sdk/dotnet/test/AgentHooks.Tests/RecordSemanticsTests.cs @@ -82,3 +82,86 @@ public async Task NamesAndCountOnRecord() Assert.Null(r.Verdicts[1].Name); } } + +public class HostFailureTests +{ + private sealed class Allow : IInterceptor + { + public ValueTask InterceptAsync(AgentContext ctx, CancellationToken ct = default) + => ValueTask.FromResult(Verdict.Allow); + } + + [Fact] + public void RecordHostFailureSynthesizesRejectionShape() + { + // §10.3 host projection failure: the host could not construct a + // context at all; the synthesized record is the rejection shape + // with the host's envelope facts. + var em = new InterceptionEmitter(); + em.Register(new Allow()); + var r = em.RecordHostFailure( + InterceptionPoint.PreToolCall, + detail: "InvalidOperationException", + sessionId: "s", + sequence: 7, + timestamp: "2026-01-01T00:00:00Z"); + Assert.False(r.Proceeds); + Assert.Equal(InterceptionPoint.PreToolCall, r.InterceptionPoint); + Assert.Equal("host_error:context_invalid", r.Verdict.Reason); + Assert.Equal("InvalidOperationException", r.Verdict.Message); + // §10.3 rejection shape: null identities under the declared + // provider, nothing dispatched. + Assert.Equal("jcs-sha256", r.IdentityProvider); + Assert.Null(r.InputIdentity); + Assert.Null(r.EnforcedIdentity); + Assert.Null(r.DecidedBy); + Assert.Empty(r.Verdicts); + Assert.Equal(1, r.InterceptorsRegistered); + // Envelope facts the host supplied. + Assert.Equal("s", r.SessionId); + Assert.Equal(7, r.Sequence); + Assert.Equal("2026-01-01T00:00:00Z", r.Timestamp); + // The record entered the emitter's stream like any emission. + Assert.Single(em.Records); + } + + [Fact] + public void RecordHostFailureDefaultsAreTheUnknownValues() + { + var em = new InterceptionEmitter(); + var r = em.RecordHostFailure(InterceptionPoint.Output); + Assert.Equal("", r.SessionId); + Assert.Equal(-1, r.Sequence); + Assert.Null(r.Timestamp); + Assert.Null(r.Verdict.Message); + Assert.Equal(0, r.InterceptorsRegistered); + } + + [Fact] + public void RecordHostFailureRecordsInEvaluateOnlyAndHitsSink() + { + // §8: synthesis still records in evaluate_only — records are the + // point — and the mode member keeps the record from implying a + // block happened. + var em = new InterceptionEmitter(EnforcementMode.EvaluateOnly); + var seen = new List(); + em.SetRecordSink(seen.Add); + var r = em.RecordHostFailure(InterceptionPoint.PreToolCall, detail: "TypeError"); + Assert.Equal(EnforcementMode.EvaluateOnly, r.Mode); + Assert.Equal("host_error:context_invalid", r.Verdict.Reason); + Assert.Equal([r], seen); + } + + [Fact] + public void RecordHostFailureDetailTruncatedByProjection() + { + // §10.3: the synthesized verdict crosses the same payload-free + // projection as every combined verdict. + var em = new InterceptionEmitter(); + var r = em.RecordHostFailure( + InterceptionPoint.PreToolCall, detail: new string('x', 300)); + Assert.NotNull(r.Verdict.Message); + Assert.EndsWith("…", r.Verdict.Message); + Assert.True(System.Text.Encoding.UTF8.GetByteCount(r.Verdict.Message!) <= 256 + 3); + } +} diff --git a/sdk/go/agenthooks/emitter.go b/sdk/go/agenthooks/emitter.go index 41b2acd..3680506 100644 --- a/sdk/go/agenthooks/emitter.go +++ b/sdk/go/agenthooks/emitter.go @@ -437,6 +437,103 @@ func (e *InterceptionEmitter) EmitUnchecked(ctx context.Context, actx AgentConte if err := json.Unmarshal([]byte(recJSON), &rec); err != nil { return InterceptionRecord{}, err } + return e.deliver(rec), nil +} + +// HostFailure carries the envelope facts for RecordHostFailure: what +// the host still knows about an emission whose context it could not +// construct (§10.3 "Host projection failure"). Everything is optional — +// an absent member records the §10.3 unknown value (session_id: "", +// sequence: -1, timestamp absent). +type HostFailure struct { + // Detail is the payload-free failure detail — an error TYPE NAME + // or a path, never the content that failed to project (§14 data + // minimization). Recorded as the verdict message (truncated by the + // §10.3 projection). Empty records nothing. + Detail string + // SessionID is session.id of the failed emission, when the host + // knows it. Empty records the unknown value. + SessionID string + // Sequence is the number the failed emission would have carried. + // The host SHOULD consume the next number from its context source + // so records stay totally ordered within the session (§10.3). nil + // records -1. + Sequence *int64 + // Timestamp is the RFC 3339 event time, when the host has one. + Timestamp string +} + +// RecordHostFailure synthesizes and delivers the §10.3/§11 fail-closed +// record for an emission whose AgentContext the host could not +// construct at all — its own projection to the wire failed before +// anything existed to Emit (e.g. a tool-call argument failed to-wire +// conversion at the chat seam). Without this the host can only fail +// the action closed recordless; with it the trail stays complete under +// host-side faults. +// +// The record is the §10.3 rejection shape: the payload-free projection +// of a deny host_error:context_invalid carrying failure.Detail as its +// message; null identities under the declared provider; decided_by +// null; no per-interceptor summaries (no interceptor ran); envelope +// members from HostFailure, with the §10.3 unknown values (""/-1) +// where absent. It takes the next slot in the record stream (sink, +// then buffer) like any emission. In enforce mode the host MUST still +// fail the action closed; in evaluate_only the record documents the +// host fault without implying enforcement (§8). A non-nil error is an +// infrastructure failure only (JSON marshalling or core invocation). +func (e *InterceptionEmitter) RecordHostFailure(point InterceptionPoint, failure HostFailure) (InterceptionRecord, error) { + // Deliberately partial basis: only the envelope facts the host + // still knows. It never passes §4 validation (spec is absent), so + // the core's finalize always yields the §10.3 rejection shape — + // null identities under the declared provider — and keeps the + // synthesized context_invalid deny (with the host's detail) + // instead of substituting its own. + basis := map[string]any{"interception_point": string(point)} + if failure.SessionID != "" { + basis["session"] = map[string]any{"id": failure.SessionID} + } + if failure.Sequence != nil { + basis["sequence"] = *failure.Sequence + } + if failure.Timestamp != "" { + basis["timestamp"] = failure.Timestamp + } + opts := map[string]any{ + "input_identity": nil, + "identity_provider": e.identity.name(), + "enforced_identity": nil, + "decided_by": nil, + "composition": e.composition, + "verdicts": []VerdictSummary{}, + "fold_truncated": nil, + "resolved_by": nil, + "interceptors_registered": len(e.interceptors), + } + basisJSON, err := json.Marshal(basis) + if err != nil { + return InterceptionRecord{}, err + } + verdictJSON, err := json.Marshal(HostErrorVerdict(ErrContextInvalid, failure.Detail)) + if err != nil { + return InterceptionRecord{}, err + } + optsJSON, err := json.Marshal(opts) + if err != nil { + return InterceptionRecord{}, err + } + recJSON, err := nativeFinalize(string(basisJSON), string(verdictJSON), string(e.mode), string(optsJSON)) + if err != nil { + return InterceptionRecord{}, err + } + var rec InterceptionRecord + if err := json.Unmarshal([]byte(recJSON), &rec); err != nil { + return InterceptionRecord{}, err + } + return e.deliver(rec), nil +} + +// deliver hands a record to the sink and the bounded buffer (§10.3). +func (e *InterceptionEmitter) deliver(rec InterceptionRecord) InterceptionRecord { if e.recordSink != nil { // Audit delivery must not take down the control plane (§10.3). func() { @@ -453,7 +550,7 @@ func (e *InterceptionEmitter) EmitUnchecked(ctx context.Context, actx AgentConte } e.records = append(e.records, rec) e.mu.Unlock() - return rec, nil + return rec } // ----------------------------------------------------------------------------- diff --git a/sdk/go/agenthooks/emitter_test.go b/sdk/go/agenthooks/emitter_test.go index a451c52..7084421 100644 --- a/sdk/go/agenthooks/emitter_test.go +++ b/sdk/go/agenthooks/emitter_test.go @@ -793,3 +793,121 @@ func TestSetCompositionEmptyProfileResetsToDefault(t *testing.T) { t.Fatalf("composition = %+v, want default", rec.Composition) } } + +// ---- §10.3 host projection failure ------------------------------------------ + +func TestRecordHostFailureSynthesizesRejectionShape(t *testing.T) { + // §10.3 host projection failure: the host could not construct a + // context at all; the synthesized record is the rejection shape + // with the host's envelope facts. + e := NewInterceptionEmitter(Enforce, nil) + e.Register(scripted{v: AllowVerdict}) + seq := int64(7) + r, err := e.RecordHostFailure(PreToolCall, HostFailure{ + Detail: "json.UnsupportedTypeError", + SessionID: "s", + Sequence: &seq, + Timestamp: "2026-01-01T00:00:00Z", + }) + if err != nil { + t.Fatal(err) + } + if r.Proceeds() { + t.Fatal("host failure must not proceed in enforce mode") + } + if r.InterceptionPoint != PreToolCall { + t.Fatalf("point: %v", r.InterceptionPoint) + } + if r.Verdict.Reason != "host_error:context_invalid" { + t.Fatalf("reason: %q", r.Verdict.Reason) + } + if r.Verdict.Message != "json.UnsupportedTypeError" { + t.Fatalf("message: %q", r.Verdict.Message) + } + // §10.3 rejection shape: null identities under the declared + // provider, nothing dispatched. + if r.IdentityProvider == nil || *r.IdentityProvider != JCSSHA256 { + t.Fatalf("identity_provider: %v", r.IdentityProvider) + } + if r.InputIdentity != nil || r.EnforcedIdentity != nil { + t.Fatal("identities must be null") + } + if r.DecidedBy != nil { + t.Fatal("decided_by must be null") + } + if len(r.Verdicts) != 0 { + t.Fatal("no interceptor ran") + } + if r.InterceptorsRegistered != 1 { + t.Fatalf("interceptors_registered: %d", r.InterceptorsRegistered) + } + // Envelope facts the host supplied. + if r.SessionID != "s" || r.Sequence != 7 { + t.Fatalf("envelope: %q/%d", r.SessionID, r.Sequence) + } + if r.Timestamp == nil || *r.Timestamp != "2026-01-01T00:00:00Z" { + t.Fatalf("timestamp: %v", r.Timestamp) + } + // The record entered the emitter's stream like any emission. + if len(e.Records()) != 1 { + t.Fatalf("records: %d", len(e.Records())) + } +} + +func TestRecordHostFailureDefaultsAreTheUnknownValues(t *testing.T) { + e := NewInterceptionEmitter(Enforce, nil) + r, err := e.RecordHostFailure(Output, HostFailure{}) + if err != nil { + t.Fatal(err) + } + if r.SessionID != "" || r.Sequence != -1 { + t.Fatalf("envelope: %q/%d", r.SessionID, r.Sequence) + } + if r.Timestamp != nil { + t.Fatalf("timestamp: %v", r.Timestamp) + } + if r.Verdict.Message != "" { + t.Fatalf("message: %q", r.Verdict.Message) + } + if r.InterceptorsRegistered != 0 { + t.Fatalf("interceptors_registered: %d", r.InterceptorsRegistered) + } +} + +func TestRecordHostFailureEvaluateOnlyRecordsAndHitsSink(t *testing.T) { + // §8: synthesis still records in evaluate_only — records are the + // point — and the mode member keeps the record from implying a + // block happened. + e := NewInterceptionEmitter(EvaluateOnly, nil) + var seen []InterceptionRecord + e.SetRecordSink(func(r InterceptionRecord) { seen = append(seen, r) }) + r, err := e.RecordHostFailure(PreToolCall, HostFailure{Detail: "reflect.ValueError"}) + if err != nil { + t.Fatal(err) + } + if r.Mode != EvaluateOnly { + t.Fatalf("mode: %v", r.Mode) + } + if r.Verdict.Reason != "host_error:context_invalid" { + t.Fatalf("reason: %q", r.Verdict.Reason) + } + if len(seen) != 1 { + t.Fatalf("sink saw %d records", len(seen)) + } +} + +func TestRecordHostFailureDetailTruncatedByProjection(t *testing.T) { + // §10.3: the synthesized verdict crosses the same payload-free + // projection as every combined verdict. + e := NewInterceptionEmitter(Enforce, nil) + r, err := e.RecordHostFailure(PreToolCall, HostFailure{Detail: strings.Repeat("x", 300)}) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(r.Verdict.Message, "…") { + t.Fatalf("message not truncated: %q", r.Verdict.Message) + } + if len(r.Verdict.Message) > 256+len("…") { + t.Fatalf("message too long: %d bytes", len(r.Verdict.Message)) + } +} diff --git a/sdk/python/python/agent_hooks/emitter.py b/sdk/python/python/agent_hooks/emitter.py index 101be16..0d75e5f 100644 --- a/sdk/python/python/agent_hooks/emitter.py +++ b/sdk/python/python/agent_hooks/emitter.py @@ -442,6 +442,72 @@ async def emit_unchecked(self, ctx: AgentContext) -> InterceptionRecord: record = self._finalize(ctx, outcome, input_identity) return self._append(record) + def record_host_failure( + self, + point: InterceptionPoint, + detail: str | None = None, + *, + session_id: str | None = None, + sequence: int | None = None, + timestamp: str | None = None, + ) -> InterceptionRecord: + """§10.3/§11 host projection failure: synthesize and deliver the + fail-closed record for an emission whose ``AgentContext`` the + host could not construct at all — its own projection to the + wire failed before anything existed to :meth:`emit` (e.g. a + tool-call argument raised during to-wire conversion at the chat + seam). Without this the host can only fail the action closed + *recordless*; with it the trail stays complete under host-side + faults. + + The record is the §10.3 rejection shape: the payload-free + projection of a ``deny host_error:context_invalid`` carrying + ``detail`` (payload-free: an exception **type name** or a path, + never content — §14 data minimization) as its message; ``null`` + identities under the declared provider; ``decided_by: null``; + no per-interceptor summaries (no interceptor ran). The keyword + arguments carry the envelope facts the host still knows; + ``sequence`` SHOULD be the number the failed emission would + have carried (consume the next one from the context source so + records stay totally ordered); absent members record the §10.3 + unknown values (``""``/``-1``). The record takes the next slot + in the record stream (sink, then buffer) like any emission. In + ``enforce`` mode the host MUST still fail the action closed; in + ``evaluate_only`` the record documents the host fault without + implying enforcement (§8). + """ + # Deliberately partial basis: only the envelope facts the host + # still knows. It never passes §4 validation (``spec`` is + # absent), so the core's finalize always yields the §10.3 + # rejection shape — null identities under the declared provider + # — and keeps the synthesized ``context_invalid`` deny (with + # the host's detail) instead of substituting its own. + basis: dict[str, Any] = { + "interception_point": point.value if isinstance(point, InterceptionPoint) else point + } + if session_id is not None: + basis["session"] = {"id": session_id} + if sequence is not None: + basis["sequence"] = sequence + if timestamp is not None: + basis["timestamp"] = timestamp + verdict = Verdict.host_error(HostError.CONTEXT_INVALID, detail) + options: dict[str, Any] = { + "input_identity": None, + "identity_provider": self._provider_name(), + "enforced_identity": None, + "decided_by": None, + "composition": self._composition.to_wire(), + "verdicts": [], + "fold_truncated": None, + "resolved_by": None, + "interceptors_registered": len(self._interceptors), + } + record_json = _core.finalize( + dumps(basis), dumps(verdict.to_wire()), self._mode.value, dumps(options) + ) + return self._append(InterceptionRecord.from_core(json.loads(record_json))) + def _append(self, record: InterceptionRecord) -> InterceptionRecord: """Deliver ``record`` to the sink and the bounded buffer (§10.3).""" if self._record_sink is not None: diff --git a/sdk/python/tests/test_record_semantics.py b/sdk/python/tests/test_record_semantics.py index 4ed5467..11ea407 100644 --- a/sdk/python/tests/test_record_semantics.py +++ b/sdk/python/tests/test_record_semantics.py @@ -103,3 +103,76 @@ def test_knob_defaults_on_record() -> None: em.register(Allow()) r = _emit(em, _ctx()) assert r.composition.on_approval is not None # resolved default: stop + + +def test_record_host_failure_rejection_shape() -> None: + # §10.3 host projection failure: the host could not construct a + # context at all; the synthesized record is the rejection shape + # with the host's envelope facts. + from agent_hooks import InterceptionPoint + + em = InterceptionEmitter() + em.register(Allow()) + r = em.record_host_failure( + InterceptionPoint.PRE_TOOL_CALL, + "AttributeError", + session_id="s", + sequence=7, + timestamp="2026-01-01T00:00:00Z", + ) + assert not r.proceeds + assert r.interception_point is InterceptionPoint.PRE_TOOL_CALL + assert r.verdict.reason == "host_error:context_invalid" + assert r.verdict.message == "AttributeError" + # §10.3 rejection shape: null identities under the declared + # provider, nothing dispatched. + assert r.identity_provider == "jcs-sha256" + assert r.input_identity is None and r.enforced_identity is None + assert r.decided_by is None + assert r.verdicts == () + assert r.interceptors_registered == 1 + # Envelope facts the host supplied. + assert r.session_id == "s" + assert r.sequence == 7 + assert r.timestamp == "2026-01-01T00:00:00Z" + # The record entered the emitter's stream like any emission. + assert em.results == [r] + + +def test_record_host_failure_unknown_envelope_defaults() -> None: + from agent_hooks import InterceptionPoint + + em = InterceptionEmitter() + r = em.record_host_failure(InterceptionPoint.OUTPUT) + assert r.session_id == "" + assert r.sequence == -1 + assert r.timestamp is None + assert r.verdict.message is None + assert r.interceptors_registered == 0 + + +def test_record_host_failure_evaluate_only_records_and_hits_sink() -> None: + # §8: synthesis still records in evaluate_only — records are the + # point — and the mode member keeps the record from implying a + # block happened. + from agent_hooks import InterceptionPoint + + em = InterceptionEmitter(mode=EnforcementMode.EVALUATE_ONLY) + seen: list[Any] = [] + em.set_record_sink(seen.append) + r = em.record_host_failure(InterceptionPoint.PRE_TOOL_CALL, "TypeError") + assert r.mode is EnforcementMode.EVALUATE_ONLY + assert r.verdict.reason == "host_error:context_invalid" + assert seen == [r] + + +def test_record_host_failure_detail_truncated_by_projection() -> None: + # §10.3: the synthesized verdict crosses the same payload-free + # projection as every combined verdict. + from agent_hooks import InterceptionPoint + + em = InterceptionEmitter() + r = em.record_host_failure(InterceptionPoint.PRE_TOOL_CALL, "x" * 300) + assert r.verdict.message is not None + assert r.verdict.message.endswith("…") + assert len(r.verdict.message.encode()) <= 256 + len("…".encode()) diff --git a/sdk/rust/core/src/emitter.rs b/sdk/rust/core/src/emitter.rs index 8fb4246..8602dc6 100644 --- a/sdk/rust/core/src/emitter.rs +++ b/sdk/rust/core/src/emitter.rs @@ -184,6 +184,28 @@ fn is_host_synthesized(v: &Verdict) -> bool { .is_some_and(|r| r.starts_with("host_error:")) } +/// Envelope facts for [`InterceptionEmitter::record_host_failure`]: +/// what the host still knows about an emission whose context it could +/// not construct (§10.3 "Host projection failure"). Everything is +/// optional — an absent member records the §10.3 unknown value +/// (`session_id: ""`, `sequence: -1`, `timestamp` absent). +#[derive(Debug, Clone, Default)] +pub struct HostFailure { + /// Payload-free failure detail — an exception **type name** or a + /// path, never the content that failed to project (§14 data + /// minimization). Recorded as the verdict `message` (truncated by + /// the §10.3 projection). + pub detail: Option, + /// `session.id` of the failed emission, when the host knows it. + pub session_id: Option, + /// The sequence number the failed emission would have carried. The + /// host SHOULD consume the next number from its context source so + /// records stay totally ordered within the session (§10.3). + pub sequence: Option, + /// RFC 3339 event time, when the host has one. + pub timestamp: Option, +} + /// §9/§14 approval redactor: produces the context placed in every /// ApprovalRequest. pub type ApprovalRedactor = Box AgentContext + Send + Sync>; @@ -448,6 +470,75 @@ impl InterceptionEmitter { interceptors_registered: self.interceptors.len() as u32, }; let record = finalize(ctx, outcome.combined, self.mode, meta); + self.deliver(record) + } + + /// §10.3/§11 host projection failure: synthesize and deliver the + /// fail-closed record for an emission whose `AgentContext` the host + /// could not construct at all — its own projection to the wire + /// failed before anything existed to [`emit`](Self::emit) (e.g. a + /// tool-call argument getter raised during to-wire conversion at + /// the chat seam). Without this the host can only fail the action + /// closed *recordless*; with it the trail stays complete under + /// host-side faults. + /// + /// The record is the §10.3 rejection shape: the payload-free + /// projection of a `deny host_error:context_invalid` carrying + /// `failure.detail` (payload-free: a type name or path, never + /// content) as its message; `null` identities under the declared + /// provider; `decided_by: null`; no per-interceptor summaries (no + /// interceptor ran); envelope members from [`HostFailure`], with + /// the §10.3 unknown values (`""`/`-1`) where absent. It takes the + /// next slot in the record stream (sink, then buffer) like any + /// emission. In `enforce` mode the host MUST still fail the action + /// closed; in `evaluate_only` the record documents the host fault + /// without implying enforcement (§8) — the action failed on its + /// own, not on a verdict. + pub fn record_host_failure( + &mut self, + point: InterceptionPoint, + failure: HostFailure, + ) -> InterceptionRecord { + // Deliberately partial basis: only the envelope facts the host + // still knows. It never passes §4 validation (`spec` is + // absent), so `finalize` always yields the §10.3 rejection + // shape — null identities under the declared provider — and + // keeps the synthesized `context_invalid` deny (with the + // host's detail) instead of substituting its own. + let mut basis = AgentContext::new(); + basis.insert( + "interception_point".into(), + Value::String(point.as_str().to_owned()), + ); + if let Some(sid) = failure.session_id { + basis.insert("session".into(), serde_json::json!({ "id": sid })); + } + if let Some(seq) = failure.sequence { + basis.insert("sequence".into(), Value::from(seq)); + } + if let Some(ts) = failure.timestamp { + basis.insert("timestamp".into(), Value::String(ts)); + } + let verdict = Verdict::host_error(HostError::ContextInvalid, failure.detail); + let meta = FinalizeMeta { + input_identity: None, + identity_provider: self.identity.name(), + enforced_identity: None, + jcs_input_rejected: false, + unchanged_since_input: false, + decided_by: None, + composition: self.composition, + verdicts: Vec::new(), + fold_truncated: None, + resolved_by: None, + interceptors_registered: self.interceptors.len() as u32, + }; + let record = finalize(&basis, verdict, self.mode, meta); + self.deliver(record) + } + + /// Deliver a record to the sink and the bounded buffer (§10.3). + fn deliver(&mut self, record: InterceptionRecord) -> InterceptionRecord { if let Some(sink) = &self.record_sink { // Audit delivery must not take down the control plane. let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| sink(&record))); @@ -1394,6 +1485,96 @@ mod tests { assert_eq!(r.verdict.decision, Decision::Deny); } + #[tokio::test] + async fn host_failure_synthesizes_rejection_shape_record() { + // §10.3 host projection failure: the host could not construct + // a context at all; the synthesized record is the rejection + // shape with the host's envelope facts. + let mut e = InterceptionEmitter::new(EnforcementMode::Enforce, None); + e.register(Box::new(Scripted(Verdict::allow()))); + let r = e.record_host_failure( + InterceptionPoint::PreToolCall, + HostFailure { + detail: Some("InvalidOperationException".into()), + session_id: Some("s".into()), + sequence: Some(7), + timestamp: Some("2026-01-01T00:00:00Z".into()), + }, + ); + assert!(!r.proceeds()); + assert_eq!(r.interception_point, InterceptionPoint::PreToolCall); + assert_eq!( + r.verdict.reason.as_deref(), + Some("host_error:context_invalid") + ); + assert_eq!( + r.verdict.message.as_deref(), + Some("InvalidOperationException") + ); + // §10.3 rejection shape: null identities under the declared + // provider, nothing dispatched. + assert_eq!(r.identity_provider.as_deref(), Some(JCS_SHA256)); + assert!(r.input_identity.is_none() && r.enforced_identity.is_none()); + assert_eq!(r.decided_by, None); + assert!(r.verdicts.is_empty(), "no interceptor ran"); + assert_eq!(r.interceptors_registered, 1); + // Envelope facts the host supplied. + assert_eq!(r.session_id, "s"); + assert_eq!(r.sequence, 7); + assert_eq!(r.timestamp.as_deref(), Some("2026-01-01T00:00:00Z")); + // The record entered the emitter's stream like any emission. + assert_eq!(e.records().len(), 1); + } + + #[tokio::test] + async fn host_failure_defaults_are_the_unknown_values() { + let mut e = InterceptionEmitter::new(EnforcementMode::Enforce, None); + let r = e.record_host_failure(InterceptionPoint::Output, HostFailure::default()); + assert_eq!(r.session_id, ""); + assert_eq!(r.sequence, -1); + assert!(r.timestamp.is_none()); + assert!(r.verdict.message.is_none()); + assert_eq!(r.interceptors_registered, 0); + } + + #[tokio::test] + async fn host_failure_records_in_evaluate_only_without_implying_enforcement() { + // §8: synthesis still records in evaluate_only — records are + // the point — and the mode member keeps the record from + // implying a block happened. + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + let seen = Arc::new(AtomicUsize::new(0)); + let seen2 = seen.clone(); + let mut e = InterceptionEmitter::new(EnforcementMode::EvaluateOnly, None); + e.set_record_sink(move |_r| { + seen2.fetch_add(1, Ordering::SeqCst); + }); + let r = e.record_host_failure(InterceptionPoint::PreToolCall, HostFailure::default()); + assert_eq!(r.mode, EnforcementMode::EvaluateOnly); + assert_eq!( + r.verdict.reason.as_deref(), + Some("host_error:context_invalid") + ); + assert_eq!(seen.load(Ordering::SeqCst), 1, "sink saw the record"); + } + + #[tokio::test] + async fn host_failure_detail_is_truncated_by_the_projection() { + // §10.3: the synthesized verdict crosses the same payload-free + // projection as every combined verdict. + let mut e = InterceptionEmitter::new(EnforcementMode::Enforce, None); + let r = e.record_host_failure( + InterceptionPoint::PreToolCall, + HostFailure { + detail: Some("x".repeat(300)), + ..HostFailure::default() + }, + ); + let m = r.verdict.message.unwrap(); + assert!(m.ends_with('…') && m.len() <= 256 + '…'.len_utf8()); + } + #[tokio::test] async fn record_sink_and_ring_buffer() { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/sdk/rust/core/src/lib.rs b/sdk/rust/core/src/lib.rs index 0e5bada..f14b7d6 100644 --- a/sdk/rust/core/src/lib.rs +++ b/sdk/rust/core/src/lib.rs @@ -72,7 +72,7 @@ pub use composition::{ aggregate_strictest, severity, Aggregate, CompositionConfig, CompositionProfile, OnApproval, SynthesisPolicy, }; -pub use emitter::{IdentityProvider, InterceptionBlocked, InterceptionEmitter}; +pub use emitter::{HostFailure, IdentityProvider, InterceptionBlocked, InterceptionEmitter}; pub use enforce::{apply_transform_to_ctx, finalize, validate_transform, FinalizeMeta}; pub use path::{apply as apply_transform_path, parse as parse_transform_path, resolve, Segment}; pub use types::{ diff --git a/sdk/typescript/src/emitter.ts b/sdk/typescript/src/emitter.ts index df94f14..ba9b223 100644 --- a/sdk/typescript/src/emitter.ts +++ b/sdk/typescript/src/emitter.ts @@ -160,6 +160,27 @@ type Consultation = const NOT_CONSULTED: Consultation = { consulted: false }; +/** Envelope facts for {@link InterceptionEmitter.recordHostFailure}: + * what the host still knows about an emission whose context it could + * not construct (§10.3 "Host projection failure"). Everything is + * optional — an absent member records the §10.3 unknown value + * (`session_id: ""`, `sequence: -1`, `timestamp` absent). */ +export interface HostFailure { + /** Payload-free failure detail — an exception **type name** or a + * path, never the content that failed to project (§14 data + * minimization). Recorded as the verdict `message` (truncated by + * the §10.3 projection). */ + detail?: string; + /** `session.id` of the failed emission, when the host knows it. */ + session_id?: string; + /** The sequence number the failed emission would have carried. The + * host SHOULD consume the next number from its context source so + * records stay totally ordered within the session (§10.3). */ + sequence?: number; + /** RFC 3339 event time, when the host has one. */ + timestamp?: string; +} + export class InterceptionEmitter { private readonly interceptors: Interceptor[] = []; private _records: InterceptionRecord[] = []; @@ -389,6 +410,62 @@ export class InterceptionEmitter { } else { record = finalize(ctx, outcome.combined, this.mode, meta); } + return this.deliver(record); + } + + /** §10.3/§11 host projection failure: synthesize and deliver the + * fail-closed record for an emission whose `AgentContext` the host + * could not construct at all — its own projection to the wire failed + * before anything existed to {@link emit} (e.g. a tool-call argument + * getter threw during to-wire conversion at the chat seam). Without + * this the host can only fail the action closed *recordless*; with + * it the trail stays complete under host-side faults. + * + * The record is the §10.3 rejection shape: the payload-free + * projection of a `deny host_error:context_invalid` carrying + * `failure.detail` (payload-free: a type name or path, never + * content) as its message; `null` identities under the declared + * provider; `decided_by: null`; no per-interceptor summaries (no + * interceptor ran); envelope members from {@link HostFailure}, with + * the §10.3 unknown values (`""`/`-1`) where absent. It takes the + * next slot in the record stream (sink, then buffer) like any + * emission. In `enforce` mode the host MUST still fail the action + * closed; in `evaluate_only` the record documents the host fault + * without implying enforcement (§8). */ + recordHostFailure(point: InterceptionPoint, failure: HostFailure = {}): InterceptionRecord { + // Deliberately partial basis: only the envelope facts the host + // still knows. It never passes §4 validation (`spec` is absent), + // so the core's finalize always yields the §10.3 rejection shape — + // null identities under the declared provider — and keeps the + // synthesized `context_invalid` deny (with the host's detail) + // instead of substituting its own. + const basis: Record = { interception_point: point }; + if (failure.session_id !== undefined) basis["session"] = { id: failure.session_id }; + if (failure.sequence !== undefined) basis["sequence"] = failure.sequence; + if (failure.timestamp !== undefined) basis["timestamp"] = failure.timestamp; + const providerName = + this.identity === null ? null : this.identity === JCS_SHA256 ? JCS_SHA256 : this.identity.name; + const record = finalize( + basis as unknown as AgentContext, + hostErrorVerdict(HostError.ContextInvalid, failure.detail), + this.mode, + { + input_identity: null, + identity_provider: providerName, + enforced_identity: null, + decided_by: null, + composition: this.composition, + verdicts: null, + fold_truncated: null, + resolved_by: null, + interceptors_registered: this.interceptors.length, + }, + ); + return this.deliver(record); + } + + /** Deliver a record to the sink and the bounded buffer (§10.3). */ + private deliver(record: InterceptionRecord): InterceptionRecord { if (this.recordSink) { // Audit delivery must not take down the control plane (§10.3). try { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 11000cc..37f88fc 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -513,6 +513,7 @@ export function composeAggregate( export { AgentContextBuilder } from "./builder"; export { InterceptionEmitter } from "./emitter"; +export type { HostFailure } from "./emitter"; /** Raised by a host when a verdict blocks the guarded action (§6). */ /** Returned by `InterceptionEmitter.emit` on a proceeding emission: diff --git a/sdk/typescript/test/emitter.test.mjs b/sdk/typescript/test/emitter.test.mjs index fb26a76..ce9daa2 100644 --- a/sdk/typescript/test/emitter.test.mjs +++ b/sdk/typescript/test/emitter.test.mjs @@ -649,3 +649,64 @@ test("setComposition accepts every declared §7.2 profile", async () => { const rec = await e.emitUnchecked(ctx()); assert.equal(rec.composition.profile, "parallel/unanimous"); }); + +// ---- §10.3 host projection failure ------------------------------------------ + +test("recordHostFailure synthesizes the rejection-shape record", () => { + const e = new InterceptionEmitter(EnforcementMode.Enforce); + e.register(scripted({ decision: "allow" })); + const r = e.recordHostFailure("pre_tool_call", { + detail: "TypeError", + session_id: "s", + sequence: 7, + timestamp: "2026-01-01T00:00:00Z", + }); + assert.equal(r.interception_point, "pre_tool_call"); + assert.equal(r.verdict.decision, Decision.Deny); + assert.equal(r.verdict.reason, "host_error:context_invalid"); + assert.equal(r.verdict.message, "TypeError"); + // §10.3 rejection shape: null identities under the declared + // provider, nothing dispatched. + assert.equal(r.identity_provider, "jcs-sha256"); + assert.equal(r.input_identity, null); + assert.equal(r.enforced_identity, null); + assert.equal(r.decided_by, null); + assert.equal(r.verdicts, undefined, "no interceptor ran"); + assert.equal(r.interceptors_registered, 1); + // Envelope facts the host supplied. + assert.equal(r.session_id, "s"); + assert.equal(r.sequence, 7); + assert.equal(r.timestamp, "2026-01-01T00:00:00Z"); + // The record entered the emitter's stream like any emission. + assert.equal(e.records.length, 1); +}); + +test("recordHostFailure defaults are the §10.3 unknown values", () => { + const e = new InterceptionEmitter(EnforcementMode.Enforce); + const r = e.recordHostFailure("output"); + assert.equal(r.session_id, ""); + assert.equal(r.sequence, -1); + assert.equal(r.timestamp, undefined); + assert.equal(r.verdict.message, undefined); + assert.equal(r.interceptors_registered, 0); +}); + +test("recordHostFailure records in evaluate_only and hits the sink", () => { + // §8: synthesis still records in evaluate_only — records are the + // point — and the mode member keeps the record from implying a + // block happened. + const e = new InterceptionEmitter(EnforcementMode.EvaluateOnly); + const seen = []; + e.setRecordSink((r) => seen.push(r)); + const r = e.recordHostFailure("pre_tool_call", { detail: "TypeError" }); + assert.equal(r.mode, EnforcementMode.EvaluateOnly); + assert.equal(r.verdict.reason, "host_error:context_invalid"); + assert.deepEqual(seen, [r]); +}); + +test("recordHostFailure detail is truncated by the §10.3 projection", () => { + const e = new InterceptionEmitter(EnforcementMode.Enforce); + const r = e.recordHostFailure("pre_tool_call", { detail: "x".repeat(300) }); + assert.ok(r.verdict.message.endsWith("…")); + assert.ok(Buffer.byteLength(r.verdict.message, "utf8") <= 256 + Buffer.byteLength("…")); +}); diff --git a/spec/AGENT-HOOKS-0.1.md b/spec/AGENT-HOOKS-0.1.md index 65ee046..5f13c1c 100644 --- a/spec/AGENT-HOOKS-0.1.md +++ b/spec/AGENT-HOOKS-0.1.md @@ -1079,6 +1079,35 @@ of the record. | `resolved_by` | Consultation outcome (§7.6): `"approval"` iff a permit resolution substituted for a verdict; `"rejection"` iff the seam was consulted and did **not** lift the deny (reject, unresolved, resolver failure, or echo violation); absent iff the seam was never consulted. A record reader can therefore always answer "was a human consulted, and did they permit?". | | `interceptors_registered` | Number of interceptors registered at emission time. Together with `verdicts`/`fold_truncated` this makes skipped interceptors detectable from the record alone. | +**Host projection failure.** The reserved reasons of §11 assume an +emission: a context reached the emitter and something about it or its +dispatch failed. A fault in the host's **own projection to the wire** +— the host cannot construct an `AgentContext` for a point at all +(e.g. a tool-call argument's property getter throws during to-wire +conversion at the chat seam) — happens before anything exists to +emit, and a host without further provision can only fail the action +closed **recordless**, leaving the trail silently shorter than the +session. A host MUST still fail the guarded action closed in +`enforce` mode, and SHOULD produce an interception record for the +failed emission: the payload-free projection of a synthesized `deny` +with reason `host_error:context_invalid` (§11 — the host could not +construct a schema-valid context) whose OPTIONAL `message` names the +failure **type or path only**, never the content that failed to +project (§14); `input_identity` and `enforced_identity` `null` under +the declared provider (the rejection shape above); `decided_by: +null`, no `verdicts` entries, and `interceptors_registered` reporting +the registration count — no interceptor ran. The envelope members +carry what the host still knows: `session_id` and `sequence` SHOULD +be the values the failed emission would have carried — the host +SHOULD consume the next sequence number for it, so records stay +totally ordered within the session — and are `""`/`-1` when unknown; +`timestamp` is present when the host has one. The record is produced +in both enforcement modes; in `evaluate_only` it documents the host +fault without implying enforcement (§8) — the action failed on its +own, not on a verdict. SDK emitters expose this as the +`record_host_failure` affordance (per-language naming), delivered +through the same record stream (sink, then buffer) as every emission. + *Informative — OpenTelemetry alignment.* The optional context fields `usage.prompt_tokens`/`usage.completion_tokens` (§4.5) correspond to the OTel GenAI semantic-convention attributes `gen_ai.usage.input_tokens`/ @@ -1094,7 +1123,8 @@ divergent naming. [Pure Specification] A host MUST use the following `reason` values, and only these, when it -synthesizes a `deny` verdict per §6.3, §5.2, §7.5, or §9. An interceptor MUST NOT emit a +synthesizes a `deny` verdict per §6.3, §5.2, §7.5, §9, or §10.3 (host +projection failure). An interceptor MUST NOT emit a `reason` beginning with `host_error:`. | Reason | Cause |