sentrykit 0.1.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. sentrykit/__init__.py +200 -0
  2. sentrykit/audit.py +967 -0
  3. sentrykit/cedar.py +750 -0
  4. sentrykit/cli.py +1405 -0
  5. sentrykit/config.py +300 -0
  6. sentrykit/constraints.py +928 -0
  7. sentrykit/context.py +393 -0
  8. sentrykit/decorator.py +252 -0
  9. sentrykit/engine.py +1509 -0
  10. sentrykit/envelope.py +513 -0
  11. sentrykit/errors.py +252 -0
  12. sentrykit/integrations/__init__.py +1 -0
  13. sentrykit/integrations/base.py +767 -0
  14. sentrykit/integrations/fastapi.py +361 -0
  15. sentrykit/integrations/flask.py +342 -0
  16. sentrykit/integrations/mcp.py +929 -0
  17. sentrykit/integrations/pydantic_ai.py +728 -0
  18. sentrykit/integrations/tools.py +479 -0
  19. sentrykit/masking.py +459 -0
  20. sentrykit/policy/__init__.py +40 -0
  21. sentrykit/policy/loader.py +764 -0
  22. sentrykit/policy/pack/10-rbac.cedar +9 -0
  23. sentrykit/policy/pack/20-groups.cedar +9 -0
  24. sentrykit/policy/pack/30-ownership.cedar +8 -0
  25. sentrykit/policy/pack/40-delegation.cedar +40 -0
  26. sentrykit/policy/pack/50-expiry.cedar +24 -0
  27. sentrykit/policy/pack/60-break-glass.cedar +34 -0
  28. sentrykit/policy/pack/70-separation-of-duties.cedar +22 -0
  29. sentrykit/policy/pack/80-jit-elevation.cedar +32 -0
  30. sentrykit/policy/pack/entities.json +3 -0
  31. sentrykit/policy/pack/pack.cedarschema +45 -0
  32. sentrykit/policy/pack/templates/time-boxed-grant.cedartemplate +5 -0
  33. sentrykit/py.typed +0 -0
  34. sentrykit/registry.py +666 -0
  35. sentrykit/stores/__init__.py +116 -0
  36. sentrykit/stores/base.py +870 -0
  37. sentrykit/stores/composite.py +635 -0
  38. sentrykit/stores/filesystem.py +400 -0
  39. sentrykit/stores/jsonfile.py +701 -0
  40. sentrykit/stores/jsonl.py +357 -0
  41. sentrykit/stores/memory.py +500 -0
  42. sentrykit-0.1.0rc1.dist-info/METADATA +541 -0
  43. sentrykit-0.1.0rc1.dist-info/RECORD +46 -0
  44. sentrykit-0.1.0rc1.dist-info/WHEEL +4 -0
  45. sentrykit-0.1.0rc1.dist-info/entry_points.txt +20 -0
  46. sentrykit-0.1.0rc1.dist-info/licenses/LICENSE +201 -0
sentrykit/envelope.py ADDED
@@ -0,0 +1,513 @@
1
+ """The ``_authz`` envelope — what sentrykit tells the caller about its own decision.
2
+
3
+ An authorization layer that silently rewrites a caller's arguments is worse than one that
4
+ refuses them: the caller gets a plausible answer to a question they did not ask. The
5
+ envelope is the fix. It reports what was processed, what was rejected and why, and what was
6
+ defaulted, so a narrowed call is visibly narrowed — to a human reading an HTTP response, and
7
+ to a language model deciding what to do next.
8
+
9
+ Reporting the rejection is not enough on its own, and that is a measured result rather than a
10
+ design opinion. A narrowed call *succeeded*: the policy decided, the arguments were refilled
11
+ from the principal's own entitlement, and the callable ran and returned real data. But an
12
+ envelope whose only narrowing fields are ``rejected`` and ``defaults_applied`` describes that
13
+ success in the vocabulary of a failure, and a reader acting on it — most sharply a language
14
+ model — concludes the call failed and withholds the answer it is holding. So the envelope
15
+ states the outcome as well as the exceptions: :attr:`AuthzEnvelope.narrowed` says a proposed
16
+ value did not survive, :attr:`AuthzEnvelope.narrowed_arguments` names which, and
17
+ :attr:`AuthzEnvelope.guidance` says in one sentence that the result is valid and what to do
18
+ with it. Those three carry no argument values and no policy ids, so unlike ``processed`` they
19
+ are available at every verbosity above ``off`` — a caller who may not be told *what* was
20
+ narrowed can still be told *that* the result is good.
21
+
22
+ Verbosity trades that transparency against leaking policy structure and argument values to
23
+ callers who should not see either:
24
+
25
+ ``off``
26
+ No envelope.
27
+ ``summary``
28
+ The decision, the action and resource, and the *names* and reasons of rejected and
29
+ defaulted arguments — enough to explain the outcome without disclosing values.
30
+ ``verbose``
31
+ Adds the processed argument values and the offending values on rejections.
32
+ ``debug``
33
+ Adds the trace: determining policies, the policy-set hash, timings, and the effective
34
+ settings. For development and incident response.
35
+
36
+ The envelope is a report, not the audit record. The audit record is written append-only,
37
+ masked, and hash-chained regardless of what verbosity shows the caller.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from collections.abc import Mapping, MutableMapping, Sequence
43
+ from contextvars import ContextVar
44
+ from typing import Literal, TypeVar, cast
45
+
46
+ from pydantic import BaseModel, ConfigDict, Field
47
+
48
+ from sentrykit.config import Verbosity
49
+ from sentrykit.constraints import RejectedArgument
50
+
51
+ __all__ = [
52
+ "AuthzEnvelope",
53
+ "EnvelopeRecorder",
54
+ "attach_envelope",
55
+ "build_envelope",
56
+ "current_recorder",
57
+ "last_envelope",
58
+ "narrowing_guidance",
59
+ "record_decisions",
60
+ "set_last_envelope",
61
+ ]
62
+
63
+ T = TypeVar("T")
64
+
65
+ ENVELOPE_KEY: str = "_authz"
66
+ """Key under which the envelope is attached to a mapping result."""
67
+
68
+ _LAST_ENVELOPE: ContextVar[AuthzEnvelope | None] = ContextVar(
69
+ "sentrykit_last_envelope", default=None
70
+ )
71
+
72
+ _RECORDER: ContextVar[EnvelopeRecorder | None] = ContextVar(
73
+ "sentrykit_envelope_recorder", default=None
74
+ )
75
+ """The boundary's recorder, reached through the context rather than stored in it.
76
+
77
+ A ``ContextVar`` holding a *value* is write-down-only: a sync route's write is discarded when
78
+ its worker thread's context copy is dropped. A ``ContextVar`` holding a *mutable object* is
79
+ not, because the copy points at the same instance. That distinction is the whole mechanism;
80
+ see :class:`EnvelopeRecorder`."""
81
+
82
+
83
+ class AuthzEnvelope(BaseModel):
84
+ """A caller-facing report of one authorization decision.
85
+
86
+ Attributes:
87
+ decision: ``allow`` or ``deny``.
88
+ action: The action that was authorized.
89
+ resource: The resource it was authorized against.
90
+ principal: The acting principal's uid. Omitted below ``verbose``.
91
+ processed: The argument set the policy decided and sentrykit passed. A parameter
92
+ left to the callable's own default is absent here and named in
93
+ ``signature_defaults`` instead. Omitted below ``verbose``.
94
+ rejected: Arguments the policy refused, with reasons. Values are included only at
95
+ ``verbose`` and above.
96
+ defaults_applied: Arguments filled in from the principal's defaults. Below
97
+ ``verbose`` the keys are still the argument names but every value is ``None``,
98
+ so the field keeps one shape at every verbosity and a caller can always read
99
+ ``defaults_applied.keys()``.
100
+ signature_defaults: Governed parameters whose value came from the callable's own
101
+ signature — the caller proposed none that survived, policy supplied none, and the
102
+ constraint was checked against the application's own default and accepted it.
103
+ Names only, never values. Present at every verbosity, because the point of the
104
+ field is that this case used to be invisible: from the argument layer's point of
105
+ view nothing happened, so a clean allow was reported over a value no policy chose.
106
+ A call where that default was *refused* does not appear here; it is a refusal.
107
+ narrowed: Whether the call ran with a value the caller did not propose — the
108
+ reject-and-refill outcome. Distinct from ``rejected`` being non-empty: a
109
+ rejection that left the argument with the caller's own surviving value is not
110
+ narrowing, and a default injected for an argument the caller never mentioned is
111
+ not either. Omitted from :meth:`as_dict` when false, so a clean allow stays
112
+ terse.
113
+ narrowed_arguments: The parameter names whose proposed value did not survive.
114
+ Present at every verbosity, because ``rejected`` already carries these names.
115
+ guidance: One sentence on what the outcome means and what to do with it, set only
116
+ when :attr:`narrowed` is true. Carries no argument values and no policy ids, so
117
+ it is safe at every verbosity. It exists because a reader given only
118
+ ``rejected`` reads a successful narrowed call as a failed one.
119
+ obligations: Obligations the policy attached to the decision — break-the-glass
120
+ justification, review requirements. Carried through from the determining
121
+ policies; the shipped policy pack is what attaches them.
122
+ reason: Why a denial happened, in one sentence.
123
+ determining_policies: The policies that decided it. ``debug`` only, since policy ids
124
+ describe the shape of the rule set.
125
+ trace: Decision trace — policy-set hash, engine errors, timings, effective settings.
126
+ ``debug`` only.
127
+ """
128
+
129
+ model_config = ConfigDict(frozen=True, extra="forbid")
130
+
131
+ decision: Literal["allow", "deny"]
132
+ action: str
133
+ resource: str | None = None
134
+ principal: str | None = None
135
+ processed: dict[str, object] = Field(default_factory=dict)
136
+ rejected: tuple[RejectedArgument, ...] = ()
137
+ defaults_applied: dict[str, object] = Field(default_factory=dict)
138
+ signature_defaults: tuple[str, ...] = ()
139
+ narrowed: bool = False
140
+ narrowed_arguments: tuple[str, ...] = ()
141
+ guidance: str | None = None
142
+ obligations: tuple[str, ...] = ()
143
+ reason: str | None = None
144
+ determining_policies: tuple[str, ...] = ()
145
+ trace: dict[str, object] | None = None
146
+
147
+ def as_dict(self) -> dict[str, object]:
148
+ """Render as a JSON-ready mapping, dropping empty sections.
149
+
150
+ Argument values are whatever the protected callable was called with, so they are not
151
+ guaranteed to be JSON-serializable. Anything pydantic cannot render is replaced by
152
+ its ``repr`` rather than raising: a report about a decision must not be able to fail
153
+ the call the decision permitted.
154
+
155
+ Returns:
156
+ The envelope with unset and empty fields omitted, so a clean allow stays terse.
157
+ ``narrowed`` is dropped when false for the same reason: it is the *exceptional*
158
+ outcome, and emitting ``"narrowed": false`` on every ordinary response would
159
+ train a reader to skip the key.
160
+ """
161
+ try:
162
+ data = self.model_dump(mode="json", exclude_none=True, warnings=False, fallback=repr)
163
+ except TypeError: # pydantic < 2.11 has no ``fallback``
164
+ data = self.model_dump(mode="json", exclude_none=True, warnings=False)
165
+ if not data.get("narrowed"):
166
+ data.pop("narrowed", None)
167
+ return {
168
+ key: value
169
+ for key, value in data.items()
170
+ if not (isinstance(value, dict | list) and len(value) == 0)
171
+ }
172
+
173
+
174
+ def narrowing_guidance(narrowed_arguments: Sequence[str], *, unsupplied: Sequence[str] = ()) -> str:
175
+ """Compose the sentence :attr:`AuthzEnvelope.guidance` carries.
176
+
177
+ The wording has one job, and it is not decoration: a reader handed only ``rejected``
178
+ treats a narrowed call as a failed one and withholds the result. Measured across language
179
+ models, that happened in roughly half of narrowed calls. So the sentence leads
180
+ with the outcome rather than the exceptions, and it closes by saying not to retry the
181
+ original values, because a reader that reads "rejected" as "try again" will loop.
182
+
183
+ The lead is **conditional**, and that is the point of the ``unsupplied`` argument. When
184
+ every narrowed argument was refilled from the principal's own entitlement, the call ran
185
+ on values policy approves and the result can be vouched for. When one was not, the
186
+ callable's own signature default landed in the slot, no constraint ever saw it, and
187
+ claiming the result is "valid" would be precisely the overclaim this envelope exists to
188
+ prevent — so that case says "served" and marks the affected part unverified instead.
189
+
190
+ Only names appear, never values and never policy ids, which is what makes the sentence
191
+ safe to emit at ``summary`` verbosity alongside the trimmed fields.
192
+
193
+ Args:
194
+ narrowed_arguments: Parameter names whose proposed value did not survive.
195
+ unsupplied: The subset of those for which sentrykit supplied no replacement, so the
196
+ callable's own signature default applied. Called out separately because
197
+ sentrykit never saw that value and must not imply it is policy-approved.
198
+
199
+ Returns:
200
+ One sentence, suitable for a human reading an HTTP response or a model reading a
201
+ tool result.
202
+ """
203
+ names = ", ".join(narrowed_arguments)
204
+ refilled = [name for name in narrowed_arguments if name not in set(unsupplied)]
205
+
206
+ if not unsupplied:
207
+ # Every narrowed argument was refilled from this principal's own entitlement, so the
208
+ # call ran entirely on values policy approves and the result can be vouched for.
209
+ return (
210
+ f"This result is valid and was served. The authorization policy adjusted {names} "
211
+ f"before the call ran, because the proposed value is not one this principal may "
212
+ f"use. Report the result as delivered and say which parameters were adjusted; do "
213
+ f"not retry with the original values."
214
+ )
215
+
216
+ # At least one argument reached the callable with no policy-supplied value, so the lead
217
+ # cannot claim the result is policy-approved: sentrykit never saw what landed there.
218
+ # Saying "valid" here would be the exact overclaim the envelope exists to prevent.
219
+ sentence = (
220
+ f"This result was served, with {names} adjusted by the authorization policy because "
221
+ f"the proposed value is not one this principal may use. Report the result as "
222
+ f"delivered and say which parameters were adjusted; do not retry with the original "
223
+ f"values. Note that for {', '.join(unsupplied)} the policy supplied no replacement, "
224
+ f"so the callable's own default applied and no constraint checked it — treat that "
225
+ f"part of the result as unverified."
226
+ )
227
+ if refilled:
228
+ sentence += f" {', '.join(refilled)} did come from this principal's own entitlement."
229
+ return sentence
230
+
231
+
232
+ def build_envelope(
233
+ *,
234
+ decision: Literal["allow", "deny"],
235
+ action: str,
236
+ resource: str | None,
237
+ principal: str | None,
238
+ processed: Mapping[str, object],
239
+ rejected: tuple[RejectedArgument, ...],
240
+ defaults_applied: Mapping[str, object],
241
+ signature_defaults: Sequence[str] = (),
242
+ narrowed_arguments: Sequence[str] = (),
243
+ narrowed_without_replacement: Sequence[str] = (),
244
+ obligations: tuple[str, ...] = (),
245
+ reason: str | None = None,
246
+ determining_policies: tuple[str, ...] = (),
247
+ trace: Mapping[str, object] | None = None,
248
+ verbosity: Verbosity,
249
+ ) -> AuthzEnvelope | None:
250
+ """Build the envelope for one decision, trimmed to ``verbosity``.
251
+
252
+ Trimming happens here rather than at render time so that a value excluded by verbosity is
253
+ never carried on the object at all — a report cannot leak a field it does not hold.
254
+
255
+ Below ``verbose`` the trimming is: no ``principal``, no ``processed``, no
256
+ ``determining_policies`` and no ``trace``; each rejection is rebuilt without its
257
+ offending ``value``; and ``defaults_applied`` keeps only the *names*, each mapped to
258
+ ``None``. Mapping to ``None`` rather than dropping the section keeps one shape for the
259
+ field across every verbosity, so a caller can always read
260
+ ``envelope.defaults_applied.keys()`` to learn which arguments policy filled in.
261
+
262
+ Args:
263
+ decision: The outcome.
264
+ action: Action uid.
265
+ resource: Resource uid, if any.
266
+ principal: Principal uid.
267
+ processed: Final arguments the call ran with.
268
+ rejected: Arguments the policy refused.
269
+ defaults_applied: Arguments filled in from policy defaults.
270
+ signature_defaults: Governed parameters the callable's own signature default filled,
271
+ after the constraint checked and accepted it. Names only, and not trimmed by
272
+ verbosity for the same reason ``narrowed_arguments`` is not.
273
+ narrowed_arguments: Parameter names whose proposed value did not survive. Sets
274
+ :attr:`AuthzEnvelope.narrowed` and drives the guidance sentence. Not trimmed by
275
+ verbosity: these names already appear on ``rejected`` at every verbosity.
276
+ narrowed_without_replacement: The subset of ``narrowed_arguments`` that sentrykit
277
+ supplied no value for, so the callable's signature default applied.
278
+ obligations: Obligations attached by the policy.
279
+ reason: One-sentence explanation, for denials.
280
+ determining_policies: Policies that decided the outcome.
281
+ trace: Debug trace.
282
+ verbosity: How much to include.
283
+
284
+ Returns:
285
+ The envelope, or ``None`` when ``verbosity`` is ``off``.
286
+ """
287
+ if verbosity == "off":
288
+ return None
289
+
290
+ with_values = verbosity in ("verbose", "debug")
291
+ with_trace = verbosity == "debug"
292
+
293
+ principal_out: str | None
294
+ processed_out: dict[str, object]
295
+ rejected_out: tuple[RejectedArgument, ...]
296
+ defaults_out: dict[str, object]
297
+
298
+ if with_values:
299
+ principal_out = principal
300
+ processed_out = dict(processed)
301
+ rejected_out = rejected
302
+ defaults_out = dict(defaults_applied)
303
+ else:
304
+ principal_out = None
305
+ processed_out = {}
306
+ # Rebuilt rather than copied so the offending value is absent from the object,
307
+ # not merely hidden from one rendering of it.
308
+ rejected_out = tuple(
309
+ RejectedArgument(name=item.name, rule=item.rule, reason=item.reason, policy=item.policy)
310
+ for item in rejected
311
+ )
312
+ defaults_out = dict.fromkeys(defaults_applied)
313
+
314
+ return AuthzEnvelope(
315
+ decision=decision,
316
+ action=action,
317
+ resource=resource,
318
+ principal=principal_out,
319
+ processed=processed_out,
320
+ rejected=rejected_out,
321
+ defaults_applied=defaults_out,
322
+ # Not trimmed by verbosity: these are parameter names, which `rejected` and
323
+ # `defaults_applied` already carry at every verbosity, and the whole point of the
324
+ # field is to stop this case being silent.
325
+ signature_defaults=tuple(signature_defaults),
326
+ narrowed=bool(narrowed_arguments),
327
+ narrowed_arguments=tuple(narrowed_arguments),
328
+ guidance=(
329
+ narrowing_guidance(narrowed_arguments, unsupplied=narrowed_without_replacement)
330
+ if narrowed_arguments
331
+ else None
332
+ ),
333
+ obligations=obligations,
334
+ reason=reason,
335
+ determining_policies=determining_policies if with_trace else (),
336
+ trace=dict(trace) if with_trace and trace is not None else None,
337
+ )
338
+
339
+
340
+ def attach_envelope(result: T, envelope: AuthzEnvelope | None) -> T:
341
+ """Attach ``envelope`` to a result and record it as the context's last envelope.
342
+
343
+ A mapping result gets the envelope under ``_authz`` in place — which is what an agent
344
+ tool result or a JSON handler return usually is, and the reason the README can promise
345
+ the report travels with the answer. Any other result is returned untouched: rewrapping
346
+ it would change the protected callable's return type and break both static typing and
347
+ every caller. Those callers read :func:`last_envelope` instead, which is how the
348
+ web framework providers attach it to an HTTP response.
349
+
350
+ Args:
351
+ result: Whatever the protected callable returned.
352
+ envelope: The envelope, or ``None`` to attach nothing.
353
+
354
+ Returns:
355
+ ``result`` — the same object, mutated in place when it is a mapping.
356
+ """
357
+ set_last_envelope(envelope)
358
+ if envelope is None:
359
+ return result
360
+ if isinstance(result, MutableMapping):
361
+ # ``T`` is preserved: the same object comes back, only with one key added. The cast
362
+ # is needed because a narrowing on ``T`` does not tell mypy the keys are strings.
363
+ cast("MutableMapping[str, object]", result)[ENVELOPE_KEY] = envelope.as_dict()
364
+ recorder = _RECORDER.get()
365
+ if recorder is not None:
366
+ # The boundary now knows this result speaks for itself, so an adapter does not
367
+ # have to annotate it a second time out of band.
368
+ recorder.last_was_in_band = True
369
+ return result
370
+
371
+
372
+ def last_envelope() -> AuthzEnvelope | None:
373
+ """Return the envelope from the most recent decision in this context.
374
+
375
+ Scoped to the current ``ContextVar`` context, so concurrent tasks and threads each see
376
+ their own.
377
+
378
+ Returns:
379
+ The most recent envelope, or ``None`` if there has been no decision or verbosity is
380
+ ``off``.
381
+ """
382
+ return _LAST_ENVELOPE.get()
383
+
384
+
385
+ def set_last_envelope(envelope: AuthzEnvelope | None) -> None:
386
+ """Record ``envelope`` as this context's most recent decision.
387
+
388
+ Called by the engine on both allow and deny, so a provider rendering an error response
389
+ has the same detail available as one rendering a success. It also feeds the boundary's
390
+ :class:`EnvelopeRecorder` when one is installed, which is the only path that survives a
391
+ decision made inside a copied context.
392
+ """
393
+ _LAST_ENVELOPE.set(envelope)
394
+ recorder = _RECORDER.get()
395
+ if recorder is not None and envelope is not None:
396
+ recorder.record(envelope)
397
+
398
+
399
+ class EnvelopeRecorder:
400
+ """A boundary's running account of the decisions made while serving one request.
401
+
402
+ This exists because :func:`last_envelope` cannot answer the boundary's question. A
403
+ ``ContextVar`` write propagates *downward* into a copied context and never back *up*: a
404
+ sync framework route runs in a worker thread holding a copy, so nothing the engine sets
405
+ while that route executes is visible to the middleware that has to write the response
406
+ headers. Measured, not assumed — see ADR-029.
407
+
408
+ A recorder closes that gap by being a **mutable object reached through** the context
409
+ variable rather than a value stored in it. The copy a worker thread receives points at
410
+ the same instance, so :meth:`record` mutates what the boundary is still holding.
411
+
412
+ It is also the right shape for the question a boundary actually asks. A request may make
413
+ several protected calls, and "was anything narrowed while serving this response" is not
414
+ answerable from the most recent envelope alone, so :attr:`narrowed_arguments` accumulates
415
+ the union across every decision in the scope, in first-seen order.
416
+
417
+ A boundary installs one with :func:`record_decisions` and drops it with :meth:`close`;
418
+ :class:`~sentrykit.integrations.base.BaseContextProvider` does both, so every shipped
419
+ framework adapter has one without asking.
420
+
421
+ Attributes:
422
+ decisions: How many decisions have been recorded.
423
+ envelope: The most recent one, matching what :func:`last_envelope` would return had
424
+ the decision been made in this context.
425
+ narrowed_arguments: Every argument narrowed anywhere in the scope, first-seen order.
426
+ last_was_in_band: Whether the **most recent** decision's envelope was written into
427
+ the result itself under ``_authz``. Per-decision rather than cumulative on
428
+ purpose: it answers "does this result already say so?", which is a question about
429
+ one return value, and a boundary asks it immediately after the call it describes.
430
+ A non-mapping return leaves it ``False``, and that is the signal the agent
431
+ adapters act on.
432
+ """
433
+
434
+ __slots__ = (
435
+ "_closed",
436
+ "_seen",
437
+ "decisions",
438
+ "envelope",
439
+ "last_was_in_band",
440
+ "narrowed_arguments",
441
+ )
442
+
443
+ def __init__(self) -> None:
444
+ self.decisions = 0
445
+ self.envelope: AuthzEnvelope | None = None
446
+ self.narrowed_arguments: tuple[str, ...] = ()
447
+ self.last_was_in_band = False
448
+ self._seen: set[str] = set()
449
+ self._closed = False
450
+
451
+ @property
452
+ def narrowed(self) -> bool:
453
+ """Whether any decision in this scope ran with a value its caller did not propose."""
454
+ return bool(self.narrowed_arguments)
455
+
456
+ def record(self, envelope: AuthzEnvelope) -> None:
457
+ """Fold one decision into the account.
458
+
459
+ Args:
460
+ envelope: The decision's envelope. A ``None`` envelope — verbosity ``off`` — is
461
+ filtered out by :func:`set_last_envelope` before it reaches here, so a host
462
+ that turned reporting off gets no headers rather than empty ones.
463
+ """
464
+ self.decisions += 1
465
+ self.envelope = envelope
466
+ # Cleared per decision, and set again only if ``attach_envelope`` manages to write
467
+ # into the result. The engine records the envelope before it invokes, so this is the
468
+ # earliest point at which the flag can be made to describe *this* call.
469
+ self.last_was_in_band = False
470
+ fresh = [name for name in envelope.narrowed_arguments if name not in self._seen]
471
+ if fresh:
472
+ self._seen.update(fresh)
473
+ self.narrowed_arguments = (*self.narrowed_arguments, *fresh)
474
+
475
+ def close(self) -> None:
476
+ """Uninstall this recorder. Idempotent, and safe to call on an already-closed one.
477
+
478
+ Shaped like :meth:`~sentrykit.context.ContextScope.reset` on purpose: a boundary
479
+ tears down what it set up, in a ``finally``, and a pooled worker must not start the
480
+ next request holding the previous caller's account.
481
+ """
482
+ if self._closed:
483
+ return
484
+ self._closed = True
485
+ _RECORDER.set(None)
486
+
487
+
488
+ def record_decisions() -> EnvelopeRecorder:
489
+ """Install an :class:`EnvelopeRecorder` for the current context and return it.
490
+
491
+ Call at a request boundary, before anything protected runs, and
492
+ :meth:`EnvelopeRecorder.close` it at teardown. The shipped providers do this inside
493
+ :meth:`~sentrykit.integrations.base.BaseContextProvider.establish` and
494
+ :meth:`~sentrykit.integrations.base.BaseContextProvider.release`; a hand-written provider
495
+ that wants narrowing to reach its response needs the same pair.
496
+
497
+ Returns:
498
+ The recorder. Also reachable from the same context with :func:`current_recorder`,
499
+ which is what lets a boundary read it without threading it through the call stack.
500
+ """
501
+ recorder = EnvelopeRecorder()
502
+ _RECORDER.set(recorder)
503
+ return recorder
504
+
505
+
506
+ def current_recorder() -> EnvelopeRecorder | None:
507
+ """Return the recorder installed for this context, if any.
508
+
509
+ Returns:
510
+ The recorder, or ``None`` outside a boundary that installed one — which is the normal
511
+ case for a plain ``with establish(...)`` block in application code.
512
+ """
513
+ return _RECORDER.get()