-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdecisions.go
More file actions
401 lines (368 loc) · 16.1 KB
/
Copy pathdecisions.go
File metadata and controls
401 lines (368 loc) · 16.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
// Decision explainability methods for AxonFlow SDK.
// Implements the contract locked in ADR-043 (Explainability Data Contract).
package axonflow
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
// ============================================================================
// Types (ADR-043 — frozen shape)
// ============================================================================
// DecisionExplanation is the canonical payload returned by Decisions.Explain.
// Shape is frozen per ADR-043; additive fields may be added with omitempty,
// renames/removals require a major version bump.
type DecisionExplanation struct {
DecisionID string `json:"decision_id"`
Timestamp time.Time `json:"timestamp"`
PolicyMatches []ExplainPolicy `json:"policy_matches"`
MatchedRules []ExplainRule `json:"matched_rules,omitempty"`
Decision string `json:"decision"` // canonical audit verdict: allowed|blocked|redacted|needs_approval|error (9.0.0+)
Reason string `json:"reason"`
RiskLevel string `json:"risk_level,omitempty"`
OverrideAvailable bool `json:"override_available"`
OverrideExistingID string `json:"override_existing_id,omitempty"`
HistoricalHitCountSession int `json:"historical_hit_count_session"`
PolicySourceLink string `json:"policy_source_link,omitempty"`
ToolSignature string `json:"tool_signature,omitempty"`
// Context is the FULL sanitized request context the PEP attached to the
// decision (canonical lower_snake_case keys, string values), read from
// the audit row's policy_details->'context'. Unlike ListDecisions (which
// truncates to the 5 most-correlated keys), Explain returns every
// persisted key up to the platform's 10-key cap, so an auditor gets the
// complete correlation set (x_ai_agent / x_session_id / x_leader_identity,
// x-bukuwarung-*). ContextTruncated reports whether the agent dropped
// surplus keys at write time. Both omitempty so pre-v8.4.0 audit rows keep
// their original byte-shape. (platform #2509 / epic #2508)
Context map[string]string `json:"context,omitempty"`
ContextTruncated bool `json:"context_truncated,omitempty"`
}
// ExplainPolicy is a policy reference inside an explanation.
type ExplainPolicy struct {
PolicyID string `json:"policy_id"`
PolicyName string `json:"policy_name,omitempty"`
Action string `json:"action,omitempty"`
RiskLevel string `json:"risk_level,omitempty"`
AllowOverride bool `json:"allow_override,omitempty"`
PolicyDescription string `json:"policy_description,omitempty"`
}
// ExplainRule is rule-level detail inside an explanation.
type ExplainRule struct {
PolicyID string `json:"policy_id"`
RuleID string `json:"rule_id,omitempty"`
RuleText string `json:"rule_text,omitempty"`
MatchedOn string `json:"matched_on,omitempty"`
}
// ============================================================================
// Methods
// ============================================================================
// ExplainDecision fetches the full explanation for a previously-made
// policy decision.
//
// # Which decisions this returns (platform #2922)
//
// The read is scoped to the per-user identity the caller presents, NOT to the
// tenant credential. On an enterprise stack:
//
// - a tenant-wide role (admin, owner, policy_admin) explains any decision in
// the tenant;
// - any other identity (developer, viewer) explains only the decisions
// attributed to it — another user's decision answers exactly like a
// decision that does not exist;
// - a caller presenting NO identity explains nothing at all. Every call
// answers not-found, whatever the decision id.
//
// Community and Community-SaaS deployments are single-operator and read
// tenant-wide with no identity needed.
//
// Present the identity with AxonFlowConfig.UserToken (client-wide) or
// axonflow.WithUserToken (this call only).
//
// # Telling the misses apart, as far as the platform allows
//
// A miss is returned as *ReadScopeError whenever the platform's
// X-Axonflow-Read-Scope header says the caller's scope was what decided it, so
// "the platform resolved no identity for you" stops being indistinguishable
// from "past retention".
//
// It does NOT separate "not yours" from "not there": under own-rows the
// platform answers both with the same 404 on purpose, so that a miss cannot be
// used to probe for another user's rows. The error reports the scope the read
// ran under, which is the honest thing this SDK can say:
//
// exp, err := client.ExplainDecision(ctx, id)
// if rse, ok := axonflow.AsReadScopeError(err); ok {
// if rse.IdentityMissing() { /* configure UserToken */ }
// /* else: the decision belongs to someone else */
// }
//
// Context cancellation is honored; the underlying HTTP request is bound
// to the given ctx.
//
// Example:
//
// exp, err := client.ExplainDecision(ctx, "dec_wf123_step4")
// if err != nil { return err }
// if exp.OverrideAvailable {
// // offer the user a "override this for 10 minutes" action
// }
func (c *AxonFlowClient) ExplainDecision(ctx context.Context, decisionID string, opts ...ReadOption) (*DecisionExplanation, error) {
if decisionID == "" {
return nil, fmt.Errorf("decisionID is required")
}
ctx = applyReadOptions(ctx, opts...)
// Path-escape the decision ID — platform-generated IDs are usually
// filesystem-safe, but nothing in ADR-043 guarantees it, and IDs that
// contain "/" or "?" would break the URL otherwise.
fullURL := c.config.Endpoint + "/api/v1/decisions/" + url.PathEscape(decisionID) + "/explain"
httpReq, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to build explain request: %w", err)
}
httpReq.Header.Set("Accept", "application/json")
c.addAuthHeaders(httpReq)
resp, err := c.doHttpRequest(c.httpClient, httpReq)
if err != nil {
return nil, fmt.Errorf("explain request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read explain response: %w", err)
}
if resp.StatusCode != http.StatusOK {
// A scoped miss reports WHY it missed. readScopeErrorFor returns nil
// for a tenant-wide caller (a real miss), for a platform that stated
// no scope, and for a scope this build does not recognise — in all
// three the plain HTTP error is the honest answer.
//
// Only 404 is interpreted. The scope header is stamped before the
// handler writes its status, so it also rides a 500 from further down
// the handler; explaining a server fault as a scoping outcome would be
// exactly the confident-wrong-diagnosis this type exists to prevent.
if resp.StatusCode == http.StatusNotFound {
if rse := readScopeErrorFor("decision", decisionID, readScopeOf(resp), resp.StatusCode); rse != nil {
return nil, rse
}
}
return nil, &httpError{
statusCode: resp.StatusCode,
message: string(body),
}
}
var out DecisionExplanation
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("failed to decode explain response: %w", err)
}
return &out, nil
}
// ============================================================================
// list_decisions — Session γ (#1982)
// ============================================================================
// DecisionSummary is the slim 5-field row returned by ListDecisions.
// PolicyID and ToolSignature are omitempty because pre-α1 audit rows
// + dynamic-only blocks may not populate them. Additive new fields
// land via omitempty per ADR-043 §"Versioning" (non-breaking).
//
// Cross-SDK parity:
//
// Python: axonflow-sdk-python/axonflow/decisions.py (DecisionSummary)
// TS: axonflow-sdk-typescript/src/types/decisions.ts (DecisionSummary)
// Java: .../sdk/types/DecisionSummary.java
// Rust: axonflow-sdk-rust/src/types/decisions.rs (DecisionSummary)
type DecisionSummary struct {
DecisionID string `json:"decision_id"`
Timestamp time.Time `json:"timestamp"`
Decision string `json:"decision"` // canonical audit verdict: allowed|blocked|redacted|needs_approval|error (9.0.0+)
PolicyID string `json:"policy_id,omitempty"`
ToolSignature string `json:"tool_signature,omitempty"`
// Context is the sanitized request context the PEP attached to the
// decision (canonical lower_snake_case keys, string values), surfaced
// from the audit row's policy_details->'context'. The list summary is
// truncated by the platform to the 5 most-correlated keys; the full map
// (up to the 10-key cap) is available via ExplainDecision. omitempty so
// pre-v8.4.0 audit rows + decisions with no context keep the original
// byte-shape. (platform #2509 / epic #2508)
Context map[string]string `json:"context,omitempty"`
}
// ListDecisionsOptions carries the 5 optional filters for ListDecisions.
// Zero / empty values are omitted from the URL so the platform applies
// its tier-default page. Limit=0 means "use the tier default"; pass
// the value you want explicitly.
//
// Decision, when set, must be a canonical audit verdict
// (allowed|blocked|redacted|needs_approval|error) on platform 9.0.0+; the
// pre-9.0.0 values allow|deny|require_approval are rejected with HTTP 400.
// See https://docs.getaxonflow.com/docs/deployment/v8-to-v9-migration/
type ListDecisionsOptions struct {
Since time.Time
Decision string // allowed|blocked|redacted|needs_approval|error (9.0.0+)
PolicyID string
ToolSignature string
Limit int
}
// UpgradeInfo is the V1 upgrade context inside a 429 envelope.
// Mirrors the platform-side
// feedback_429_no_upgrade_hint_is_conversion_gap.md contract.
type UpgradeInfo struct {
Tier string `json:"tier"`
Wording string `json:"wording"`
CompareURL string `json:"compare_url"`
BuyURL string `json:"buy_url"`
}
// RateLimitEnvelope is the parsed 429 body when ListDecisions hits a
// tier cap. Surface via *RateLimitError so callers can branch on the
// upgrade fields without re-parsing the body.
type RateLimitEnvelope struct {
Error string `json:"error"`
LimitType string `json:"limit_type"`
Tier string `json:"tier"`
Limit int `json:"limit"`
Remaining int `json:"remaining"`
Upgrade UpgradeInfo `json:"upgrade"`
}
// RateLimitError is the typed 429 surfaced when ListDecisions hits a
// tier cap. Implements error and exposes the parsed RateLimitEnvelope
// so callers can branch on Upgrade.{Tier,CompareURL,BuyURL} cleanly.
//
// Use errors.As(err, &rle) to extract from a wrapped error chain.
type RateLimitError struct {
Envelope RateLimitEnvelope
}
func (e *RateLimitError) Error() string {
return fmt.Sprintf("HTTP 429 (tier=%s, limit_type=%s): %s",
e.Envelope.Tier, e.Envelope.LimitType, e.Envelope.Error)
}
// ListDecisions returns recent policy decisions visible to the identity the
// caller presents (slim 5-field DecisionSummary rows). The platform applies a
// tier-gated cap; passing a Limit above the cap yields *RateLimitError
// carrying the V1 upgrade envelope. Filters compose; zero-valued fields are
// omitted from the URL.
//
// # Which decisions this returns (platform #2922)
//
// Not "the caller's tenant" — the caller's SCOPE. On an enterprise stack a
// tenant-wide role (admin, owner, policy_admin) lists the whole tenant, any
// other identity lists only its own rows, and a caller presenting NO identity
// lists nothing whatsoever. See AxonFlowConfig.UserToken and WithUserToken.
//
// # The empty list that was never true
//
// That last case used to return an empty slice and a nil error, which reads
// as "your tenant has made no decisions" and is a different statement from
// what happened. When the platform reports X-Axonflow-Read-Scope: none and the
// result is empty, this method now returns *ReadScopeError instead — the read
// could not have returned a row, so its emptiness is not evidence about the
// data. Callers upgrading from an earlier SDK on an enterprise stack will see
// this error exactly where they were being told nothing was there; the remedy
// is to present an identity.
//
// A genuinely empty own-rows or tenant-wide read is NOT an error: those
// callers could have seen rows and there were none.
//
// Example:
//
// decisions, err := client.ListDecisions(ctx, ListDecisionsOptions{
// Decision: "blocked",
// Limit: 10,
// })
// var rle *RateLimitError
// if errors.As(err, &rle) {
// fmt.Println("upgrade to:", rle.Envelope.Upgrade.BuyURL)
// }
// if rse, ok := axonflow.AsReadScopeError(err); ok && rse.IdentityMissing() {
// fmt.Println("set AxonFlowConfig.UserToken — this read was unscoped")
// }
func (c *AxonFlowClient) ListDecisions(ctx context.Context, opts ListDecisionsOptions, readOpts ...ReadOption) ([]DecisionSummary, error) {
ctx = applyReadOptions(ctx, readOpts...)
fullURL := c.config.Endpoint + "/api/v1/decisions"
if qs := buildListDecisionsQuery(opts); qs != "" {
fullURL += "?" + qs
}
httpReq, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to build list_decisions request: %w", err)
}
httpReq.Header.Set("Accept", "application/json")
c.addAuthHeaders(httpReq)
resp, err := c.doHttpRequest(c.httpClient, httpReq)
if err != nil {
return nil, fmt.Errorf("list_decisions request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read list_decisions response: %w", err)
}
if resp.StatusCode == http.StatusTooManyRequests {
// Try to parse the V1 upgrade envelope. If the body changed
// shape we still surface the 429 — never silently succeed.
var env RateLimitEnvelope
if jerr := json.Unmarshal(body, &env); jerr == nil && env.LimitType != "" {
return nil, &RateLimitError{Envelope: env}
}
return nil, &httpError{statusCode: resp.StatusCode, message: string(body)}
}
if resp.StatusCode != http.StatusOK {
return nil, &httpError{statusCode: resp.StatusCode, message: string(body)}
}
var envelope struct {
Decisions []DecisionSummary `json:"decisions"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("failed to decode list_decisions response: %w", err)
}
// An empty page under ReadScopeNone is the fail-closed shape, not a
// finding: the platform returned zero rows because it had no identity to
// scope on, so the page says nothing about what exists. Guarded on
// len == 0 as well as on the scope so a non-empty page is never turned
// into an error, whatever the header says.
//
// Only ReadScopeNone refuses. ReadScopeOwnRows with zero rows is a real
// answer ("you have made no decisions matching this filter"), and turning
// it into an error would replace one wrong report with another.
if scopeErr := refuseVacuousScopedPage(resp, "decisions", len(envelope.Decisions)); scopeErr != nil {
return nil, scopeErr
}
return envelope.Decisions, nil
}
// buildListDecisionsQuery serializes ListDecisionsOptions into a URL
// query string. Zero-valued fields are omitted; field order is stable
// so test mocks can match the URL exactly.
func buildListDecisionsQuery(opts ListDecisionsOptions) string {
q := url.Values{}
if !opts.Since.IsZero() {
// Use UTC + RFC3339 — same wire format the explain endpoint
// emits on the server side.
q.Set("since", opts.Since.UTC().Format(time.RFC3339))
}
if opts.Decision != "" {
q.Set("decision", opts.Decision)
}
if opts.PolicyID != "" {
q.Set("policy_id", opts.PolicyID)
}
if opts.ToolSignature != "" {
q.Set("tool_signature", opts.ToolSignature)
}
if opts.Limit > 0 {
q.Set("limit", strconv.Itoa(opts.Limit))
}
return q.Encode()
}
// AsRateLimitError unwraps err and returns the typed RateLimitError if
// present. Convenience for callers that don't want to import errors and
// declare the local pointer.
func AsRateLimitError(err error) (*RateLimitError, bool) {
var rle *RateLimitError
if errors.As(err, &rle) {
return rle, true
}
return nil, false
}