capsule-engine 0.1.0__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.
- capsule_engine/__init__.py +17 -0
- capsule_engine/bundle_viewer/__init__.py +21 -0
- capsule_engine/bundle_viewer/static/offline_shell.html +1509 -0
- capsule_engine/bundle_viewer/viewer.py +57 -0
- capsule_engine/cli/__init__.py +5 -0
- capsule_engine/cli/bundle_cmd.py +269 -0
- capsule_engine/cli/console_cmd.py +70 -0
- capsule_engine/cli/constraints_cmd.py +74 -0
- capsule_engine/cli/format.py +173 -0
- capsule_engine/cli/guard_cmds.py +361 -0
- capsule_engine/cli/init_cmds.py +169 -0
- capsule_engine/cli/main.py +120 -0
- capsule_engine/cli/packs_cmds.py +96 -0
- capsule_engine/cli/telemetry_cmd.py +80 -0
- capsule_engine/cli/tenant_cmds.py +199 -0
- capsule_engine/cli/verify_cmd.py +110 -0
- capsule_engine/compiler/__init__.py +13 -0
- capsule_engine/compiler/effect_model.py +79 -0
- capsule_engine/compiler/vocabulary.py +253 -0
- capsule_engine/console/__init__.py +10 -0
- capsule_engine/console/api.py +251 -0
- capsule_engine/console/components.css +406 -0
- capsule_engine/console/console.css +130 -0
- capsule_engine/console/console.html +51 -0
- capsule_engine/console/console.js +330 -0
- capsule_engine/console/gallery.html +274 -0
- capsule_engine/console/server.py +137 -0
- capsule_engine/console/tokens.css +68 -0
- capsule_engine/conversation/__init__.py +88 -0
- capsule_engine/conversation/capsules.py +268 -0
- capsule_engine/conversation/exchange.py +279 -0
- capsule_engine/conversation/merkle.py +80 -0
- capsule_engine/conversation/session.py +101 -0
- capsule_engine/envcompat.py +15 -0
- capsule_engine/events/__init__.py +14 -0
- capsule_engine/events/capsule.py +133 -0
- capsule_engine/examples/__init__.py +9 -0
- capsule_engine/examples/data/tau2_airline/LICENSE-tau2-bench +21 -0
- capsule_engine/examples/data/tau2_airline/PROVENANCE.json +70 -0
- capsule_engine/examples/data/tau2_airline/hand_labels.json +893 -0
- capsule_engine/examples/data/tau2_airline/pilot1_live_gemini-2-5-flash_airline_shift1.jsonl +190 -0
- capsule_engine/examples/data/tau2_airline/tau2_committed_claude-3-7-sonnet_airline_trial0.jsonl +556 -0
- capsule_engine/examples/data/tau2_airline/tau2_committed_gpt-4-1-mini_airline_trial0.jsonl +480 -0
- capsule_engine/examples/data/tau2_airline/tau2_committed_gpt-4-1_airline_trial0.jsonl +496 -0
- capsule_engine/examples/data/tau2_airline/tau2_committed_o4-mini_airline_trial0.jsonl +392 -0
- capsule_engine/examples/data/tau2_airline/tau2_conversations_claude-3-7-sonnet_airline_4trials.jsonl +200 -0
- capsule_engine/examples/tau2_airline_reference.py +460 -0
- capsule_engine/examples/tau2_conversation_exchange.py +92 -0
- capsule_engine/folds/__init__.py +104 -0
- capsule_engine/folds/account_core.py +89 -0
- capsule_engine/folds/approval_latency.py +86 -0
- capsule_engine/folds/catalog.py +79 -0
- capsule_engine/folds/catalog_defs/actions.count_by_developer.yaml +10 -0
- capsule_engine/folds/catalog_defs/actions.executed_count.yaml +16 -0
- capsule_engine/folds/catalog_defs/actions.last_decision.yaml +12 -0
- capsule_engine/folds/catalog_defs/counterparty.seen_before.v2.yaml +34 -0
- capsule_engine/folds/catalog_defs/counterparty.seen_before.v3.yaml +38 -0
- capsule_engine/folds/catalog_defs/counterparty.seen_before.yaml +28 -0
- capsule_engine/folds/catalog_defs/disclosure.class_seen.yaml +29 -0
- capsule_engine/folds/catalog_defs/disclosure.statement_seen.yaml +32 -0
- capsule_engine/folds/catalog_defs/hold.active_exposure.yaml +44 -0
- capsule_engine/folds/catalog_defs/spend.weekly.v2.yaml +38 -0
- capsule_engine/folds/catalog_defs/spend.weekly.v3.1.yaml +71 -0
- capsule_engine/folds/catalog_defs/spend.weekly.v3.yaml +76 -0
- capsule_engine/folds/catalog_defs/spend.weekly.yaml +33 -0
- capsule_engine/folds/counterparty_signals.py +179 -0
- capsule_engine/folds/definition.py +421 -0
- capsule_engine/folds/duration.py +16 -0
- capsule_engine/folds/engine.py +436 -0
- capsule_engine/folds/errors.py +44 -0
- capsule_engine/folds/loader.py +24 -0
- capsule_engine/folds/ordering.py +143 -0
- capsule_engine/folds/paths.py +25 -0
- capsule_engine/folds/record_type_coverage.py +58 -0
- capsule_engine/folds/reducers.py +75 -0
- capsule_engine/folds/retention_continuity.py +117 -0
- capsule_engine/folds/set_membership.py +59 -0
- capsule_engine/folds/taxonomy.py +231 -0
- capsule_engine/guards/__init__.py +59 -0
- capsule_engine/guards/action.py +446 -0
- capsule_engine/guards/action_taxonomy.json +27 -0
- capsule_engine/guards/action_taxonomy_v2.json +27 -0
- capsule_engine/guards/action_taxonomy_v3.json +27 -0
- capsule_engine/guards/action_taxonomy_v4.json +27 -0
- capsule_engine/guards/action_taxonomy_v5.json +27 -0
- capsule_engine/guards/capsule.py +377 -0
- capsule_engine/guards/checks/__init__.py +268 -0
- capsule_engine/guards/checks/action_class_gate.py +199 -0
- capsule_engine/guards/checks/base.py +33 -0
- capsule_engine/guards/checks/caps.py +517 -0
- capsule_engine/guards/checks/channel_change.py +64 -0
- capsule_engine/guards/checks/commitment.py +41 -0
- capsule_engine/guards/checks/counterparty_identity_change.py +95 -0
- capsule_engine/guards/checks/counterparty_list.py +176 -0
- capsule_engine/guards/checks/counterparty_seen_before.py +160 -0
- capsule_engine/guards/checks/credential_pattern.py +73 -0
- capsule_engine/guards/checks/dedupe.py +259 -0
- capsule_engine/guards/checks/destination_rail.py +120 -0
- capsule_engine/guards/checks/field_change_count.py +100 -0
- capsule_engine/guards/checks/offer_expiry.py +134 -0
- capsule_engine/guards/checks/plan_containment.py +180 -0
- capsule_engine/guards/checks/price_floor.py +211 -0
- capsule_engine/guards/checks/promise_never.py +236 -0
- capsule_engine/guards/checks/promise_requires_approval.py +214 -0
- capsule_engine/guards/checks/recipient_role.py +117 -0
- capsule_engine/guards/checks/recurring_charge.py +49 -0
- capsule_engine/guards/checks/refundability.py +46 -0
- capsule_engine/guards/checks/release_on_acceptance.py +286 -0
- capsule_engine/guards/checks/required_disclosure.py +233 -0
- capsule_engine/guards/checks/single_commitment.py +221 -0
- capsule_engine/guards/checks/task_authority.py +182 -0
- capsule_engine/guards/checks/upfront_amount.py +71 -0
- capsule_engine/guards/checks/verify_before_dispatch.py +75 -0
- capsule_engine/guards/classes.py +393 -0
- capsule_engine/guards/engine.py +926 -0
- capsule_engine/guards/history_state.py +127 -0
- capsule_engine/guards/plan.py +237 -0
- capsule_engine/guards/revocation.py +120 -0
- capsule_engine/guards/signing.py +60 -0
- capsule_engine/guards/statements.py +62 -0
- capsule_engine/guards/tool_call.py +83 -0
- capsule_engine/guards/wickets/__init__.py +23 -0
- capsule_engine/guards/wickets/catalog.py +75 -0
- capsule_engine/guards/wickets/catalog_defs/action_class_gate.v1.1.yaml +38 -0
- capsule_engine/guards/wickets/catalog_defs/action_class_gate.v1.2.yaml +52 -0
- capsule_engine/guards/wickets/catalog_defs/action_class_gate.yaml +34 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v2.yaml +30 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v3.yaml +49 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v4.yaml +45 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v5.1.yaml +53 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v5.yaml +43 -0
- capsule_engine/guards/wickets/catalog_defs/caps.yaml +17 -0
- capsule_engine/guards/wickets/catalog_defs/caps_holds.yaml +18 -0
- capsule_engine/guards/wickets/catalog_defs/channel_change.v1.1.yaml +18 -0
- capsule_engine/guards/wickets/catalog_defs/channel_change.yaml +10 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_identity_change.yaml +9 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_list.yaml +20 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v2.yaml +15 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v3.yaml +23 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v4.yaml +26 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/credential_pattern.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/dedupe.yaml +19 -0
- capsule_engine/guards/wickets/catalog_defs/destination_rail.seller.v1.1.yaml +32 -0
- capsule_engine/guards/wickets/catalog_defs/destination_rail.seller.yaml +10 -0
- capsule_engine/guards/wickets/catalog_defs/destination_rail.yaml +9 -0
- capsule_engine/guards/wickets/catalog_defs/hold_reconcile.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/material_fields_changed.v1.1.yaml +22 -0
- capsule_engine/guards/wickets/catalog_defs/material_fields_changed.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/offer_expiry.v1.1.yaml +31 -0
- capsule_engine/guards/wickets/catalog_defs/offer_expiry.yaml +24 -0
- capsule_engine/guards/wickets/catalog_defs/offer_fields_changed.v1.1.yaml +22 -0
- capsule_engine/guards/wickets/catalog_defs/offer_fields_changed.yaml +10 -0
- capsule_engine/guards/wickets/catalog_defs/price_floor.yaml +32 -0
- capsule_engine/guards/wickets/catalog_defs/promise_never.v1.1.yaml +64 -0
- capsule_engine/guards/wickets/catalog_defs/promise_never.yaml +16 -0
- capsule_engine/guards/wickets/catalog_defs/promise_requires_approval.v1.1.yaml +26 -0
- capsule_engine/guards/wickets/catalog_defs/promise_requires_approval.yaml +21 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.v1.1.yaml +23 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.v1.2.yaml +24 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.yaml +10 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.v1.1.yaml +26 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.yaml +11 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_seen_before.yaml +16 -0
- capsule_engine/guards/wickets/catalog_defs/recurring_charge.yaml +9 -0
- capsule_engine/guards/wickets/catalog_defs/refundability.v1.1.yaml +15 -0
- capsule_engine/guards/wickets/catalog_defs/refundability.yaml +7 -0
- capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.v1.1.yaml +44 -0
- capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.v1.2.yaml +63 -0
- capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.yaml +36 -0
- capsule_engine/guards/wickets/catalog_defs/required_disclosure.v1.1.yaml +36 -0
- capsule_engine/guards/wickets/catalog_defs/required_disclosure.v1.2.yaml +42 -0
- capsule_engine/guards/wickets/catalog_defs/required_disclosure.yaml +25 -0
- capsule_engine/guards/wickets/catalog_defs/single_commitment.seller.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/task_authority.v1.1.yaml +20 -0
- capsule_engine/guards/wickets/catalog_defs/task_authority.yaml +8 -0
- capsule_engine/guards/wickets/catalog_defs/upfront_amount.v1.1.yaml +19 -0
- capsule_engine/guards/wickets/catalog_defs/upfront_amount.yaml +11 -0
- capsule_engine/guards/wickets/catalog_defs/verify_before_dispatch.yaml +9 -0
- capsule_engine/guards/wickets/definition.py +164 -0
- capsule_engine/guards/wickets/errors.py +18 -0
- capsule_engine/guards/wickets/loader.py +26 -0
- capsule_engine/guards/wickets/retired.py +62 -0
- capsule_engine/holds/__init__.py +35 -0
- capsule_engine/holds/capsules.py +266 -0
- capsule_engine/holds/engine.py +476 -0
- capsule_engine/holds/errors.py +29 -0
- capsule_engine/holds/policy.py +62 -0
- capsule_engine/holds/scope.py +47 -0
- capsule_engine/mcp/__init__.py +16 -0
- capsule_engine/mcp/config.py +117 -0
- capsule_engine/mcp/server.py +338 -0
- capsule_engine/mcp/tools.py +424 -0
- capsule_engine/packaging.py +60 -0
- capsule_engine/packs/__init__.py +45 -0
- capsule_engine/packs/backfill_coverage.py +238 -0
- capsule_engine/packs/catalog/airline-engagement/AI-BOOTSTRAP.md +53 -0
- capsule_engine/packs/catalog/airline-engagement/folds/session_close.yaml +8 -0
- capsule_engine/packs/catalog/airline-engagement/pack.yaml +146 -0
- capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/good_execution.json +36 -0
- capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/oversight_declared.json +36 -0
- capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/tampered_execution.json +36 -0
- capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/trusted_issuer.jwk.json +5 -0
- capsule_engine/packs/catalog/eu-ai-act/obligations-pack-note.md +68 -0
- capsule_engine/packs/catalog/eu-ai-act/obligations-register.yaml +761 -0
- capsule_engine/packs/catalog/eu-ai-act/pack.yaml +88 -0
- capsule_engine/packs/catalog/eu-ai-act/register.yaml +96 -0
- capsule_engine/packs/catalog/eu-ai-act-deterministic/fixtures/synthetic_internal_assist_sample.jsonl +6 -0
- capsule_engine/packs/catalog/eu-ai-act-deterministic/pack.yaml +580 -0
- capsule_engine/packs/catalog/everyday/AI-BOOTSTRAP.md +194 -0
- capsule_engine/packs/catalog/everyday/fixtures/mini_ledger.jsonl +45 -0
- capsule_engine/packs/catalog/everyday/pack.yaml +480 -0
- capsule_engine/packs/catalog/payments-safety/AI-BOOTSTRAP.md +83 -0
- capsule_engine/packs/catalog/payments-safety/fixtures/mini_ledger.jsonl +8 -0
- capsule_engine/packs/catalog/payments-safety/folds/spend_weekly.yaml +29 -0
- capsule_engine/packs/catalog/payments-safety/pack.yaml +112 -0
- capsule_engine/packs/catalog/seller/fixtures/mini_ledger.jsonl +83 -0
- capsule_engine/packs/catalog/seller/pack.yaml +503 -0
- capsule_engine/packs/catalog/standard-vendor/AI-BOOTSTRAP.md +70 -0
- capsule_engine/packs/catalog/standard-vendor/folds/session_close.yaml +8 -0
- capsule_engine/packs/catalog/standard-vendor/pack.yaml +466 -0
- capsule_engine/packs/contract_diff.py +809 -0
- capsule_engine/packs/contract_validate.py +185 -0
- capsule_engine/packs/corpus_verify.py +126 -0
- capsule_engine/packs/enforce.py +81 -0
- capsule_engine/packs/errors.py +159 -0
- capsule_engine/packs/eu_ai_act_trace_field_map.py +46 -0
- capsule_engine/packs/install.py +266 -0
- capsule_engine/packs/loader.py +1462 -0
- capsule_engine/packs/measurability_report.py +280 -0
- capsule_engine/packs/obligation_results.py +72 -0
- capsule_engine/packs/pins.py +92 -0
- capsule_engine/packs/schema.py +1053 -0
- capsule_engine/packs/thresholds.py +146 -0
- capsule_engine/packs/trace_attainment.py +188 -0
- capsule_engine/packs/trace_reader.py +104 -0
- capsule_engine/policy/__init__.py +37 -0
- capsule_engine/policy/activation.py +87 -0
- capsule_engine/policy/catalog_defs/default.yaml +23 -0
- capsule_engine/policy/catalog_defs/holds.yaml +27 -0
- capsule_engine/policy/errors.py +46 -0
- capsule_engine/policy/limits.py +287 -0
- capsule_engine/policy/loader.py +26 -0
- capsule_engine/policy/manifest.py +289 -0
- capsule_engine/policy/profile.py +281 -0
- capsule_engine/policy/resolve.py +224 -0
- capsule_engine/register/__init__.py +39 -0
- capsule_engine/register/compiler.py +219 -0
- capsule_engine/register/errors.py +40 -0
- capsule_engine/register/loader.py +247 -0
- capsule_engine/register/schema.py +129 -0
- capsule_engine/registry/__init__.py +19 -0
- capsule_engine/registry/conventions.json +162 -0
- capsule_engine/registry/conventions.py +188 -0
- capsule_engine/registry/cpb_registry.json +78 -0
- capsule_engine/report/__init__.py +62 -0
- capsule_engine/report/build.py +267 -0
- capsule_engine/report/coverage.py +777 -0
- capsule_engine/report/errors.py +35 -0
- capsule_engine/report/live_check.py +126 -0
- capsule_engine/report/live_history.py +420 -0
- capsule_engine/report/model.py +95 -0
- capsule_engine/report/render.py +467 -0
- capsule_engine/report/replay.py +1677 -0
- capsule_engine/report/result.py +504 -0
- capsule_engine/report/result_from_folds.py +222 -0
- capsule_engine/report/result_from_layers.py +670 -0
- capsule_engine/report/sale_bundle.py +864 -0
- capsule_engine/report/static/verify.js +451 -0
- capsule_engine/schemas/evidence-contract-v0.json +383 -0
- capsule_engine/schemas/vendor/epistemic-types.json +21 -0
- capsule_engine/schemas/vendor/evidence-result-v0.json +463 -0
- capsule_engine/telemetry/__init__.py +9 -0
- capsule_engine/telemetry/consent.py +39 -0
- capsule_engine/telemetry/events.py +93 -0
- capsule_engine/telemetry/fixtures/synthetic_events.jsonl +45 -0
- capsule_engine/telemetry/funnel.py +147 -0
- capsule_engine/telemetry/record.py +106 -0
- capsule_engine/telemetry/sink.py +61 -0
- capsule_engine/telemetry/state.py +60 -0
- capsule_engine/tenants.py +327 -0
- capsule_engine-0.1.0.dist-info/METADATA +47 -0
- capsule_engine-0.1.0.dist-info/RECORD +288 -0
- capsule_engine-0.1.0.dist-info/WHEEL +5 -0
- capsule_engine-0.1.0.dist-info/entry_points.txt +2 -0
- capsule_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
- capsule_engine-0.1.0.dist-info/licenses/capsule_engine/examples/data/tau2_airline/LICENSE-tau2-bench +21 -0
- capsule_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""caps check: a fold predicate over T1's fold engine.
|
|
3
|
+
|
|
4
|
+
``weekly_spend + amount <= cap``, where ``weekly_spend`` is the T1 fold's
|
|
5
|
+
own evaluated result (a real replay over the ledger, never a number the
|
|
6
|
+
guard computes itself) and ``amount`` is this action's own, not-yet-
|
|
7
|
+
recorded amount. Records that carry no amount (imported/foreign capsules,
|
|
8
|
+
or non-money actions) are skipped by the fold engine itself (spec §3
|
|
9
|
+
rule 4), not treated as zero-contributing noise by this check.
|
|
10
|
+
|
|
11
|
+
The running total is partitioned by the fold's own ``key`` (``developer`` or
|
|
12
|
+
``operator``), and the evidence names that key and the value the total was
|
|
13
|
+
read under. ``caps_minor`` is keyed by action class; ``resolve_caps_minor``
|
|
14
|
+
maps a legacy name to its canonical row so both spellings are one cap.
|
|
15
|
+
|
|
16
|
+
A class may also carry a per-action limit, compared against the proposed
|
|
17
|
+
action's own amount alone. With one, the evidence adds ``per_action_cap_minor``
|
|
18
|
+
and ``tripped``: one ``{limit, threshold_minor, observed_minor}`` entry per
|
|
19
|
+
limit exceeded (``per_action`` against the amount, ``window`` against the
|
|
20
|
+
projected total), empty on a pass. Without one the evidence keeps its
|
|
21
|
+
single-limit shape, so records under a window-only config keep their bytes.
|
|
22
|
+
|
|
23
|
+
Under a config with ``per_action_reads: spend_authorized_minor``
|
|
24
|
+
(``caps/5.0.0``) the per-action limit reads the action's authorised maximum,
|
|
25
|
+
``spend_authorized_minor``: the most the payment may take, which can exceed
|
|
26
|
+
the expected capture by a pre-authorisation buffer. It reads the capture,
|
|
27
|
+
``spend_minor`` (the action's ``amount_minor``), when no authorised maximum was
|
|
28
|
+
declared, or one below the capture was. The window limit always reads the
|
|
29
|
+
capture, because the rolling total sums what was taken. The evidence adds
|
|
30
|
+
``per_action_basis``: the field read, whether it fell back to the capture, and
|
|
31
|
+
the value compared.
|
|
32
|
+
|
|
33
|
+
When the engine reads its limits from activated policy (``policy/limits.py``)
|
|
34
|
+
the evidence adds ``limit_sources``: for each limit applied (``window``, and
|
|
35
|
+
``per_action`` when set), whether its value is the operator's, from a policy
|
|
36
|
+
profile (``operator_profile``, with that profile's digest), or the wicket's
|
|
37
|
+
default (``definition_default``), plus any raise activated but still inside
|
|
38
|
+
its cooling-off (``pending_raise``). An engine given bare limit tables has no
|
|
39
|
+
provenance to report, and its evidence keeps the shape it had.
|
|
40
|
+
|
|
41
|
+
Under a fold with a ``reversal`` clause (``spend.weekly/3.0.0``) the evidence
|
|
42
|
+
adds ``reversals``: how many cancels or refunds took a linked charge back out
|
|
43
|
+
of the total and by how much, and how many were unlinked and did nothing.
|
|
44
|
+
Under a fold without one the evidence keeps the shape it had.
|
|
45
|
+
|
|
46
|
+
Under a fold that also counts executed acts (``spend.weekly/3.1.0``, whose
|
|
47
|
+
filter admits ``EXECUTED_DECISION``) a replay, or a live check's history
|
|
48
|
+
ledger, writes a record for each executed act (``report/replay.py``,
|
|
49
|
+
``ExecutedActs``). One whose spend could not be read carries
|
|
50
|
+
``asg_payload.spend_unreadable`` and no amount, so the fold skips it. With one
|
|
51
|
+
in the window the total is not known: the check is ``n/a``, in scope, naming
|
|
52
|
+
``spend_minor``, with the reason ``WINDOW_UNREADABLE_REASON``, and the engine
|
|
53
|
+
asks rather than allows (``CheckOutcome.asks_when_unevaluated``). A per-action
|
|
54
|
+
limit the action exceeds still fails, and its evidence adds
|
|
55
|
+
``window_unreadable_count``.
|
|
56
|
+
|
|
57
|
+
A live check's history ledger that is not known to hold every act writes an
|
|
58
|
+
``incomplete`` record (``guards/history_state.py``) under the checked
|
|
59
|
+
record's operator. With one in the ledger the window total may leave out acts
|
|
60
|
+
that were carried out: the check is ``n/a``, in scope, with the reason
|
|
61
|
+
``history_incomplete_reason`` and no missing field, and the engine asks
|
|
62
|
+
rather than allows, under any fold. It takes precedence over
|
|
63
|
+
``WINDOW_UNREADABLE_REASON``. A per-action limit the action exceeds still
|
|
64
|
+
fails, its evidence unchanged. A ledger with no such record is read as
|
|
65
|
+
before.
|
|
66
|
+
"""
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
from collections.abc import Mapping
|
|
70
|
+
from dataclasses import replace
|
|
71
|
+
from typing import Literal, NotRequired, TypedDict
|
|
72
|
+
|
|
73
|
+
from cll.ledger.api import LedgerAPI, ScanQuery
|
|
74
|
+
|
|
75
|
+
from ...folds.definition import FilterClause, FoldDefinition, ReadField, Reduce
|
|
76
|
+
from ...folds.engine import EvaluationTrace, ReversalSummary, evaluate_one
|
|
77
|
+
from ..action import Action
|
|
78
|
+
from ..capsule import ConstraintOutcome, not_applicable_evidence
|
|
79
|
+
from ..classes import resolve
|
|
80
|
+
from ..history_state import INCOMPLETE, WINDOW_HELD, history_state
|
|
81
|
+
from .base import CheckOutcome
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"EXECUTED_DECISION",
|
|
85
|
+
"SPEND_UNREADABLE",
|
|
86
|
+
"WINDOW_UNREADABLE_REASON",
|
|
87
|
+
"LIMIT_SOURCE_DEFAULT",
|
|
88
|
+
"LIMIT_SOURCE_PROFILE",
|
|
89
|
+
"PER_ACTION_READS",
|
|
90
|
+
"CapTripped",
|
|
91
|
+
"CapsEvidence",
|
|
92
|
+
"FoldKey",
|
|
93
|
+
"LimitSource",
|
|
94
|
+
"LimitSources",
|
|
95
|
+
"PendingRaise",
|
|
96
|
+
"PerActionBasis",
|
|
97
|
+
"TwoLimitCapsEvidence",
|
|
98
|
+
"cap_for",
|
|
99
|
+
"check_caps",
|
|
100
|
+
"counts_executed_acts",
|
|
101
|
+
"history_incomplete_reason",
|
|
102
|
+
"in_window_count",
|
|
103
|
+
"require_per_action_reads",
|
|
104
|
+
"resolve_caps_minor",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# The values a caps config's ``per_action_reads`` may take. Absent, the
|
|
108
|
+
# per-action limit reads the capture, as caps/3.0.0 and caps/4.0.0 do.
|
|
109
|
+
PER_ACTION_READS = frozenset({"spend_authorized_minor"})
|
|
110
|
+
|
|
111
|
+
# Where a limit's value came from (``limit_sources``).
|
|
112
|
+
LIMIT_SOURCE_PROFILE = "operator_profile"
|
|
113
|
+
LIMIT_SOURCE_DEFAULT = "definition_default"
|
|
114
|
+
|
|
115
|
+
# The ``disposition.decision`` of the record a replay writes for an act a
|
|
116
|
+
# deal executed. No guard decision carries it.
|
|
117
|
+
EXECUTED_DECISION = "executed"
|
|
118
|
+
# The ``asg_payload`` flag on such a record whose spend could not be read.
|
|
119
|
+
SPEND_UNREADABLE = "spend_unreadable"
|
|
120
|
+
WINDOW_UNREADABLE_REASON = "an earlier act has no recorded decision, so the total cannot count it"
|
|
121
|
+
_DECISION_PATH = "disposition.decision"
|
|
122
|
+
_UNREADABLE_PATH = f"asg_payload.{SPEND_UNREADABLE}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class FoldKey(TypedDict):
|
|
126
|
+
"""The partition a running total was read under."""
|
|
127
|
+
|
|
128
|
+
path: str | None
|
|
129
|
+
value: str | None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class PendingRaise(TypedDict):
|
|
133
|
+
"""A higher value activated for a limit, not yet in force."""
|
|
134
|
+
|
|
135
|
+
value_minor: int
|
|
136
|
+
effective_at: str
|
|
137
|
+
limit_source: Literal["operator_profile", "definition_default"]
|
|
138
|
+
profile_digest: NotRequired[str]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class LimitSource(TypedDict):
|
|
142
|
+
"""Where one applied limit's value came from."""
|
|
143
|
+
|
|
144
|
+
limit_source: Literal["operator_profile", "definition_default"]
|
|
145
|
+
# Only for ``operator_profile``: the digest of the profile that set it.
|
|
146
|
+
profile_digest: NotRequired[str]
|
|
147
|
+
pending_raise: NotRequired[PendingRaise]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class LimitSources(TypedDict):
|
|
151
|
+
"""``LimitSource`` per applied limit."""
|
|
152
|
+
|
|
153
|
+
window: LimitSource
|
|
154
|
+
per_action: NotRequired[LimitSource]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class CapsEvidence(TypedDict):
|
|
158
|
+
"""The facts a pass/fail caps constraint seals."""
|
|
159
|
+
|
|
160
|
+
fold: str
|
|
161
|
+
fold_key: FoldKey
|
|
162
|
+
weekly_spend_minor: int
|
|
163
|
+
amount_minor: int
|
|
164
|
+
cap_minor: int
|
|
165
|
+
projected_minor: int
|
|
166
|
+
# Only under a fold with a ``reversal`` clause.
|
|
167
|
+
reversals: NotRequired[ReversalSummary]
|
|
168
|
+
# Only when the engine reads its limits from activated policy.
|
|
169
|
+
limit_sources: NotRequired[LimitSources]
|
|
170
|
+
# Only when an executed act in the window has a spend that cannot be read.
|
|
171
|
+
window_unreadable_count: NotRequired[int]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class CapTripped(TypedDict):
|
|
175
|
+
"""One limit an action exceeded, as sealed in the caps evidence."""
|
|
176
|
+
|
|
177
|
+
limit: Literal["per_action", "window"]
|
|
178
|
+
threshold_minor: int
|
|
179
|
+
observed_minor: int
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class PerActionBasis(TypedDict):
|
|
183
|
+
"""The amount a per-action limit read, under ``per_action_reads``."""
|
|
184
|
+
|
|
185
|
+
field: Literal["spend_authorized_minor", "spend_minor"]
|
|
186
|
+
fell_back: bool
|
|
187
|
+
observed_minor: int
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class TwoLimitCapsEvidence(CapsEvidence):
|
|
191
|
+
"""``CapsEvidence`` under a config that also sets a per-action limit."""
|
|
192
|
+
|
|
193
|
+
per_action_cap_minor: int
|
|
194
|
+
tripped: list[CapTripped]
|
|
195
|
+
# Only under a config with ``per_action_reads``.
|
|
196
|
+
per_action_basis: NotRequired[PerActionBasis]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _canonical(action_class: str) -> str:
|
|
200
|
+
ac = resolve(action_class)
|
|
201
|
+
return ac.name if ac is not None else action_class
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def resolve_caps_minor(caps_minor: Mapping[str, int]) -> dict[str, int]:
|
|
205
|
+
"""``caps_minor`` keyed by canonical class name. A name outside the
|
|
206
|
+
taxonomy is kept as written. Raises ``ValueError`` when a legacy and a
|
|
207
|
+
canonical key for one class carry different limits."""
|
|
208
|
+
out: dict[str, int] = {}
|
|
209
|
+
for name, cap in caps_minor.items():
|
|
210
|
+
canonical = _canonical(name)
|
|
211
|
+
if canonical in out and out[canonical] != cap:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"caps_minor sets two limits for {canonical!r} ({out[canonical]} and {cap}) "
|
|
214
|
+
"under a legacy and a canonical name"
|
|
215
|
+
)
|
|
216
|
+
out[canonical] = cap
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def cap_for(caps_minor: Mapping[str, int], action_class: str | None) -> int | None:
|
|
221
|
+
"""The limit for ``action_class`` in a ``resolve_caps_minor`` mapping."""
|
|
222
|
+
if action_class is None:
|
|
223
|
+
return None
|
|
224
|
+
return caps_minor.get(_canonical(action_class))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _fold_key(action: Action, key: str | None, since: str | None) -> tuple[str | None, ScanQuery]:
|
|
228
|
+
"""The action's value for the fold's partition key, and a scan narrowed to it.
|
|
229
|
+
|
|
230
|
+
``ScanQuery.counterparty`` is the ledger's filter on ``operator``.
|
|
231
|
+
"""
|
|
232
|
+
if key == "developer":
|
|
233
|
+
return action.developer, ScanQuery(agent=action.developer, since=since)
|
|
234
|
+
if key == "operator":
|
|
235
|
+
return action.operator, ScanQuery(counterparty=action.operator, since=since)
|
|
236
|
+
if key is None:
|
|
237
|
+
return None, ScanQuery(since=since)
|
|
238
|
+
raise ValueError(f"caps cannot partition by fold key {key!r}; it reads developer or operator")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def counts_executed_acts(definition: FoldDefinition | None) -> bool:
|
|
242
|
+
"""Whether ``definition``'s filter admits the record a replay writes for
|
|
243
|
+
an executed act (``EXECUTED_DECISION``)."""
|
|
244
|
+
if definition is None:
|
|
245
|
+
return False
|
|
246
|
+
for clause in definition.filter:
|
|
247
|
+
if clause.field != _DECISION_PATH:
|
|
248
|
+
continue
|
|
249
|
+
if (clause.op == "eq" and clause.value == EXECUTED_DECISION) or (
|
|
250
|
+
clause.op == "in" and EXECUTED_DECISION in clause.value
|
|
251
|
+
):
|
|
252
|
+
return True
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _unreadable_in_window(definition: FoldDefinition, records: list[dict], key_value: str | None, as_of: str) -> int:
|
|
257
|
+
"""How many executed acts with a spend that could not be read fall in
|
|
258
|
+
``definition``'s window under ``key_value``: the same key, window and
|
|
259
|
+
anchor the total was read under, counted instead of summed."""
|
|
260
|
+
return _count_in_window(
|
|
261
|
+
definition, records, key_value, as_of,
|
|
262
|
+
reads=(ReadField(path=_DECISION_PATH, erasure_class="commitment-ok"),
|
|
263
|
+
ReadField(path=_UNREADABLE_PATH, erasure_class="commitment-ok", default=False, has_default=True)),
|
|
264
|
+
filter=(FilterClause(field=_DECISION_PATH, op="eq", value=EXECUTED_DECISION),
|
|
265
|
+
FilterClause(field=_UNREADABLE_PATH, op="eq", value=True)),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def in_window_count(definition: FoldDefinition, records: list[dict], key_value: str | None, as_of: str) -> int:
|
|
270
|
+
"""How many of ``records`` the window would count by their key and time
|
|
271
|
+
alone: under ``key_value`` for ``definition``'s key, in its window as of
|
|
272
|
+
``as_of``, read as the total is read."""
|
|
273
|
+
return _count_in_window(definition, records, key_value, as_of, reads=(), filter=())
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _count_in_window(definition: FoldDefinition, records: list[dict], key_value: str | None, as_of: str, *,
|
|
277
|
+
reads: tuple[ReadField, ...], filter: tuple[FilterClause, ...]) -> int:
|
|
278
|
+
"""How many of ``records`` pass ``filter`` (over ``reads``) and fall in
|
|
279
|
+
``definition``'s window under ``key_value``, counted instead of summed."""
|
|
280
|
+
probe_reads = [ReadField(path="timestamp", erasure_class="commitment-ok"), *reads]
|
|
281
|
+
if definition.key is not None:
|
|
282
|
+
probe_reads.append(ReadField(path=definition.key, erasure_class="commitment-ok"))
|
|
283
|
+
probe = replace(definition, reads=tuple(probe_reads), filter=filter, reduce=Reduce(reducer="count"), reversal=None)
|
|
284
|
+
return evaluate_one(probe, records, key_value=key_value, as_of=as_of).result or 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _operator_history_incomplete(ledger: LedgerAPI, action: Action) -> bool:
|
|
288
|
+
"""Whether ``action``'s operator's records hold a live history's
|
|
289
|
+
``incomplete`` record, which is written under the checked record's
|
|
290
|
+
operator."""
|
|
291
|
+
return any(history_state(r.capsule) == INCOMPLETE for r in ledger.scan(ScanQuery(counterparty=action.operator)))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _window_held(records: list[dict]) -> str | None:
|
|
295
|
+
"""The reason a live history's ``window_held`` record gives, when one of
|
|
296
|
+
``records`` is that record."""
|
|
297
|
+
for capsule in records:
|
|
298
|
+
if history_state(capsule) == WINDOW_HELD:
|
|
299
|
+
reason = (capsule.get("asg_payload") or {}).get("held_reason")
|
|
300
|
+
return reason if isinstance(reason, str) and reason else "the history cannot decide the window"
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def history_incomplete_reason(definition: FoldDefinition) -> str:
|
|
305
|
+
"""Why the window total is not known on a history that may be missing
|
|
306
|
+
acts, naming the fold's window (``7d``)."""
|
|
307
|
+
window = definition.window.duration if definition.window is not None and definition.window.duration else "window"
|
|
308
|
+
return (
|
|
309
|
+
"the history supplied is incomplete (more acts than it holds, or some could not be read), "
|
|
310
|
+
f"so the {window} total cannot be computed"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def require_per_action_reads(per_action_reads: str | None) -> None:
|
|
315
|
+
"""Raise ``ValueError`` unless ``per_action_reads`` is absent or one of
|
|
316
|
+
``PER_ACTION_READS``."""
|
|
317
|
+
if per_action_reads is not None and per_action_reads not in PER_ACTION_READS:
|
|
318
|
+
raise ValueError(f"caps per_action_reads {per_action_reads!r} is not one of {sorted(PER_ACTION_READS)}")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _per_action_basis(action: Action, amount_minor: int) -> PerActionBasis:
|
|
322
|
+
"""What the per-action limit compares under ``per_action_reads``. An
|
|
323
|
+
authorised maximum below the capture is not a maximum, so the capture is
|
|
324
|
+
read instead."""
|
|
325
|
+
authorized = action.spend_authorized_minor
|
|
326
|
+
if authorized is None or authorized < amount_minor:
|
|
327
|
+
return PerActionBasis(field="spend_minor", fell_back=True, observed_minor=amount_minor)
|
|
328
|
+
return PerActionBasis(field="spend_authorized_minor", fell_back=False, observed_minor=authorized)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def check_caps(
|
|
332
|
+
action: Action,
|
|
333
|
+
ledger: LedgerAPI,
|
|
334
|
+
*,
|
|
335
|
+
definition: FoldDefinition,
|
|
336
|
+
cap_minor: int,
|
|
337
|
+
per_action_cap_minor: int | None = None,
|
|
338
|
+
per_action_reads: str | None = None,
|
|
339
|
+
since: str | None = None,
|
|
340
|
+
as_of: str | None = None,
|
|
341
|
+
limit_sources: LimitSources | None = None,
|
|
342
|
+
) -> CheckOutcome:
|
|
343
|
+
require_per_action_reads(per_action_reads)
|
|
344
|
+
if limit_sources is not None and ("per_action" in limit_sources) != (per_action_cap_minor is not None):
|
|
345
|
+
raise ValueError("limit_sources must name a per_action source exactly when a per-action limit is applied")
|
|
346
|
+
if action.amount_minor is None:
|
|
347
|
+
return CheckOutcome(
|
|
348
|
+
constraint=ConstraintOutcome(
|
|
349
|
+
id="caps",
|
|
350
|
+
result="n/a",
|
|
351
|
+
reason="a cap is configured for this action class but the action carries no amount_minor; "
|
|
352
|
+
"the cap could not be evaluated",
|
|
353
|
+
evidence=not_applicable_evidence("caps", in_scope=True, missing_field="amount_minor"),
|
|
354
|
+
check_type="policy",
|
|
355
|
+
method=definition.fold_id,
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
key_value, query = _fold_key(action, definition.key, since)
|
|
360
|
+
records = [r.capsule for r in ledger.scan(query)]
|
|
361
|
+
anchor = as_of or action.resolved_timestamp()
|
|
362
|
+
trace = evaluate_one(definition, records, key_value=key_value, as_of=anchor)
|
|
363
|
+
unreadable = _unreadable_in_window(definition, records, key_value, anchor) if counts_executed_acts(definition) else 0
|
|
364
|
+
# Under a fold keyed by operator the records read already hold the operator's marker.
|
|
365
|
+
incomplete = (any(history_state(capsule) == INCOMPLETE for capsule in records) if definition.key == "operator"
|
|
366
|
+
else _operator_history_incomplete(ledger, action))
|
|
367
|
+
held = _window_held(records if definition.key == "operator"
|
|
368
|
+
else [r.capsule for r in ledger.scan(ScanQuery(counterparty=action.operator))])
|
|
369
|
+
weekly_spend = trace.result or 0
|
|
370
|
+
projected = weekly_spend + action.amount_minor
|
|
371
|
+
envelope = trace.to_envelope()
|
|
372
|
+
evidence = CapsEvidence(
|
|
373
|
+
fold=envelope["fold"],
|
|
374
|
+
fold_key=FoldKey(path=definition.key, value=key_value),
|
|
375
|
+
weekly_spend_minor=weekly_spend,
|
|
376
|
+
amount_minor=action.amount_minor,
|
|
377
|
+
cap_minor=cap_minor,
|
|
378
|
+
projected_minor=projected,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if trace.reversals is not None:
|
|
382
|
+
evidence["reversals"] = trace.reversals
|
|
383
|
+
if limit_sources is not None:
|
|
384
|
+
evidence["limit_sources"] = limit_sources
|
|
385
|
+
if unreadable:
|
|
386
|
+
evidence["window_unreadable_count"] = unreadable
|
|
387
|
+
|
|
388
|
+
if per_action_cap_minor is not None:
|
|
389
|
+
basis = _per_action_basis(action, action.amount_minor) if per_action_reads is not None else None
|
|
390
|
+
per_action_amount = basis["observed_minor"] if basis is not None else action.amount_minor
|
|
391
|
+
result, reason, tripped = _judge_two_limits(
|
|
392
|
+
per_action_amount, projected, cap_minor, per_action_cap_minor, basis["field"] if basis is not None else "amount"
|
|
393
|
+
)
|
|
394
|
+
if not any(t["limit"] == "per_action" for t in tripped):
|
|
395
|
+
if incomplete:
|
|
396
|
+
return _history_incomplete_outcome(definition, trace)
|
|
397
|
+
if held is not None:
|
|
398
|
+
return _held_outcome(definition, trace, held)
|
|
399
|
+
if unreadable:
|
|
400
|
+
return _window_unreadable(definition, envelope)
|
|
401
|
+
two_limit = TwoLimitCapsEvidence(**evidence, per_action_cap_minor=per_action_cap_minor, tripped=tripped)
|
|
402
|
+
if basis is not None:
|
|
403
|
+
two_limit["per_action_basis"] = basis
|
|
404
|
+
return CheckOutcome(
|
|
405
|
+
constraint=ConstraintOutcome(
|
|
406
|
+
id="caps",
|
|
407
|
+
result=result,
|
|
408
|
+
reason=reason,
|
|
409
|
+
evidence=two_limit,
|
|
410
|
+
check_type="policy",
|
|
411
|
+
method=definition.fold_id,
|
|
412
|
+
),
|
|
413
|
+
fold_envelopes=(envelope,),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
if incomplete:
|
|
417
|
+
return _history_incomplete_outcome(definition, trace)
|
|
418
|
+
if held is not None:
|
|
419
|
+
return _held_outcome(definition, trace, held)
|
|
420
|
+
if unreadable:
|
|
421
|
+
return _window_unreadable(definition, envelope)
|
|
422
|
+
if projected <= cap_minor:
|
|
423
|
+
return CheckOutcome(
|
|
424
|
+
constraint=ConstraintOutcome(
|
|
425
|
+
id="caps",
|
|
426
|
+
result="pass",
|
|
427
|
+
reason=f"weekly spend {weekly_spend} + {action.amount_minor} <= cap {cap_minor} (minor units)",
|
|
428
|
+
evidence=evidence,
|
|
429
|
+
check_type="policy",
|
|
430
|
+
method=definition.fold_id,
|
|
431
|
+
),
|
|
432
|
+
fold_envelopes=(envelope,),
|
|
433
|
+
)
|
|
434
|
+
return CheckOutcome(
|
|
435
|
+
constraint=ConstraintOutcome(
|
|
436
|
+
id="caps",
|
|
437
|
+
result="fail",
|
|
438
|
+
reason=f"weekly spend {weekly_spend} + {action.amount_minor} exceeds cap {cap_minor} (minor units)",
|
|
439
|
+
evidence=evidence,
|
|
440
|
+
check_type="policy",
|
|
441
|
+
method=definition.fold_id,
|
|
442
|
+
),
|
|
443
|
+
fold_envelopes=(envelope,),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _window_unreadable(definition: FoldDefinition, envelope: dict) -> CheckOutcome:
|
|
448
|
+
"""``n/a`` in scope, naming ``spend_minor``: an executed act in the
|
|
449
|
+
window has a spend that cannot be read, so the total is not known. The
|
|
450
|
+
engine asks rather than allows (``CheckOutcome.asks_when_unevaluated``)."""
|
|
451
|
+
return CheckOutcome(
|
|
452
|
+
constraint=ConstraintOutcome(
|
|
453
|
+
id="caps",
|
|
454
|
+
result="n/a",
|
|
455
|
+
reason=WINDOW_UNREADABLE_REASON,
|
|
456
|
+
evidence=not_applicable_evidence("caps", in_scope=True, missing_field="spend_minor"),
|
|
457
|
+
check_type="policy",
|
|
458
|
+
method=definition.fold_id,
|
|
459
|
+
),
|
|
460
|
+
fold_envelopes=(envelope,),
|
|
461
|
+
asks_when_unevaluated=True,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _history_incomplete_outcome(definition: FoldDefinition, trace: EvaluationTrace) -> CheckOutcome:
|
|
466
|
+
"""``n/a`` in scope, no missing field: the history may be missing acts,
|
|
467
|
+
so the total is not known. The engine asks rather than allows."""
|
|
468
|
+
return CheckOutcome(
|
|
469
|
+
constraint=ConstraintOutcome(
|
|
470
|
+
id="caps",
|
|
471
|
+
result="n/a",
|
|
472
|
+
reason=history_incomplete_reason(definition),
|
|
473
|
+
evidence=not_applicable_evidence("caps", in_scope=True),
|
|
474
|
+
check_type="policy",
|
|
475
|
+
method=definition.fold_id,
|
|
476
|
+
),
|
|
477
|
+
fold_envelopes=(trace.to_envelope(),),
|
|
478
|
+
asks_when_unevaluated=True,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _held_outcome(definition: FoldDefinition, trace: EvaluationTrace, reason: str) -> CheckOutcome:
|
|
483
|
+
"""``n/a`` in scope, no missing field: a complete history the window
|
|
484
|
+
cannot be summed over (``window_held``), so the total is not known. The
|
|
485
|
+
engine asks rather than allows."""
|
|
486
|
+
return CheckOutcome(
|
|
487
|
+
constraint=ConstraintOutcome(
|
|
488
|
+
id="caps",
|
|
489
|
+
result="n/a",
|
|
490
|
+
reason=reason,
|
|
491
|
+
evidence=not_applicable_evidence("caps", in_scope=True),
|
|
492
|
+
check_type="policy",
|
|
493
|
+
method=definition.fold_id,
|
|
494
|
+
),
|
|
495
|
+
fold_envelopes=(trace.to_envelope(),),
|
|
496
|
+
asks_when_unevaluated=True,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _judge_two_limits(
|
|
501
|
+
amount: int, projected: int, cap_minor: int, per_action_cap_minor: int, amount_label: str
|
|
502
|
+
) -> tuple[str, str, list[CapTripped]]:
|
|
503
|
+
tripped: list[CapTripped] = []
|
|
504
|
+
if amount > per_action_cap_minor:
|
|
505
|
+
tripped.append(CapTripped(limit="per_action", threshold_minor=per_action_cap_minor, observed_minor=amount))
|
|
506
|
+
if projected > cap_minor:
|
|
507
|
+
tripped.append(CapTripped(limit="window", threshold_minor=cap_minor, observed_minor=projected))
|
|
508
|
+
if tripped:
|
|
509
|
+
reason = "; ".join(
|
|
510
|
+
f"{t['limit']} limit {t['threshold_minor']} exceeded by {t['observed_minor']} (minor units)" for t in tripped
|
|
511
|
+
)
|
|
512
|
+
return "fail", reason, tripped
|
|
513
|
+
reason = (
|
|
514
|
+
f"{amount_label} {amount} <= per-action limit {per_action_cap_minor}; "
|
|
515
|
+
f"projected {projected} <= window limit {cap_minor} (minor units)"
|
|
516
|
+
)
|
|
517
|
+
return "pass", reason, tripped
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""channel_change check: compare the channel a counterparty is using with
|
|
3
|
+
the one the relationship started on, both read off the same action.
|
|
4
|
+
|
|
5
|
+
``Action.channel`` is the channel KIND in use at this action and
|
|
6
|
+
``Action.first_contact_channel`` the kind the relationship started on
|
|
7
|
+
(``email``, ``sms``, ...), never an address. They are compared for equality.
|
|
8
|
+
A value outside the pinned ``seeded_channels`` (a namespaced one) is compared
|
|
9
|
+
the same way, and the evidence records whether each was seeded. No history is
|
|
10
|
+
read: the producer states both values on the one record. Applies only to the
|
|
11
|
+
configured ``action_classes``.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TypedDict
|
|
16
|
+
|
|
17
|
+
from ..action import Action
|
|
18
|
+
from ..capsule import ConstraintOutcome, NotApplicableEvidence, not_applicable_evidence
|
|
19
|
+
from .base import CheckOutcome
|
|
20
|
+
|
|
21
|
+
__all__ = ["check_channel_change"]
|
|
22
|
+
|
|
23
|
+
_CHECK_ID = "channel_change"
|
|
24
|
+
_METHOD = "same_record_v0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ChannelEvidence(TypedDict):
|
|
28
|
+
channel: str
|
|
29
|
+
first_contact_channel: str
|
|
30
|
+
channel_seeded: bool
|
|
31
|
+
first_contact_channel_seeded: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _outcome(result: str, reason: str, evidence: ChannelEvidence | NotApplicableEvidence) -> CheckOutcome:
|
|
35
|
+
return CheckOutcome(
|
|
36
|
+
constraint=ConstraintOutcome(
|
|
37
|
+
id=_CHECK_ID, result=result, reason=reason, evidence=evidence, check_type="policy", method=_METHOD
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _missing(name: str) -> CheckOutcome:
|
|
43
|
+
return _outcome("n/a", f"the action carries no {name}; the channels could not be compared",
|
|
44
|
+
not_applicable_evidence(_CHECK_ID, in_scope=True, missing_field=name))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_channel_change(action: Action, *, seeded_channels: list[str], action_classes: list[str]) -> CheckOutcome:
|
|
48
|
+
if action.action_class not in action_classes:
|
|
49
|
+
return _outcome("n/a", "the rule is not configured for this action class",
|
|
50
|
+
not_applicable_evidence(_CHECK_ID, in_scope=False))
|
|
51
|
+
if action.channel is None:
|
|
52
|
+
return _missing("channel")
|
|
53
|
+
if action.first_contact_channel is None:
|
|
54
|
+
return _missing("first_contact_channel")
|
|
55
|
+
evidence = ChannelEvidence(
|
|
56
|
+
channel=action.channel,
|
|
57
|
+
first_contact_channel=action.first_contact_channel,
|
|
58
|
+
channel_seeded=action.channel in seeded_channels,
|
|
59
|
+
first_contact_channel_seeded=action.first_contact_channel in seeded_channels,
|
|
60
|
+
)
|
|
61
|
+
if action.channel != action.first_contact_channel:
|
|
62
|
+
return _outcome("fail", f"the counterparty moved from {action.first_contact_channel!r} to {action.channel!r}",
|
|
63
|
+
evidence)
|
|
64
|
+
return _outcome("pass", "the counterparty is on the channel the relationship started on", evidence)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Opening a salted commitment a deal record seals (commit_alg
|
|
3
|
+
``sha256-jcs-nonce256``): SHA-256 over the JCS bytes of ``{"nonce", "text"}``,
|
|
4
|
+
with a 256-bit nonce in lowercase hex. capsulectl seals every ``*_commitment``
|
|
5
|
+
this way (capsule-cli ``internal/cli/deal_ids.go``, ``CommitText``).
|
|
6
|
+
|
|
7
|
+
``price_floor`` opens the task authority's ``bounds_commitment`` with the
|
|
8
|
+
commercial-bounds document as the text; ``promise_never`` opens a represent
|
|
9
|
+
check's ``representing.text_commitment`` with the words it is about to say.
|
|
10
|
+
Nothing an opening holds is read until it opens the sealed commitment.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from agent_action_capsule.canonical import json_digest
|
|
17
|
+
|
|
18
|
+
__all__ = ["opens_commitment", "text_commitment"]
|
|
19
|
+
|
|
20
|
+
_HEX64 = re.compile(r"[0-9a-f]{64}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def text_commitment(nonce: str, text: str) -> str:
|
|
24
|
+
"""The commitment to ``text`` under ``nonce``."""
|
|
25
|
+
return json_digest({"nonce": nonce, "text": text})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def opens_commitment(nonce: object, text: object, sealed: object) -> bool:
|
|
29
|
+
"""Whether ``nonce`` and ``text``, decoded JSON from a checker input,
|
|
30
|
+
open ``sealed``: a nonce of 64 lowercase hex, a string text, a sealed
|
|
31
|
+
commitment of 64 lowercase hex, and the commitment they recompute equal
|
|
32
|
+
to it."""
|
|
33
|
+
if not isinstance(nonce, str) or not _HEX64.fullmatch(nonce) or not isinstance(text, str):
|
|
34
|
+
return False
|
|
35
|
+
if not isinstance(sealed, str) or not _HEX64.fullmatch(sealed):
|
|
36
|
+
return False
|
|
37
|
+
try:
|
|
38
|
+
return text_commitment(nonce, text) == sealed
|
|
39
|
+
# A text holding a lone surrogate has no JCS bytes, so it opens nothing.
|
|
40
|
+
except UnicodeEncodeError:
|
|
41
|
+
return False
|