Skip to content

fix(sessions): persist approval turn context#1219

Merged
Aaronontheweb merged 5 commits into
devfrom
fix/1213-approval-state-machine
May 29, 2026
Merged

fix(sessions): persist approval turn context#1219
Aaronontheweb merged 5 commits into
devfrom
fix/1213-approval-state-machine

Conversation

@Aaronontheweb
Copy link
Copy Markdown
Collaborator

Summary

  • Adds durable TurnContext persistence for approval-paused session turns.
  • Redrives recovered approvals from persisted authority instead of synthesized MessageSource.
  • Fails closed for approval-required source-less turns and unsafe legacy recovery gaps.
  • Adds focused turn-context, serialization, recall, approval recovery, and memory-safety coverage.

Validation

  • dotnet test src/Netclaw.Actors.Tests/Netclaw.Actors.Tests.csproj
  • openspec validate redesign-session-approval-state-machine --strict
  • dotnet slopwatch analyze
  • pwsh ./scripts/Add-FileHeaders.ps1 -Verify
  • git diff --check

Fixes #1213

Comment thread src/Netclaw.Actors/Channels/TurnContext.cs Fixed
Comment thread src/Netclaw.Actors/Channels/TurnContext.cs Fixed
Comment thread src/Netclaw.Actors/Sessions/LlmSessionActor.cs Fixed
Comment thread src/Netclaw.Actors.Tests/Channels/TurnContextTests.cs Fixed
Comment thread src/Netclaw.Actors.Tests/Channels/TurnContextTests.cs Fixed
Comment thread src/Netclaw.Actors.Tests/Channels/TurnContextTests.cs Fixed
Copy link
Copy Markdown
Collaborator Author

@Aaronontheweb Aaronontheweb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review notes to make the behavior changes easier to review.

/// stay on <see cref="MessageSource"/>; this model carries only security and
/// provenance fields that must survive approval pause and recovery.
/// </summary>
public sealed record TurnContext
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main design change: a turn now has one durable object for who asked, from where, and under what trust boundary. We stop treating live transport metadata as something we can safely reconstruct after restart.

_currentTurnSource = cmd.Source;
_currentTrustContext = _trustContextDeriver?.Derive(cmd.Source);
BindTurnTelemetry(cmd.Source);
_currentTurnContext = TurnContext.FromMessageSource(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We build the turn context once when the user message is accepted. Everything later in the turn, including approval recovery, uses this same context instead of re-deriving trust fields from scattered state.

repeated string option_keys = 15;
bool has_third_party_adopted_context = 16;
repeated string adopted_speaker_ids = 17;
TurnContextRecordProto turn_context = 18;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is additive persistence only. Old journals still deserialize, but new approval requests carry the original turn context so approval after restart can resume safely.

Directory = c.Directory
})
.ToArray(),
TurnContext = _currentTurnContext?.ToRecord(),
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the approval prompt becomes recoverable. The approval event now stores the original turn context, not just individual copied fields that could drift or be forgotten.


internal static class ToolApprovalTurnContext
{
public static TurnContext? Restore(ToolApprovalRequested evt, out string? failure)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recovery is centralized here on purpose. New events use the persisted turn context; old events go through one legacy compatibility path so fallback behavior stays visible and contained.

}

private bool TryRedriveToolBatchAfterApproval(string callId)
private ApprovalRedriveOutcome TryRedriveToolBatchAfterApproval(string callId)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redrive now has three outcomes: started, deferred, or failed. That distinction matters because a safe wait for sibling approvals should not look the same as a failed recovery.

_currentTrustContext = _trustContextDeriver?.Derive(synthesized);
BindTurnTelemetry(synthesized);
}
if (pending.TurnContext is not { } turnContext)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replaces the old synthetic MessageSource recovery path. If the original turn context is missing, we do not run the tool under guessed authority.

catch (ToolApprovalRequiredException approvalEx)
when (approvalChannel is not null && emitApprovalRequest is not null)
{
if (!CanRequestInteractiveApproval(source, turnContext))
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A tool call that requires human approval but has no interactive requester now fails immediately. Previously it could emit a prompt that nobody could validly approve.

TurnContext? turnContext = null)
{
var audience = turnSource?.Audience
var audience = turnContext?.Audience
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory recall now prefers the restored turn context. This keeps recovered approval turns from accidentally using a weaker or default memory audience after restart.

// no safe default — defaulting a missing source to Personal would silently
// escalate the job's audience. A null source here is a programming error.
if (source is null)
// A background job inherits the submitting turn's authority context.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Background jobs inherit the submitting turn's authority context. There is no safe default here because guessing could run a job under the wrong audience or boundary.

@Aaronontheweb Aaronontheweb merged commit 397eb0d into dev May 29, 2026
20 checks passed
@Aaronontheweb Aaronontheweb deleted the fix/1213-approval-state-machine branch May 29, 2026 19:16
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.

Redesign LlmSessionActor approval state machine

1 participant