Skip to content

sdk: Forward decisionContext on permission replies across languages - #2294

Open
aymenfurter wants to merge 11 commits into
mainfrom
aymenfurter-rust-permission-decision-context
Open

sdk: Forward decisionContext on permission replies across languages#2294
aymenfurter wants to merge 11 commits into
mainfrom
aymenfurter-rust-permission-decision-context

Conversation

@aymenfurter

@aymenfurter aymenfurter commented Aug 7, 2026

Copy link
Copy Markdown

Why

The runtime emits auto_approval_decision telemetry only when the client sends an explicit decisionContext with its permission reply. The wire schema, runtime, and generated SDK types already support the field.

The hand-written permission reply paths in these SDKs did not forward it. Hosts that answered permission requests through an SDK therefore could not tell the runtime whether a decision came from a person, host policy, or an automated recommendation.

What changed

Permission handlers can now attach optional decision context. The SDK sends it as a top-level sibling of result in session.permissions.handlePendingPermissionRequest:

  • Node: createAttributedPermissionResult(result, context)
  • Python: copilot.create_attributed_permission_result(result, context)
  • Go: copilot.NewAttributedPermissionResult(result, context)
  • .NET: set DecisionContext on the permission decision
  • Java: PermissionRequestResult.approveOnce().setDecisionContext(context)
  • Rust: PermissionResult::approve_once().with_context(context)

Each SDK follows its existing language conventions. Applying context twice replaces the previous context instead of nesting it. A no-result response remains suppressed.

When a handler does not supply context, the SDK sends the same legacy JSON shape with only sessionId, requestId, and result. It does not send decisionContext: null.

No schema or generated code changed. This PR only fills the gap in the hand-written permission reply paths.

Rust compatibility

Rust keeps PermissionHandler as the single handler API. Attribution is represented by a new PermissionResult::AttributedDecision { decision, context } variant, and with_context returns PermissionResult directly.

This is an intentional Rust source break. Clients that match PermissionResult exhaustively must handle the new variant. Existing handler implementations and session registration remain unchanged. This avoids a parallel attributed handler trait and avoids ambiguous configuration with two public handler fields.

The other five SDK changes are additive.

Testing

  • Unit tests in all six SDKs cover context forwarding, omission when absent, no-result behavior, and replacement when context is applied twice.
  • The Node E2E test runs the real permission flow and checks the exact params passed to the CLI.
  • The Rust fake-server test captures the actual outbound JSON-RPC request and verifies that decisionContext is beside result, not inside it.
  • Rust library and session tests pass. Clippy and formatting are clean.
  • The targeted Node, Python, Go, .NET, and Java test suites pass.

Release note

Permission handlers can attach decisionContext so the runtime can attribute permission decisions. This is additive for Node, Python, Go, .NET, and Java. Rust clients with exhaustive matches on PermissionResult must handle the new AttributedDecision variant.

Aymen Furter and others added 2 commits August 7, 2026 14:30
The runtime emits `auto_approval_decision` telemetry only when a client
supplies an explicit `decisionContext` alongside its permission reply.
The generated wire types already carry the optional field, but the
hand-written reply path built a fixed three-key JSON literal and had no
way for a PermissionHandler to attribute its decision.

Add `PermissionResult::AttributedDecision` plus a `with_context` builder,
and forward the context as a top-level sibling of `result`. When no
context is supplied the emitted params are byte-identical to before, so
legacy behavior is preserved exactly.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Permission handlers can now attach optional provenance describing how
and where a decision was reached. The SDK forwards it to the runtime as
a sibling of `result` -- never nested inside it -- so auto-approval
decisions made programmatically can be attributed.

The wire schema and every language's generated types already accepted
the field; only the hand-written reply paths never populated it. No
schema, codegen, or protocol version change is required.

Fully additive: handlers returning a plain decision emit a payload
byte-identical to before, with no `decisionContext` key at all.
No-result suppression is preserved in every language.

Per CONTRIBUTING.md, the feature is implemented in sync across all six
SDKs:

- Rust:   PermissionResult::AttributedDecision + with_context()
- Node:   AttributedPermissionResult + withDecisionContext()
- Python: AttributedPermissionResult + with_decision_context()
- Go:     AttributedPermissionResult + WithDecisionContext()
- .NET:   PermissionDecision.WithContext()
- Java:   PermissionRequestResult.withContext()

Each language gains focused unit tests asserting the sibling placement,
the byte-identical legacy payload, replace-not-nest on re-application,
and preserved no-result suppression. Node and Rust add end-to-end
coverage against a CLI carrying the runtime-side support.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 79152db2-4cc7-4777-983a-655fae5b68c9
@github-actions

This comment has been minimized.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Generated by SDK Consistency Review Agent for #2294 · sonnet46 53.2 AIC · ⌖ 5.69 AIC · ⊞ 6.6K

Java accepted null as a "clear" operation while .NET rejects it and the
other SDKs disallow it at the type level. Since null is Java's default,
an uninitialized variable would have silently dropped the context --
producing exactly the unattributed telemetry this feature removes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 79152db2-4cc7-4777-983a-655fae5b68c9
@aymenfurter
aymenfurter marked this pull request as ready for review August 7, 2026 15:21
@aymenfurter
aymenfurter requested a review from a team as a code owner August 7, 2026 15:21
Copilot AI balanced review requested due to automatic review settings August 7, 2026 15:21
@github-actions

This comment has been minimized.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds optional permission-decision provenance forwarding across all six SDKs while preserving the legacy wire shape when absent.

Changes:

  • Adds language-specific APIs for attaching decisionContext.
  • Forwards context beside result in permission RPCs.
  • Adds unit and E2E coverage plus compatibility documentation.
Show a summary per file
File Description
test/snapshots/permissions/should_honor_a_decision_annotated_with_decisioncontext.yaml Adds shared permission E2E fixture.
rust/tests/e2e/permissions.rs Tests attributed rejection end to end.
rust/src/types.rs Re-exports generated context types.
rust/src/session.rs Builds attributed permission RPC parameters.
rust/src/handler.rs Adds attributed permission results.
python/test_permission_decision_context.py Tests Python serialization behavior.
python/copilot/session.py Adds and forwards attributed results.
python/copilot/__init__.py Exports the new Python API.
nodejs/test/e2e/permissions.e2e.test.ts Verifies live RPC shape and behavior.
nodejs/test/client.test.ts Tests Node.js attribution handling.
nodejs/src/types.ts Defines attributed result helpers and types.
nodejs/src/session.ts Forwards context in permission replies.
nodejs/src/index.ts Exports the new Node.js API.
java/src/test/java/com/github/copilot/rpc/PermissionRequestResultDecisionContextTest.java Tests Java result serialization.
java/src/main/java/com/github/copilot/rpc/PermissionRequestResult.java Stores optional decision context.
java/src/main/java/com/github/copilot/CopilotSession.java Passes context to the generated RPC.
go/types.go Exposes generated decision-context types.
go/session.go Unwraps and forwards attributed decisions.
go/permissions.go Adds the Go attribution wrapper.
go/permission_context_test.go Tests raw Go JSON-RPC output.
dotnet/test/Unit/ClientSessionLifetimeTests.cs Tests .NET forwarding and omission.
dotnet/src/Session.cs Passes context through the RPC client.
dotnet/src/PermissionDecision.cs Adds fluent context attachment.
docs/troubleshooting/compatibility.md Documents optional attribution support.

Review details

  • Files reviewed: 24/24 changed files
  • Comments generated: 5
  • Review effort level: Balanced

Comment thread go/session.go Outdated
Comment thread rust/src/handler.rs Outdated
Comment thread java/src/main/java/com/github/copilot/CopilotSession.java
Comment thread python/copilot/session.py Outdated
Comment thread go/permissions.go Outdated
Comment thread dotnet/src/PermissionDecision.cs Outdated
Comment thread dotnet/src/PermissionDecision.cs
Go embedded the decision interface in AttributedPermissionResult, which
promotes the interface methods to the value type. A handler returning
`*WithDecisionContext(...)` therefore satisfied rpc.PermissionDecision
but slipped past the pointer-only type assertion: the wrapper itself was
sent as `result` and the context was silently dropped. Both the unwrap
in the session and the replace-not-nest check now accept either form,
with regression tests that fail against the pointer-only code.

Rust PermissionResult gains #[non_exhaustive], matching the convention
used throughout this crate, so downstream exhaustive matches keep
compiling as variants are added.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 79152db2-4cc7-4777-983a-655fae5b68c9
@github-actions

This comment has been minimized.

Aymen Furter and others added 3 commits August 7, 2026 19:02
The hand-written .NET SDK has no other fluent `With*` builders, so adding
one here introduced a pattern that exists nowhere else in the surface.
Java and Rust keep their fluent forms because those match long-standing
convention in each of those SDKs.

Callers now set the public `DecisionContext` property through an object
initializer, which is what the class documentation already recommends for
richer decisions. The wire format is unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 79152db2-4cc7-4777-983a-655fae5b68c9
The Go, Node, and Python helpers were named `WithDecisionContext` and
friends, a shape none of those SDKs use. In Go a `WithX` function
conventionally builds a functional option rather than decorating a value,
and there were no `With` functions in the package at all. Node and Python
had no `with`-prefixed helper either.

Each now follows the constructor naming its own SDK already uses:
`NewAttributedPermissionResult` alongside `NewCanvasError`,
`createAttributedPermissionResult` alongside `createCanvas`, and
`create_attributed_permission_result` alongside
`create_session_fs_adapter`.

The Node wrapper also gains a `kind: "attributed"` discriminant so it is
narrowed the same way as every other union in that SDK, instead of by
testing for the presence of a property.

Java and Rust keep their fluent methods, which match long-standing
convention in each of those SDKs. Behavior and wire format are unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 79152db2-4cc7-4777-983a-655fae5b68c9
The pointer/value type switch was duplicated verbatim in
NewAttributedPermissionResult and the session permission dispatch.
Embedding an interface promotes its methods to the value type too, so
both forms satisfy rpc.PermissionDecision and both must be unwrapped --
missing the value case is what produced the bug caught in review.

Fold both copies into splitAttribution so that hazard is stated and
handled in exactly one place.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 79152db2-4cc7-4777-983a-655fae5b68c9
@github-actions

This comment has been minimized.

* if {@code context} is {@code null}
* @since 1.3.0
*/
public PermissionRequestResult withContext(PermissionDecisionContext context) {

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.

Is this necessary?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Renamed to setDecisionContext to match the getter and the other setters here, and dropped the requireNonNull since null now means "no context" everywhere else.

The Java SDK uses setX for mutators (589 of them); withX appears twice
and both return a copy rather than mutating in place. withContext was
the odd one out on both counts, and did not match its own getter or the
sibling setKind/setRules/setFeedback on this class.

Also drop the requireNonNull. The other setters here do not null-check,
and null now means "no context" in every other SDK, so throwing made
Java the outlier rather than the consistent one.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 79152db2-4cc7-4777-983a-655fae5b68c9
@github-actions

This comment has been minimized.

Hand-written Rust here has 157 enum variants: 89 unit, 35 tuple with
exactly one payload, and 33 struct-style. Every variant carrying two or
more values uses the struct form, so a two-payload tuple was the only
one of its kind.

Name the payloads instead. Construction and both read sites now say
which value they mean rather than relying on position.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 79152db2-4cc7-4777-983a-655fae5b68c9
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

✅ Cross-SDK Consistency Review

This PR adds decisionContext support to all six SDK implementations consistently. No cross-language gaps found.

Feature parity: All SDKs ✅

SDK Helper API Pattern
Node.js createAttributedPermissionResult(result, ctx) Wrapper function, exports new types
Python create_attributed_permission_result(result, ctx) Wrapper function + AttributedPermissionResult dataclass
Go NewAttributedPermissionResult(result, ctx) Struct embedding rpc.PermissionDecision, satisfies the interface
.NET decision.DecisionContext = ctx [JsonIgnore] property on PermissionDecision base class
Java result.setDecisionContext(ctx) @JsonIgnore field with fluent setter on PermissionRequestResult
Rust PermissionResult::approve_once().with_context(ctx) New AttributedDecision enum variant + builder method

Key consistency properties verified ✅

  • Wire shape is identical across all SDKs: decisionContext is always emitted as a sibling of result, never nested inside it
  • Backward-compatible: omitting context produces byte-identical output to the current behavior (no decisionContext key emitted)
  • Re-attribution replaces, never nests: all implementations unwrap an already-attributed result before re-wrapping
  • No-result decisions: all SDKs correctly handle the case where NoResult suppresses the response even when context is attached
  • Naming conventions: each SDK follows its established idiom (camelCase for JS/Java, snake_case for Python/Rust, PascalCase for Go/C#)

Tests coverage ✅

All SDKs include unit tests covering: context present, context absent (key set verification), re-application replacing not nesting, and no-result with context. Node and Rust additionally have E2E coverage verifying the outgoing wire shape.

The changes are well-structured and maintain strong cross-language consistency.

Generated by SDK Consistency Review Agent for #2294 · sonnet46 33 AIC · ⌖ 5.48 AIC · ⊞ 6.6K ·

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

  • Files reviewed: 24/24 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@aymenfurter
aymenfurter marked this pull request as draft August 10, 2026 12:18
Add a separate attributed permission handler path so clients can forward decision context without changing the existing PermissionResult enum or PermissionHandler contract.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 79152db2-4cc7-4777-983a-655fae5b68c9
@aymenfurter

Copy link
Copy Markdown
Author

@stephentoub I moved this PR back to draft after finding that the current Rust API change breaks existing clients. Adding AttributedDecision to PermissionResult, or marking the enum #[non_exhaustive], breaks clients that match its existing variants exhaustively.

I tested a backwards-compatible alternative that keeps PermissionHandler and PermissionResult unchanged. It adds a separate AttributedPermissionHandler for clients that need to provide decisionContext.

Existing clients continue to use:

impl PermissionHandler for MyHandler {
    async fn handle(...) -> PermissionResult {
        PermissionResult::approve_once()
    }
}

SessionConfig::default()
    .with_permission_handler(Arc::new(MyHandler))

Copilot App would use:

impl AttributedPermissionHandler for CopilotAppPermissionHandler {
    async fn handle(...) -> AttributedPermissionResult {
        PermissionResult::approve_once().with_context(
            PermissionDecisionContext {
                outcome: PermissionDecisionOutcome::PromptedUser,
                source: PermissionDecisionSource::HumanResponse,
                surface: PermissionDecisionSurface::CopilotApp,
            },
        )
    }
}

let client = Client::start(ClientOptions::default()).await?;

let session = client
    .create_session(
        SessionConfig::default().with_attributed_permission_handler(
            Arc::new(CopilotAppPermissionHandler),
        ),
    )
    .await?;

Existing clients compile without changes, and context-aware clients implement only one handler method. The cost is one additional handler trait and configuration method.

@github-actions github-actions Bot mentioned this pull request Aug 10, 2026
@aymenfurter
aymenfurter marked this pull request as ready for review August 10, 2026 13:01
@stephentoub

Copy link
Copy Markdown
Collaborator

Adding AttributedDecision to PermissionResult, or marking the enum #[non_exhaustive], breaks clients that match its existing variants exhaustively.

In the past, I believe @tclem has suggested we shouldn't care about such breaking changes for rust consumers. Tim?

@aymenfurter
aymenfurter marked this pull request as draft August 10, 2026 14:21
@aymenfurter

Copy link
Copy Markdown
Author

@tclem What do you think? 👀

@aymenfurter
aymenfurter marked this pull request as ready for review August 11, 2026 13:17
Comment thread rust/tests/e2e/permissions.rs Outdated
}

#[tokio::test]
async fn should_honor_a_decision_annotated_with_decisioncontext() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This test passes even if decisionContext forwarding is removed; it only observes the rejection. Please test the outbound JSON-RPC request with the existing fake server in session_test.rs, asserting decisionContext beside result.

  Generated via Copilot (GPT-5.6 Sol) on behalf of @tclem

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. I removed the E2E test because it only proved that the rejection was honored. The replacement test uses the existing fake server in session_test.rs, captures the outbound JSON-RPC request, and verifies that decisionContext is beside result.

Comment thread rust/src/handler.rs Outdated

/// Handler for permission requests that also reports how the decision was made.
#[async_trait]
pub trait AttributedPermissionHandler: Send + Sync + 'static {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we avoid introducing a second handler hierarchy? Attribution changes the response metadata, not the dispatch contract. I’d rather evolve PermissionResult and keep PermissionHandler as the single API. If source compatibility is mandatory, a provided PermissionHandler::handle_with_context defaulting to the existing handle is still less permanent surface than parallel traits and configuration.

  Generated via Copilot (GPT-5.6 Sol) on behalf of @tclem

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. I removed AttributedPermissionHandler and kept PermissionHandler as the single dispatch API. PermissionResult now carries the optional attribution through an AttributedDecision variant. This intentionally creates one Rust source break: exhaustive matches on PermissionResult must handle the new variant. Existing handler implementations and session registration remain unchanged.

Comment thread rust/src/handler.rs Outdated
/// surface: PermissionDecisionSurface::Sdk,
/// });
/// ```
pub fn with_context(self, context: PermissionDecisionContext) -> AttributedPermissionResult {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This API is misleading: PermissionResult::approve_once().with_context(ctx) no longer returns PermissionResult, so it cannot be returned from PermissionHandler::handle. Please keep with_context returning Self; the obvious usage should compile.

  Generated via Copilot (GPT-5.6 Sol) on behalf of @tclem

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. with_context now returns Self, so PermissionResult::approve_once().with_context(context) can be returned directly from PermissionHandler::handle. Calling it again replaces the previous context.

Comment thread rust/src/types.rs Outdated
/// emit `permission.requested` broadcasts to this client.
pub permission_handler: Option<Arc<dyn PermissionHandler>>,
/// Optional context-aware permission-request handler.
pub attributed_permission_handler: Option<Arc<dyn AttributedPermissionHandler>>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These public fields can both be set through direct assignment, while resolve_handler silently prefers the attributed handler. Since direct field assignment is explicitly supported, this introduces an invalid and ambiguous state. A single handler field avoids it.

  Generated via Copilot (GPT-5.6 Sol) on behalf of @tclem

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. SessionConfig and ResumeSessionConfig now have only one permission handler field. The attributed field and its precedence rules are gone.

Comment thread rust/src/handler.rs Outdated
}
}

pub(crate) fn attributed(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

strange to have a function called attributed that returns UnattributedHandler...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed. The attributed() adapter and UnattributedHandler type no longer exist.

Keep PermissionHandler as the single dispatch API and carry decision context through PermissionResult. Replace the ineffective rejection E2E test with an exact fake-server wire assertion.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 79152db2-4cc7-4777-983a-655fae5b68c9
@aymenfurter

Copy link
Copy Markdown
Author

We decided to make the breaking change for Rust. PermissionResult now has an AttributedDecision variant, so clients with exhaustive matches must handle the new variant. Existing PermissionHandler implementations and session registration remain unchanged. This lets us keep one handler API instead of introducing a parallel attributed handler hierarchy. Tim confirmed that this kind of Rust source break is acceptable.

@aymenfurter
aymenfurter requested a review from tclem August 13, 2026 14:57
Comment thread rust/src/handler.rs
/// Send a permission decision on the wire.
Decision(PermissionDecision),
/// Send a permission decision with context describing how it was reached.
AttributedDecision {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we extend Decision instead of adding a second semantic decision variant?

Decision {
    decision: PermissionDecision,
    context: Option<PermissionDecisionContext>,
}

AttributedDecision is still a decision, so every consumer must remember to handle both variants. More importantly, downstream code matching Decision(_) with a wildcard arm keeps compiling and may silently treat an attributed decision like NoResult. Since we're already taking a breaking change here, I'd prefer the compiler-guided break and the coherent model.

  Generated via Copilot (GPT-5.6 Sol) on behalf of @tclem

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.

4 participants