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,1677 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Replay a JSONL capsule ledger through T3's ``GuardEngine`` in dry-run mode.
|
|
3
|
+
|
|
4
|
+
Every decision here comes from actually running ``GuardEngine.check(...,
|
|
5
|
+
dry_run=True)`` over the real fixture/ledger records -- this module never
|
|
6
|
+
fabricates an outcome. The guard's own replay-local view (an ephemeral
|
|
7
|
+
``LedgerStore``) accumulates the decision capsules this replay itself
|
|
8
|
+
produces, so a dedupe hit fires the same way it would in ``test_guard_dry_run.py``:
|
|
9
|
+
against a *prior decision in this same replay*, not against the source
|
|
10
|
+
ledger's original capsules (which the guard never produced and has no
|
|
11
|
+
decision-history relationship to).
|
|
12
|
+
|
|
13
|
+
``_bridge_deal_check`` builds the action of a capsulectl deal check from the
|
|
14
|
+
check's own sealed record, disclosed beside its capsule in a capsulectl deal
|
|
15
|
+
bundle: the record names the action's taxonomy class (``action_class``,
|
|
16
|
+
``taxonomy_version``) and the amount a spend cap evaluates (``spend_minor``),
|
|
17
|
+
and the capsule binds the record by digest
|
|
18
|
+
(``model_attestation.compute_attestation.agent_input_digest``). The
|
|
19
|
+
counterparty's keyed fingerprints the record seals (``x-deal-v0.counterparty``
|
|
20
|
+
beside its body, or a typed record's ``body.counterparty``) are carried as
|
|
21
|
+
they are, never a clear value. A record
|
|
22
|
+
that does not match that digest is never read.
|
|
23
|
+
|
|
24
|
+
The action's target is the payee's per-deal fingerprint unless the payee is
|
|
25
|
+
also keyed per profile (``hmac-sha256-profile-key``, one value for one merchant
|
|
26
|
+
across a profile's deals): live, from ``record.counterparty_profile`` in an
|
|
27
|
+
external-check-input/v0 envelope (``action_for_check_input``); in a replay,
|
|
28
|
+
from the check's companion, a bound ``counterparty_profile`` record whose one
|
|
29
|
+
``about`` ref is the check's record digest. The companion states no act. A
|
|
30
|
+
block that is not ``{fp_alg: "hmac-sha256-profile-key", ids: {payee: <64
|
|
31
|
+
lowercase hex>}}`` is ignored, named in ``Action.ignored_inputs``, and the
|
|
32
|
+
per-deal target stands. A decision sealed with the per-deal target is never
|
|
33
|
+
rewritten, so it never matches one keyed per profile.
|
|
34
|
+
|
|
35
|
+
An act record seals no counterparty, so a live check's history act takes the
|
|
36
|
+
target its check got: capsulectl gives the block that check sealed beside the
|
|
37
|
+
act (``counterparty``), and it is read as the check's own block is, the
|
|
38
|
+
profile-keyed payee still replacing it. A block that is not a check's
|
|
39
|
+
(``_check_counterparty``) is ignored and named in ``Action.ignored_inputs``.
|
|
40
|
+
|
|
41
|
+
On a check of a sale's thread, the envelope's top-level ``item_ref`` (256
|
|
42
|
+
random bits, lowercase hex, equal across the sale's threads) becomes
|
|
43
|
+
``Action.item_ref`` for ``single_commitment``. No deal record carries it, so
|
|
44
|
+
it is read only live, never from a deal record in a replay. A value in any
|
|
45
|
+
other shape is ignored and named in ``Action.ignored_inputs``. A live check
|
|
46
|
+
reads its history's acts through ``action_for_history_entry``
|
|
47
|
+
(``report/live_history.py``). A replay of a sale bundle keys the sale on
|
|
48
|
+
the digest of the sale's own task authority instead, and gives
|
|
49
|
+
``single_commitment`` each check's history of the sale's threads, read by the
|
|
50
|
+
reader a live check's history is read by (``report/sale_bundle.py``).
|
|
51
|
+
``single_commitment`` reads it whole; a ``release_on_acceptance`` that binds
|
|
52
|
+
the acceptance from the history (1.2.0) reads it cut to the live window
|
|
53
|
+
(``RULES_HISTORY_DAYS``). A cited ``release_on_acceptance`` (1.0.0, 1.1.0)
|
|
54
|
+
still reads the replay's own view, where no acceptance is sealed, so it
|
|
55
|
+
releases nothing. Outside a sale replay (a dry-run report of a deal's own
|
|
56
|
+
records, say) a 1.2.0 ``release_on_acceptance`` reads the replay's own view
|
|
57
|
+
too: its records carry no ``thread_counterparty``, so every share that needs
|
|
58
|
+
the acceptance refuses there, which fails closed. A sale replay is as close
|
|
59
|
+
to live as a bundle of one sale can get, not exact: a live history is
|
|
60
|
+
profile-wide and capped at 1,000 acts, and past the cap it reads
|
|
61
|
+
incomplete, so the acceptance is unknown. A share refused live for a
|
|
62
|
+
truncated history may therefore pass on replay of a complete bundle.
|
|
63
|
+
|
|
64
|
+
``_bridge_transfer_funds`` is the other non-default action mapping, and it
|
|
65
|
+
mirrors the pattern already established by ``tests/test_guard_dry_run.py``
|
|
66
|
+
and ``tests/test_guard_eur150k_bridge.py``: ``transfer_funds`` capsules in
|
|
67
|
+
these fixtures carry their amount only as free text inside
|
|
68
|
+
``model_attestation.compute_attestation.note`` (a field this schema version
|
|
69
|
+
has no structured place for), so the bridge regexes the real embedded
|
|
70
|
+
values out of that note rather than hardcoding them.
|
|
71
|
+
"""
|
|
72
|
+
from __future__ import annotations
|
|
73
|
+
|
|
74
|
+
import json
|
|
75
|
+
import re
|
|
76
|
+
import tempfile
|
|
77
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
78
|
+
from dataclasses import dataclass, replace
|
|
79
|
+
from datetime import datetime, timedelta
|
|
80
|
+
from pathlib import Path
|
|
81
|
+
from typing import TypedDict
|
|
82
|
+
|
|
83
|
+
from agent_action_capsule import json_digest
|
|
84
|
+
from agent_action_capsule.canonical import FloatInDigestError, UnsafeIntegerError
|
|
85
|
+
from cll.ledger import LedgerStore
|
|
86
|
+
from cll.ledger.api import LedgerAPI
|
|
87
|
+
|
|
88
|
+
from ..folds.definition import FoldDefinition
|
|
89
|
+
from ..folds.duration import parse_duration_seconds
|
|
90
|
+
from ..guards import ALLOW, ESCALATE, Action, GuardDecision, GuardEngine, LocalSigner
|
|
91
|
+
from ..guards.action import disclosed_classes_of, handover_of, listing_classes_of, repeat_disclosures_of
|
|
92
|
+
from ..guards.checks.caps import EXECUTED_DECISION, SPEND_UNREADABLE, counts_executed_acts
|
|
93
|
+
from ..guards.checks.commitment import text_commitment
|
|
94
|
+
from ..guards.checks.promise_never import ATTRIBUTED_TO_USER, WORDS_NOT_IN_BUNDLE
|
|
95
|
+
from ..guards.history_state import CHECK_FP_ALGS, RULES_HISTORY_DAYS, well_formed_counterparty
|
|
96
|
+
from ..guards.statements import StatementRecord, claim_of, make_statement_record
|
|
97
|
+
from ..guards.wickets.definition import WicketDefinition
|
|
98
|
+
from ..packs.install import engine_ask_sets
|
|
99
|
+
from ..packs.schema import PackDefinition
|
|
100
|
+
|
|
101
|
+
__all__ = [
|
|
102
|
+
"MONEY_IN",
|
|
103
|
+
"ExecutedActs",
|
|
104
|
+
"bound_agent_input",
|
|
105
|
+
"SourcedDecision",
|
|
106
|
+
"ReplayResult",
|
|
107
|
+
"SaleCheck",
|
|
108
|
+
"SaleHistory",
|
|
109
|
+
"load_records",
|
|
110
|
+
"load_disclosed",
|
|
111
|
+
"load_representing_openings",
|
|
112
|
+
"representing_openings",
|
|
113
|
+
"load_withheld",
|
|
114
|
+
"filter_since",
|
|
115
|
+
"action_for_record",
|
|
116
|
+
"action_for_check_input",
|
|
117
|
+
"action_for_history_entry",
|
|
118
|
+
"deal_claim_statements",
|
|
119
|
+
"names_two_deals",
|
|
120
|
+
"replay",
|
|
121
|
+
"statement_for_history_entry",
|
|
122
|
+
"statement_record",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
_TRANSFER_NOTE_RE = re.compile(r"amount_eur:\s*(\d+).*?target_iban:\s*([A-Z0-9]+)")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def load_records(paths: Sequence[str | Path]) -> list[dict]:
|
|
129
|
+
"""Load every record from one or more sources, in the given order.
|
|
130
|
+
|
|
131
|
+
Each source is either a plain JSONL ledger file (read in file-internal
|
|
132
|
+
line order), a ``LedgerStore`` directory (read via ``scan()``, its own
|
|
133
|
+
append order) -- the CLI's real-deployment path, since an operator's
|
|
134
|
+
actual local ledger is a store directory, not a loose JSONL file -- or a
|
|
135
|
+
capsule bundle (one JSON object with ``records`` and ``disclosures``, as
|
|
136
|
+
``capsulectl bundle --deal`` writes), read in its record order.
|
|
137
|
+
"""
|
|
138
|
+
records: list[dict] = []
|
|
139
|
+
for path in paths:
|
|
140
|
+
p = Path(path)
|
|
141
|
+
bundle = _bundle(p)
|
|
142
|
+
if bundle is not None:
|
|
143
|
+
records.extend(bundle["records"])
|
|
144
|
+
elif p.is_dir():
|
|
145
|
+
store = LedgerStore(p)
|
|
146
|
+
try:
|
|
147
|
+
for rec in store.scan():
|
|
148
|
+
records.append(rec.capsule)
|
|
149
|
+
finally:
|
|
150
|
+
store.close()
|
|
151
|
+
else:
|
|
152
|
+
with p.open(encoding="utf-8") as fh:
|
|
153
|
+
for line in fh:
|
|
154
|
+
line = line.strip()
|
|
155
|
+
if line:
|
|
156
|
+
records.append(json.loads(line))
|
|
157
|
+
return records
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _bundle(path: Path) -> dict | None:
|
|
161
|
+
"""``path``'s capsule bundle, or ``None`` when it is not one."""
|
|
162
|
+
if path.is_dir():
|
|
163
|
+
return None
|
|
164
|
+
try:
|
|
165
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
166
|
+
except ValueError:
|
|
167
|
+
return None
|
|
168
|
+
if isinstance(value, dict) and isinstance(value.get("records"), list) and isinstance(value.get("disclosures"), dict):
|
|
169
|
+
return value
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def load_disclosed(paths: Sequence[str | Path]) -> dict[str, dict]:
|
|
174
|
+
"""The records a capsule bundle discloses beside its capsules, by
|
|
175
|
+
``capsule_id``: each one's ``agent_input``. Sources that are not bundles
|
|
176
|
+
disclose nothing."""
|
|
177
|
+
disclosed: dict[str, dict] = {}
|
|
178
|
+
for path in paths:
|
|
179
|
+
bundle = _bundle(Path(path))
|
|
180
|
+
if bundle is None:
|
|
181
|
+
continue
|
|
182
|
+
for capsule_id, members in bundle["disclosures"].items():
|
|
183
|
+
agent_input = members.get("agent_input") if isinstance(members, dict) else None
|
|
184
|
+
if isinstance(agent_input, dict):
|
|
185
|
+
disclosed[capsule_id] = agent_input
|
|
186
|
+
return disclosed
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# Where capsulectl puts a deal's report in a bundle (capsule-cli
|
|
190
|
+
# internal/cli/deal_sealed_report.go, DealSealedReport): inline as the
|
|
191
|
+
# x-deal-v0 extension itself, standalone in the x-deal-report/v0 extension,
|
|
192
|
+
# or in the log as the record the extension's sealed_report names.
|
|
193
|
+
_DEAL_EXTENSION = "x-deal-v0"
|
|
194
|
+
_REPORT_EXTENSION = "x-deal-report/v0"
|
|
195
|
+
_REPORT_POINTER = "sealed_report"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _bundle_reports(bundle: dict) -> list[dict]:
|
|
199
|
+
"""Every deal report ``bundle`` carries, in any of capsulectl's three
|
|
200
|
+
forms; nothing about them is trusted here (``representing_openings``)."""
|
|
201
|
+
extensions = bundle.get("extensions") if isinstance(bundle.get("extensions"), dict) else {}
|
|
202
|
+
reports: list[object] = []
|
|
203
|
+
deal = extensions.get(_DEAL_EXTENSION)
|
|
204
|
+
if isinstance(deal, dict):
|
|
205
|
+
reports.append(deal)
|
|
206
|
+
pointer = deal.get(_REPORT_POINTER)
|
|
207
|
+
disclosures = bundle.get("disclosures") if isinstance(bundle.get("disclosures"), dict) else {}
|
|
208
|
+
shown = disclosures.get(pointer) if isinstance(pointer, str) else None
|
|
209
|
+
agent_input = shown.get("agent_input") if isinstance(shown, dict) else None
|
|
210
|
+
if isinstance(agent_input, dict):
|
|
211
|
+
reports.append(agent_input.get("report"))
|
|
212
|
+
standalone = extensions.get(_REPORT_EXTENSION)
|
|
213
|
+
agent_input = standalone.get("agent_input") if isinstance(standalone, dict) else None
|
|
214
|
+
if isinstance(agent_input, dict):
|
|
215
|
+
reports.append(agent_input.get("report"))
|
|
216
|
+
return [r for r in reports if isinstance(r, dict)]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def representing_openings(bundle: object) -> dict[str, dict]:
|
|
220
|
+
"""The openings of the agent's statements a bundle's deal report carries
|
|
221
|
+
(its ``representations``, each ``{nonce, text}``), by the commitment each
|
|
222
|
+
recomputes to (``commitment.text_commitment``). An opening proves itself:
|
|
223
|
+
the words that recompute a represent check's sealed ``text_commitment``
|
|
224
|
+
are the words that check sealed, so the report's own binding is not
|
|
225
|
+
needed to read them (capsulectl seals a claim under its check's nonce,
|
|
226
|
+
so a claim's opening is its check's). An entry whose nonce is not 64
|
|
227
|
+
lowercase hex or whose text is not a string, or that has no JCS bytes,
|
|
228
|
+
is skipped."""
|
|
229
|
+
openings: dict[str, dict] = {}
|
|
230
|
+
if not isinstance(bundle, dict):
|
|
231
|
+
return openings
|
|
232
|
+
for report in _bundle_reports(bundle):
|
|
233
|
+
said = report.get("representations")
|
|
234
|
+
for entry in said if isinstance(said, list) else []:
|
|
235
|
+
nonce, text = (entry.get("nonce"), entry.get("text")) if isinstance(entry, dict) else (None, None)
|
|
236
|
+
if not isinstance(nonce, str) or not _HEX64.fullmatch(nonce) or not isinstance(text, str):
|
|
237
|
+
continue
|
|
238
|
+
try:
|
|
239
|
+
openings.setdefault(text_commitment(nonce, text), {"nonce": nonce, "text": text})
|
|
240
|
+
except UnicodeEncodeError:
|
|
241
|
+
continue
|
|
242
|
+
return openings
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def load_representing_openings(paths: Sequence[str | Path]) -> dict[str, dict]:
|
|
246
|
+
"""``representing_openings`` of every capsule bundle among ``paths``."""
|
|
247
|
+
openings: dict[str, dict] = {}
|
|
248
|
+
for path in paths:
|
|
249
|
+
for commitment, opening in representing_openings(_bundle(Path(path))).items():
|
|
250
|
+
openings.setdefault(commitment, opening)
|
|
251
|
+
return openings
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def load_withheld(paths: Sequence[str | Path]) -> frozenset[str]:
|
|
255
|
+
"""The ``capsule_id`` of every record a capsule bundle among ``paths``
|
|
256
|
+
carries without disclosing its ``agent_input``, and no other source
|
|
257
|
+
discloses. Plain ledgers and store directories withhold nothing."""
|
|
258
|
+
carried: set[str] = set()
|
|
259
|
+
for path in paths:
|
|
260
|
+
bundle = _bundle(Path(path))
|
|
261
|
+
if bundle is not None:
|
|
262
|
+
carried.update(r["capsule_id"] for r in bundle["records"] if isinstance(r.get("capsule_id"), str))
|
|
263
|
+
return frozenset(carried - load_disclosed(paths).keys())
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _parse_ts(ts: str) -> datetime:
|
|
267
|
+
text = ts[:-1] + "+00:00" if ts.endswith("Z") else ts
|
|
268
|
+
return datetime.fromisoformat(text)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def filter_since(records: list[dict], since: str | None) -> list[dict]:
|
|
272
|
+
"""Keep only records within ``since`` (e.g. ``"7d"``) of the *latest*
|
|
273
|
+
timestamp in the set -- anchored to real ledger data, never the system
|
|
274
|
+
wall clock (same determinism principle as the fold engine's own rolling
|
|
275
|
+
windows: ``folds/engine.py`` refuses to invent an anchor from
|
|
276
|
+
``datetime.now()``)."""
|
|
277
|
+
if since is None:
|
|
278
|
+
return list(records)
|
|
279
|
+
seconds = parse_duration_seconds(since)
|
|
280
|
+
timestamps = [r["timestamp"] for r in records if r.get("timestamp")]
|
|
281
|
+
if not timestamps:
|
|
282
|
+
return list(records)
|
|
283
|
+
anchor = max(_parse_ts(t) for t in timestamps)
|
|
284
|
+
cutoff = anchor - timedelta(seconds=seconds)
|
|
285
|
+
return [r for r in records if r.get("timestamp") and _parse_ts(r["timestamp"]) >= cutoff]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _bridge_transfer_funds(record: dict) -> Action | None:
|
|
289
|
+
action_id = record.get("action_id") or ""
|
|
290
|
+
verb = action_id.split("/", 1)[0] if action_id else ""
|
|
291
|
+
if verb != "transfer_funds":
|
|
292
|
+
return None
|
|
293
|
+
note = ((record.get("model_attestation") or {}).get("compute_attestation") or {}).get("note", "")
|
|
294
|
+
match = _TRANSFER_NOTE_RE.search(note)
|
|
295
|
+
if not match:
|
|
296
|
+
return None
|
|
297
|
+
amount_eur, target_iban = match.groups()
|
|
298
|
+
return Action(
|
|
299
|
+
verb=verb,
|
|
300
|
+
operator=record.get("operator", ""),
|
|
301
|
+
developer=record.get("developer", ""),
|
|
302
|
+
action_class="money.transfer",
|
|
303
|
+
amount_minor=int(amount_eur) * 100,
|
|
304
|
+
currency="EUR",
|
|
305
|
+
target=target_iban,
|
|
306
|
+
timestamp=record.get("timestamp"),
|
|
307
|
+
action_id=action_id or None,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _bound(record: dict, disclosed: dict) -> bool:
|
|
312
|
+
"""Whether ``disclosed`` is the record ``record``'s capsule sealed."""
|
|
313
|
+
attestation = (record.get("model_attestation") or {}).get("compute_attestation") or {}
|
|
314
|
+
bound = attestation.get("agent_input_digest")
|
|
315
|
+
return isinstance(bound, str) and json_digest(disclosed) == bound
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _checked_body(disclosed: dict, *, history: bool = False) -> tuple[dict, object] | None:
|
|
319
|
+
"""The body of a deal check (an ``x-deal-v0`` check record, or a typed
|
|
320
|
+
``proposed-action/v0``) and the counterparty block it seals, or ``None``
|
|
321
|
+
for any other record. An ``x-deal-v0`` check seals the counterparty in its
|
|
322
|
+
block; capsulectl moves it into a typed record's body, the same
|
|
323
|
+
fingerprints under ``fp_alg`` ``hmac-sha256-chain-key``. With ``history``,
|
|
324
|
+
an act carried out is read the same way, in either shape a check input's
|
|
325
|
+
history states one: a typed act record (``action-record/v0``), or an
|
|
326
|
+
``x-deal-v0`` action step, which seals the counterparty in its block as a
|
|
327
|
+
check does. An action step whose body states an act taken without a check
|
|
328
|
+
(``body.unchecked``) is not read: it was never authorized."""
|
|
329
|
+
block = disclosed.get("x-deal-v0")
|
|
330
|
+
body = disclosed.get("body")
|
|
331
|
+
record_type = block.get("record_type") if isinstance(block, dict) else None
|
|
332
|
+
if record_type == "check" or (history and _is_action_step(disclosed) and not _taken_unchecked(body)):
|
|
333
|
+
counterparty = block.get("counterparty")
|
|
334
|
+
elif disclosed.get("type") == "proposed-action/v0" or (history and disclosed.get("type") == _ACT_RECORD_TYPE):
|
|
335
|
+
counterparty = body.get("counterparty") if isinstance(body, dict) else None
|
|
336
|
+
else:
|
|
337
|
+
return None
|
|
338
|
+
return (body, counterparty) if isinstance(body, dict) else None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# The sealed rule input on an offer that answers no proposal (capsule-cli
|
|
342
|
+
# deal_proposal.go), and the ref an offer names the message it answers by.
|
|
343
|
+
_NO_PROPOSAL = "no_proposal"
|
|
344
|
+
_SOURCE = "source"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _is_check(disclosed: dict) -> bool:
|
|
348
|
+
"""Whether ``disclosed`` is a deal check: an ``x-deal-v0`` check record
|
|
349
|
+
or a typed ``proposed-action/v0``."""
|
|
350
|
+
block = disclosed.get("x-deal-v0")
|
|
351
|
+
if isinstance(block, dict):
|
|
352
|
+
return block.get("record_type") == "check"
|
|
353
|
+
return disclosed.get("type") == _PROPOSED_TYPE
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _refs(disclosed: dict) -> list:
|
|
357
|
+
"""A deal record's refs: an ``x-deal-v0`` record's block refs, else a
|
|
358
|
+
typed record's own; ``[]`` when it has none in a list."""
|
|
359
|
+
block = disclosed.get("x-deal-v0")
|
|
360
|
+
refs = block.get("refs") if isinstance(block, dict) else disclosed.get("refs")
|
|
361
|
+
return refs if isinstance(refs, list) else []
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _names_source(disclosed: dict) -> bool:
|
|
365
|
+
"""Whether a deal record names any ``source`` ref, in whatever shape:
|
|
366
|
+
a check that does names a proposal it answers, resolved or not."""
|
|
367
|
+
return any(isinstance(r, dict) and r.get("rel") == _SOURCE for r in _refs(disclosed))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _no_proposal(disclosed: dict, body: dict) -> bool | None:
|
|
371
|
+
"""``True`` when ``disclosed`` is a check whose body seals
|
|
372
|
+
``no_proposal: true`` and which names no ``source`` ref; else ``None``. A
|
|
373
|
+
check naming both says it answers a message, so it is decided on that
|
|
374
|
+
message's age (``_accepted_offer_at``), or fails as missing one: never
|
|
375
|
+
as answering no proposal."""
|
|
376
|
+
if _is_check(disclosed) and body.get(_NO_PROPOSAL) is True and not _names_source(disclosed):
|
|
377
|
+
return True
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _is_action_step(disclosed: dict) -> bool:
|
|
382
|
+
"""Whether ``disclosed`` is an ``x-deal-v0`` action step: an act carried
|
|
383
|
+
out, as a deal seals it."""
|
|
384
|
+
block = disclosed.get("x-deal-v0")
|
|
385
|
+
return isinstance(block, dict) and block.get("record_type") == _ACTION_STEP
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _taken_unchecked(body: object) -> bool:
|
|
389
|
+
"""Whether a deal record's ``body`` states an act taken without a check
|
|
390
|
+
or without authority (``_taken``)."""
|
|
391
|
+
return isinstance(body, dict) and (_UNCHECKED in body or _ATTEMPTED in body)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _minor(value: object) -> int | None:
|
|
395
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _sealed_counterparty(block: object) -> tuple[dict[str, str] | None, str | None]:
|
|
399
|
+
"""The fingerprints (kind -> hex) in a sealed counterparty block
|
|
400
|
+
(``_checked_body``) and their ``fp_alg``, or ``(None, None)`` when it
|
|
401
|
+
holds none usable."""
|
|
402
|
+
if not isinstance(block, dict):
|
|
403
|
+
return None, None
|
|
404
|
+
ids, fp_alg = block.get("ids"), block.get("fp_alg")
|
|
405
|
+
if not isinstance(ids, dict) or not isinstance(fp_alg, str) or not fp_alg:
|
|
406
|
+
return None, None
|
|
407
|
+
usable = {k: v for k, v in ids.items() if isinstance(k, str) and isinstance(v, str) and v}
|
|
408
|
+
return (usable, fp_alg) if usable else (None, None)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _text(value: object) -> str | None:
|
|
415
|
+
return value if isinstance(value, str) and value else None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# A represent check (capsule-cli deal_representations.go), and the class the
|
|
419
|
+
# bridge gives one whose sealed class is not a non-empty string: no
|
|
420
|
+
# representation class, so promise_requires_approval and promise_never both
|
|
421
|
+
# fail it as not recognised. A closed token, never a stated value.
|
|
422
|
+
_REPRESENT = "represent"
|
|
423
|
+
MALFORMED_REPRESENTATION_CLASS = "malformed_representing"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _attributed_to(value: object) -> str | None:
|
|
427
|
+
"""A statement's attribution in its closed set (``user``), or ``None``:
|
|
428
|
+
any other value is read as the agent's own statement, which s10 refuses,
|
|
429
|
+
and is never sealed."""
|
|
430
|
+
return ATTRIBUTED_TO_USER if value == ATTRIBUTED_TO_USER else None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# The commitment the bridge gives a represent check whose sealed
|
|
434
|
+
# ``text_commitment`` is present but out of shape: not 64 lowercase hex, so
|
|
435
|
+
# ``promise_never`` fails it, never reading it as absent.
|
|
436
|
+
MALFORMED_TEXT_COMMITMENT = "malformed_text_commitment"
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _text_commitment(representing: dict) -> str | None:
|
|
440
|
+
"""A represent check's sealed ``text_commitment``: the digest when it is
|
|
441
|
+
64 lowercase hex, ``None`` when it is absent, and
|
|
442
|
+
``MALFORMED_TEXT_COMMITMENT`` otherwise."""
|
|
443
|
+
if "text_commitment" not in representing:
|
|
444
|
+
return None
|
|
445
|
+
return _hex64(representing["text_commitment"]) or MALFORMED_TEXT_COMMITMENT
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _hex64(value: object) -> str | None:
|
|
449
|
+
return value if isinstance(value, str) and _HEX64.fullmatch(value) else None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _flag(value: object) -> bool | None:
|
|
453
|
+
return value if isinstance(value, bool) else None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _handover(body: dict) -> str | None:
|
|
457
|
+
"""How the item reaches the buyer, as the check's ``terms.handover``
|
|
458
|
+
states it: carried only as a member of ``HANDOVERS`` (``handover_of``), and dropped
|
|
459
|
+
otherwise (a rail allowed only in person then fails, as with none)."""
|
|
460
|
+
terms = body.get("terms")
|
|
461
|
+
return handover_of(terms.get("handover")) if isinstance(terms, dict) else None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _payee_target(counterparty_ids: dict[str, str] | None, fp_alg: str | None) -> str | None:
|
|
465
|
+
"""The payee's sealed fingerprint as an opaque target,
|
|
466
|
+
``payee-fp:<fp_alg>:<hex>``. Prefixed so it never compares equal to a
|
|
467
|
+
clear reference or to a fingerprint made by another algorithm; ``None``
|
|
468
|
+
when the record seals no payee fingerprint."""
|
|
469
|
+
payee = (counterparty_ids or {}).get("payee")
|
|
470
|
+
return f"payee-fp:{fp_alg}:{payee}" if payee and fp_alg else None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# The profile-scoped payee block's name, in the checker input and on the
|
|
474
|
+
# companion record, and the fp_alg it must carry.
|
|
475
|
+
_PROFILE_INPUT = "counterparty_profile"
|
|
476
|
+
_PROFILE_FP_ALG = "hmac-sha256-profile-key"
|
|
477
|
+
# The sale's item reference: top level in the checker input for the checked
|
|
478
|
+
# record, and on each history entry for that act (AMENDMENT 6a).
|
|
479
|
+
_ITEM_INPUT = "item_ref"
|
|
480
|
+
# The time of the accepted offer a seller's commit rests on, on the record
|
|
481
|
+
# entry beside its capsule (AMENDMENT 7): the offer record's sealed ``at``.
|
|
482
|
+
_PROPOSAL_INPUT = "proposal_at"
|
|
483
|
+
# Who a history act was with, on each history entry beside its capsule: the
|
|
484
|
+
# counterparty block the act's check sealed, exactly as sealed, and the
|
|
485
|
+
# fp_algs a check seals it under (an x-deal-v0 check's, a typed check's).
|
|
486
|
+
_COUNTERPARTY_INPUT = "counterparty"
|
|
487
|
+
# The deal's earlier claims the user's agent made, on the checked record entry
|
|
488
|
+
# beside its capsule (AMENDMENT 9): each the sealed claim record's own values.
|
|
489
|
+
_CLAIMS_INPUT = "deal_claims"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _profile_target(block: object) -> str | None:
|
|
493
|
+
"""The payee keyed per profile as a target, from a ``counterparty_profile``
|
|
494
|
+
block in its agreed shape, or ``None`` for anything else."""
|
|
495
|
+
if not isinstance(block, dict) or block.get("fp_alg") != _PROFILE_FP_ALG:
|
|
496
|
+
return None
|
|
497
|
+
ids = block.get("ids")
|
|
498
|
+
payee = ids.get("payee") if isinstance(ids, dict) else None
|
|
499
|
+
if not isinstance(payee, str) or not _HEX64.fullmatch(payee):
|
|
500
|
+
return None
|
|
501
|
+
return _payee_target({"payee": payee}, _PROFILE_FP_ALG)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _check_counterparty(block: object) -> bool:
|
|
505
|
+
"""Whether ``block`` is a check's counterparty block in its agreed shape,
|
|
506
|
+
``{fp_alg: <a check's fp_alg>, ids: {<kind>: <64 lowercase hex>}}``, so
|
|
507
|
+
``_sealed_counterparty`` may read it as the check's own.
|
|
508
|
+
|
|
509
|
+
Looser than ``history_state.well_formed_counterparty`` on purpose: it
|
|
510
|
+
decides only whether the act gets its check's target, as it has since
|
|
511
|
+
the act counterparty was added, so it does not check the kinds or refuse
|
|
512
|
+
an empty block, and tightening it would move other checks' decisions.
|
|
513
|
+
``release_on_acceptance`` 1.2.0 reads the strict rule through
|
|
514
|
+
``Action.counterparty_malformed``."""
|
|
515
|
+
if not isinstance(block, dict) or block.get("fp_alg") not in CHECK_FP_ALGS:
|
|
516
|
+
return False
|
|
517
|
+
ids = block.get("ids")
|
|
518
|
+
return isinstance(ids, dict) and all(isinstance(v, str) and _HEX64.fullmatch(v) for v in ids.values())
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _typed_ref_digest(value: object) -> str | None:
|
|
522
|
+
"""The digest of a typed record reference ``{type, digest_alg, digest}``
|
|
523
|
+
when it is a SHA-256 one, or ``None``."""
|
|
524
|
+
if not isinstance(value, dict) or not _text(value.get("type")) or value.get("digest_alg") != "SHA-256":
|
|
525
|
+
return None
|
|
526
|
+
digest = value.get("digest")
|
|
527
|
+
return digest if isinstance(digest, str) and _HEX64.fullmatch(digest) else None
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# The typed record a check input's history carries for each act carried out
|
|
531
|
+
# (capsule-cli internal/cli/rules_checker.go, rulesHistory), and the
|
|
532
|
+
# ``authority_basis`` entry type that names the task authority it was taken
|
|
533
|
+
# under (action_records.go): an act record has no ``task_authority_ref``.
|
|
534
|
+
_ACT_RECORD_TYPE = "action-record/v0"
|
|
535
|
+
_TASK_AUTHORITY = "task-authority/v0"
|
|
536
|
+
_TASK_AUTHORITY_BASIS = "task_authority"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _basis_task_authority(body: dict) -> str | None:
|
|
540
|
+
"""The digest of the task authority an act record's ``authority_basis``
|
|
541
|
+
names: the ``ref`` of its one entry of type ``task_authority``. ``None``
|
|
542
|
+
when it has none, more than one, or a ref that is not a SHA-256 typed
|
|
543
|
+
reference."""
|
|
544
|
+
basis = body.get("authority_basis")
|
|
545
|
+
entries = [e for e in basis if isinstance(e, dict) and e.get("type") == _TASK_AUTHORITY_BASIS] if isinstance(
|
|
546
|
+
basis, list) else []
|
|
547
|
+
return _typed_ref_digest(entries[0].get("ref")) if len(entries) == 1 else None
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# The ``direction`` a deal check states when the money moves to the user: a
|
|
551
|
+
# refund, or a partial cancel. Such an act is never spend: the bridge carries
|
|
552
|
+
# its spend as ``0`` whatever ``spend_minor`` says, so it adds nothing to the
|
|
553
|
+
# rolling total and cannot raise it past a cap. So an act stated in this
|
|
554
|
+
# direction is never a reason the spend window cannot be computed, decided or not.
|
|
555
|
+
MONEY_IN = "in"
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# The body fields that state the amount a money-in record returns, first
|
|
559
|
+
# present wins: the producer's explicit ``returned_minor``, then a refund's
|
|
560
|
+
# ``amount_minor``, then a partial cancel's ``cancelled_amount_minor``.
|
|
561
|
+
_RETURNED_AMOUNT_FIELDS = ("returned_minor", "amount_minor", "cancelled_amount_minor")
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _returned_minor(body: dict) -> int | None:
|
|
565
|
+
"""The amount a money-in check returns (``_RETURNED_AMOUNT_FIELDS``)."""
|
|
566
|
+
return next((v for v in (_minor(body.get(f)) for f in _RETURNED_AMOUNT_FIELDS) if v is not None), None)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# Where a deal record names its deal: an ``x-deal-v0`` record in its block's
|
|
570
|
+
# ``deal_id``, a typed record in its top-level ``chain_id``, which capsulectl
|
|
571
|
+
# sets to exactly that value. Read only through ``_deal_id``.
|
|
572
|
+
_DEAL_ID_FIELDS = ("x-deal-v0.deal_id", "chain_id")
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@dataclass(frozen=True)
|
|
576
|
+
class _DealId:
|
|
577
|
+
"""The deal a record names, or ``None``; ``conflict`` when it names two."""
|
|
578
|
+
|
|
579
|
+
value: str | None
|
|
580
|
+
conflict: bool = False
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _deal_id(shown: dict) -> _DealId:
|
|
584
|
+
"""The deal ``shown`` is in: ``x-deal-v0.deal_id`` when the record states
|
|
585
|
+
one, else its typed ``chain_id``. A record stating both, and not the same
|
|
586
|
+
value, names no deal and is a conflict: neither is picked. A stated value
|
|
587
|
+
that is not a non-empty string names no deal."""
|
|
588
|
+
block = shown.get("x-deal-v0")
|
|
589
|
+
stated = isinstance(block, dict) and "deal_id" in block
|
|
590
|
+
chained = "chain_id" in shown
|
|
591
|
+
if stated and chained and block["deal_id"] != shown["chain_id"]:
|
|
592
|
+
return _DealId(value=None, conflict=True)
|
|
593
|
+
return _DealId(value=_text(block["deal_id"]) if stated else _text(shown.get("chain_id")))
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def names_two_deals(shown: dict) -> bool:
|
|
597
|
+
"""Whether the record ``shown`` names two deals (``_deal_id``), so it is
|
|
598
|
+
in none."""
|
|
599
|
+
return _deal_id(shown).conflict
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _bridge_deal_check(
|
|
603
|
+
record: dict,
|
|
604
|
+
disclosed: dict | None,
|
|
605
|
+
counterparty_profile: object = None,
|
|
606
|
+
*,
|
|
607
|
+
history: bool = False,
|
|
608
|
+
act_counterparty: object = None,
|
|
609
|
+
) -> Action | None:
|
|
610
|
+
"""The proposed action a capsulectl deal check states, from its own
|
|
611
|
+
sealed record: the class it names, and the amount a spend cap evaluates,
|
|
612
|
+
which is ``spend_minor`` only: never ``amount_minor`` (on a cancel it is
|
|
613
|
+
a refund) or ``cancelled_amount_minor``, and ``0`` for a record that says
|
|
614
|
+
the money moved in. So a cancel is never spend. The amount the body
|
|
615
|
+
states (``amount_minor``) is carried apart, as ``stated_amount_minor``, for
|
|
616
|
+
``price_floor``: on a sale it is the price, while the spend is ``0``.
|
|
617
|
+
``spend_authorized_minor``,
|
|
618
|
+
the authorised maximum sealed beside it, is carried for a per-action cap
|
|
619
|
+
and dropped when the money moved in. When it moved in, the amount
|
|
620
|
+
returned (``_returned_minor``) and the SHA-256 digest of the reversed
|
|
621
|
+
act's record (the body's typed ``reverses_ref``) are carried for
|
|
622
|
+
``dedupe`` beside the spend of ``0``, and never otherwise.
|
|
623
|
+
Only a check is an action here: the step that acts on it is the same
|
|
624
|
+
payment, and counting both would count it twice.
|
|
625
|
+
The rail and ``refundable`` come from the body's ``recourse`` block, where
|
|
626
|
+
the producer writes them (a top-level ``rail`` is read when there is no
|
|
627
|
+
recourse rail). The handover comes from the body's ``terms`` block
|
|
628
|
+
(``_handover``), as a seller's commit or sale states it. The target is the payee's sealed fingerprint
|
|
629
|
+
(``_payee_target``) from the counterparty block the check seals
|
|
630
|
+
(``_checked_body``), typed or not, so a check sealing none has no target;
|
|
631
|
+
or ``counterparty_profile``'s when it is given in
|
|
632
|
+
its agreed shape (``_profile_target``); given in any other, it is named
|
|
633
|
+
in ``ignored_inputs``. The remaining body fields are carried only in the
|
|
634
|
+
shape the action takes: a string, a boolean, an integer, or the SHA-256
|
|
635
|
+
digest of a typed reference, and dropped otherwise. A share's
|
|
636
|
+
``disclosing``, the classes of data it gives, is carried as
|
|
637
|
+
``disclosed_classes`` only as a non-empty list of known class names
|
|
638
|
+
(``DISCLOSED_CLASSES``); with any other member it is dropped whole.
|
|
639
|
+
``repeat_disclosures`` is carried the same way, possibly empty, and
|
|
640
|
+
``recipient_told_before`` only as a boolean (capsule-engine#188); neither
|
|
641
|
+
is sealed on the decision. A sale thread's ``listing_classes`` (the
|
|
642
|
+
classes every live listing stated when the check was made) is carried as
|
|
643
|
+
one only as a list of class names, and dropped whole otherwise; it is read
|
|
644
|
+
here and never sealed on the decision.
|
|
645
|
+
A check's sealed ``no_proposal`` is carried only when it is ``true`` and
|
|
646
|
+
the check names no ``source`` ref (``_no_proposal``); it is never read
|
|
647
|
+
off an act a history entry records.
|
|
648
|
+
A represent check's ``representing`` gives the class of the statement
|
|
649
|
+
(``representation_class``), whose claim it is (``attributed_to``, as
|
|
650
|
+
``statement_attributed_to``, its closed set only) and the commitment to its words
|
|
651
|
+
(``text_commitment``, as ``representation_text_commitment``: the digest
|
|
652
|
+
when it is 64 lowercase hex, ``MALFORMED_TEXT_COMMITMENT`` when it is out
|
|
653
|
+
of shape, ``None`` when absent), so ``promise_requires_approval``
|
|
654
|
+
and ``promise_never`` decide it (capsule-engine#180).
|
|
655
|
+
``taxonomy_version`` makes it an act stated against a pinned taxonomy, so
|
|
656
|
+
``dedupe`` keys it on the act, never on this record's type or id. The
|
|
657
|
+
deal is the one the record names (``_deal_id``), never the ``action_id``
|
|
658
|
+
prefix. A record naming two deals carries none, and names both fields in
|
|
659
|
+
``deal_id_conflict``, so the engine refuses whatever would be allowed.
|
|
660
|
+
With ``history``, a typed act record is bridged too (``_checked_body``),
|
|
661
|
+
and its task authority is the one its ``authority_basis`` names
|
|
662
|
+
(``_basis_task_authority``). An act record seals no counterparty, so
|
|
663
|
+
``act_counterparty``, the block its check sealed, given beside it, is
|
|
664
|
+
read in its place, as the check's own (``_check_counterparty``): the act
|
|
665
|
+
gets the target its check got, keyed per deal. Given in any other shape,
|
|
666
|
+
it is named in ``ignored_inputs`` and the act has no target from it."""
|
|
667
|
+
if disclosed is None or not _bound(record, disclosed):
|
|
668
|
+
return None
|
|
669
|
+
checked = _checked_body(disclosed, history=history)
|
|
670
|
+
if checked is None:
|
|
671
|
+
return None
|
|
672
|
+
body, sealed_counterparty = checked
|
|
673
|
+
if not body.get("action_class") or not body.get("taxonomy_version"):
|
|
674
|
+
return None
|
|
675
|
+
spend = body.get("spend_minor")
|
|
676
|
+
authorized = body.get("spend_authorized_minor")
|
|
677
|
+
returned = reverses = None
|
|
678
|
+
if body.get("direction") == MONEY_IN:
|
|
679
|
+
spend = 0
|
|
680
|
+
authorized = None
|
|
681
|
+
returned, reverses = _returned_minor(body), _typed_ref_digest(body.get("reverses_ref"))
|
|
682
|
+
if reverses is None and history and _is_action_step(disclosed):
|
|
683
|
+
# An action step names the act it reverses in its refs, not its body.
|
|
684
|
+
reverses = _only_ref(disclosed, "reverses")
|
|
685
|
+
deal = _deal_id(disclosed)
|
|
686
|
+
ignored: list[str] = []
|
|
687
|
+
if act_counterparty is not None and sealed_counterparty is None:
|
|
688
|
+
if _check_counterparty(act_counterparty):
|
|
689
|
+
sealed_counterparty = act_counterparty
|
|
690
|
+
else:
|
|
691
|
+
ignored.append(_COUNTERPARTY_INPUT)
|
|
692
|
+
counterparty_ids, fp_alg = _sealed_counterparty(sealed_counterparty)
|
|
693
|
+
malformed = sealed_counterparty is not None and not (
|
|
694
|
+
isinstance(sealed_counterparty, dict)
|
|
695
|
+
and well_formed_counterparty(sealed_counterparty.get("fp_alg"), sealed_counterparty.get("ids")))
|
|
696
|
+
target = _payee_target(counterparty_ids, fp_alg)
|
|
697
|
+
if counterparty_profile is not None:
|
|
698
|
+
profile_target = _profile_target(counterparty_profile)
|
|
699
|
+
if profile_target is None:
|
|
700
|
+
ignored.insert(0, _PROFILE_INPUT)
|
|
701
|
+
else:
|
|
702
|
+
target = profile_target
|
|
703
|
+
recourse = body.get("recourse") if isinstance(body.get("recourse"), dict) else {}
|
|
704
|
+
representing = body.get("representing") if isinstance(body.get("representing"), dict) else {}
|
|
705
|
+
stated_class = _text(representing.get("class"))
|
|
706
|
+
if stated_class is None and body.get("action") == _REPRESENT:
|
|
707
|
+
# A represent check whose class cannot be read (no representing, or
|
|
708
|
+
# one out of shape): a class no rule recognises, so s04 and s10 fail
|
|
709
|
+
# it rather than read it as n/a.
|
|
710
|
+
stated_class = MALFORMED_REPRESENTATION_CLASS
|
|
711
|
+
return Action(
|
|
712
|
+
verb=str(body.get("action") or "unknown"),
|
|
713
|
+
operator=record.get("operator", ""),
|
|
714
|
+
developer=record.get("developer", ""),
|
|
715
|
+
action_class=body["action_class"],
|
|
716
|
+
action_id=record.get("action_id") or None,
|
|
717
|
+
action_type=record.get("action_type", "decide"),
|
|
718
|
+
timestamp=record.get("timestamp"),
|
|
719
|
+
amount_minor=_minor(spend),
|
|
720
|
+
spend_authorized_minor=_minor(authorized),
|
|
721
|
+
stated_amount_minor=_minor(body.get("amount_minor")),
|
|
722
|
+
currency=body.get("currency"),
|
|
723
|
+
rail=_text(recourse.get("rail")) or _text(body.get("rail")),
|
|
724
|
+
target=target,
|
|
725
|
+
taxonomy_version=body["taxonomy_version"],
|
|
726
|
+
counterparty_ids=counterparty_ids,
|
|
727
|
+
counterparty_fp_alg=fp_alg,
|
|
728
|
+
refundable=_flag(recourse.get("refundable")),
|
|
729
|
+
handover=_handover(body),
|
|
730
|
+
recipient_role=_text(body.get("recipient_role")),
|
|
731
|
+
disclosed_classes=disclosed_classes_of(body.get("disclosing")),
|
|
732
|
+
repeat_disclosures=repeat_disclosures_of(body.get("repeat_disclosures")),
|
|
733
|
+
recipient_told_before=_flag(body.get("recipient_told_before")),
|
|
734
|
+
listing_classes=listing_classes_of(body.get("listing_classes")),
|
|
735
|
+
representation_class=stated_class,
|
|
736
|
+
statement_attributed_to=_attributed_to(representing.get("attributed_to")),
|
|
737
|
+
representation_text_commitment=_text_commitment(representing),
|
|
738
|
+
channel=_text(body.get("channel")),
|
|
739
|
+
first_contact_channel=_text(body.get("first_contact_channel")),
|
|
740
|
+
upfront_amount_minor=_minor(body.get("upfront_amount_minor")),
|
|
741
|
+
material_fields_changed=_minor(body.get("material_fields_changed")),
|
|
742
|
+
material_fields_basis=_text(body.get("material_fields_basis")),
|
|
743
|
+
offer_fields_changed=_minor(body.get("offer_fields_changed")),
|
|
744
|
+
offer_fields_basis=_text(body.get("offer_fields_basis")),
|
|
745
|
+
task_authority_ref=_basis_task_authority(body) if history else _typed_ref_digest(body.get("task_authority_ref")),
|
|
746
|
+
no_proposal=_no_proposal(disclosed, body),
|
|
747
|
+
returned_minor=returned,
|
|
748
|
+
reverses_ref=reverses,
|
|
749
|
+
deal_id=deal.value,
|
|
750
|
+
deal_id_conflict=_DEAL_ID_FIELDS if deal.conflict else (),
|
|
751
|
+
counterparty_malformed=malformed,
|
|
752
|
+
ignored_inputs=tuple(ignored),
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# The ``x-deal-v0`` record type of a statement in a deal (capsule-cli
|
|
757
|
+
# internal/cli/deal_claims.go), and the ``source_kind`` of one the user's
|
|
758
|
+
# agent made to the counterparty.
|
|
759
|
+
_CLAIM = "claim"
|
|
760
|
+
_AGENT = "agent"
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def statement_record(record: dict, disclosed: dict | None) -> StatementRecord | None:
|
|
764
|
+
"""The record of the statement ``record`` makes to the counterparty
|
|
765
|
+
(``guards/statements.py``), or ``None`` when it makes none: it must be a
|
|
766
|
+
bound ``x-deal-v0`` ``claim`` whose body seals ``source_kind``
|
|
767
|
+
``agent`` and a non-empty ``class``, in the one deal it names
|
|
768
|
+
(``_deal_id``; a record naming two names none). A disclosure record's
|
|
769
|
+
``body.fields[].class`` is never read: that is the user's personal data
|
|
770
|
+
given out, not a statement about the deal. The same reader serves the
|
|
771
|
+
replay and a live check's history."""
|
|
772
|
+
if disclosed is None or not _bound(record, disclosed):
|
|
773
|
+
return None
|
|
774
|
+
block = disclosed.get("x-deal-v0")
|
|
775
|
+
body = disclosed.get("body")
|
|
776
|
+
if not isinstance(block, dict) or block.get("record_type") != _CLAIM or not isinstance(body, dict):
|
|
777
|
+
return None
|
|
778
|
+
sealed_class = _text(body.get("class"))
|
|
779
|
+
deal = _deal_id(disclosed).value
|
|
780
|
+
capsule_id = _text(record.get("capsule_id"))
|
|
781
|
+
if body.get("source_kind") != _AGENT or sealed_class is None or deal is None or capsule_id is None:
|
|
782
|
+
return None
|
|
783
|
+
# The claim's own sealed ``at``, never the capsule's ``timestamp``: the same
|
|
784
|
+
# instant (one clock read in capsulectl), but only ``at`` is the string a
|
|
785
|
+
# live check receives in ``deal_claims``, so the two never diverge.
|
|
786
|
+
return make_statement_record(
|
|
787
|
+
operator=str(record.get("operator", "")), timestamp=_text(block.get("at")),
|
|
788
|
+
sealed_class=sealed_class, source_kind=_AGENT, deal_id=deal, claim=capsule_id,
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
# A capsulectl deal report's sealed ``type``: what the user asked and what was
|
|
793
|
+
# done, for one audience. It states no act.
|
|
794
|
+
_REPORT_TYPE = "deal_report"
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _gets_no_decision(record: dict, disclosed: dict | None, withheld: frozenset[str]) -> bool:
|
|
798
|
+
"""Whether the replay skips ``record`` before the engine sees it: a bound
|
|
799
|
+
deal record other than a check (``_states_no_act``), a bound deal report,
|
|
800
|
+
or a record its bundle carries but withholds (``load_withheld``), so
|
|
801
|
+
nothing it states can be read. No rule fires on any of them, the gate
|
|
802
|
+
included. They stay in the ledger and its completeness proof."""
|
|
803
|
+
if disclosed is None:
|
|
804
|
+
return record.get("capsule_id") in withheld
|
|
805
|
+
if _bound(record, disclosed) and disclosed.get("type") == _REPORT_TYPE:
|
|
806
|
+
return True
|
|
807
|
+
return _states_no_act(record, disclosed)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
# The typed records (capsule-cli internal/cli/action_records.go) that state no
|
|
811
|
+
# act: the task authority, an evaluation, an approval (the user's, or a
|
|
812
|
+
# counterparty's acceptance), the action taken, which carries out the act its
|
|
813
|
+
# proposed action already stated, its outcome and a report. Only a
|
|
814
|
+
# ``proposed-action/v0`` is a check. A type not named here still gets a
|
|
815
|
+
# decision.
|
|
816
|
+
_TYPED_NO_ACT = frozenset({
|
|
817
|
+
"task-authority/v0", "action-evaluation/v0", "action-approval/v0", "action-record/v0", "action-outcome/v0",
|
|
818
|
+
"action-report/v0",
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _states_no_act(record: dict, disclosed: dict | None) -> bool:
|
|
823
|
+
"""Whether ``record`` is a deal record other than a check, read from the
|
|
824
|
+
``x-deal-v0.record_type`` its capsule sealed: a baseline, verdict,
|
|
825
|
+
approval or intent states no act, and the action step carries out the
|
|
826
|
+
act its check already stated. A typed record, which has no ``x-deal-v0``
|
|
827
|
+
block, is read from its sealed ``type`` (``_TYPED_NO_ACT``). Never read
|
|
828
|
+
from ``action_id`` or ``action_type``, and never from a record the
|
|
829
|
+
capsule does not bind."""
|
|
830
|
+
if disclosed is None or not _bound(record, disclosed):
|
|
831
|
+
return False
|
|
832
|
+
block = disclosed.get("x-deal-v0")
|
|
833
|
+
if not isinstance(block, dict):
|
|
834
|
+
return disclosed.get("type") in _TYPED_NO_ACT
|
|
835
|
+
record_type = block.get("record_type")
|
|
836
|
+
return isinstance(record_type, str) and record_type != "check"
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def action_for_record(record: dict, disclosed: dict | None = None, *, counterparty_profile: object = None) -> Action:
|
|
840
|
+
"""The action ``record`` states. ``disclosed`` is the record its capsule
|
|
841
|
+
sealed, when a bundle disclosed it (``load_disclosed``).
|
|
842
|
+
``counterparty_profile`` is the payee keyed per profile for a deal check,
|
|
843
|
+
from the checker input or the check's companion (module docstring)."""
|
|
844
|
+
bridged = _bridge_deal_check(record, disclosed, counterparty_profile) or _bridge_transfer_funds(record)
|
|
845
|
+
if bridged is not None:
|
|
846
|
+
return bridged
|
|
847
|
+
if _states_no_act(record, disclosed):
|
|
848
|
+
return replace(Action.from_capsule(record), states_act=False)
|
|
849
|
+
return Action.from_capsule(record)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def action_for_check_input(entry: dict, *, item_ref: object = None) -> Action:
|
|
853
|
+
"""The action an entry of an external-check-input/v0 envelope states, the
|
|
854
|
+
``record`` entry or one of ``history``: the sealed capsule, its disclosed
|
|
855
|
+
``agent_input``, and, beside them, what capsulectl computes and passes
|
|
856
|
+
and the capsule does not seal: ``counterparty_profile``, ``proposal_at``
|
|
857
|
+
(AMENDMENT 7) and, on a history entry, ``item_ref`` (AMENDMENT 6a).
|
|
858
|
+
``item_ref`` is the envelope's top-level ``item_ref`` for the record
|
|
859
|
+
entry; when it is not passed, the entry's own is read. Either is set on
|
|
860
|
+
the action when it is 64 lowercase hex and named in ``ignored_inputs``
|
|
861
|
+
otherwise. ``proposal_at`` is set when it is a string (``offer_expiry``
|
|
862
|
+
reads and judges it) and named in ``ignored_inputs`` otherwise.
|
|
863
|
+
``deal_claims`` is read by ``deal_claim_statements``, and named in
|
|
864
|
+
``ignored_inputs`` when any of it is ignored. The
|
|
865
|
+
envelope's other top-level members, ``party_role`` among them (a checker
|
|
866
|
+
may read it to pick a pack), are not read here: an input carrying one is
|
|
867
|
+
decided as one without it. The act a history entry records is read by
|
|
868
|
+
``action_for_history_entry``; here a history entry's typed act record
|
|
869
|
+
states no act."""
|
|
870
|
+
capsule, agent_input = _entry_parts(entry)
|
|
871
|
+
action = action_for_record(capsule, agent_input, counterparty_profile=entry.get(_PROFILE_INPUT))
|
|
872
|
+
action = _with_inputs(action, entry, item_ref)
|
|
873
|
+
if _CLAIMS_INPUT in entry and deal_claim_statements(entry).ignored:
|
|
874
|
+
action = replace(action, ignored_inputs=(*action.ignored_inputs, _CLAIMS_INPUT))
|
|
875
|
+
return action
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
@dataclass(frozen=True)
|
|
879
|
+
class DealClaims:
|
|
880
|
+
"""The statements a checked record entry's ``deal_claims`` makes, and
|
|
881
|
+
whether any of it was ignored."""
|
|
882
|
+
|
|
883
|
+
statements: tuple[StatementRecord, ...]
|
|
884
|
+
ignored: bool
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def deal_claim_statements(entry: dict) -> DealClaims:
|
|
888
|
+
"""The statements the ``deal_claims`` beside a checked record entry make
|
|
889
|
+
(AMENDMENT 9), as ``statement_record`` writes them for the same claims in
|
|
890
|
+
a bundle: the claim's ``capsule_id``, its class (``delivery_promise`` read
|
|
891
|
+
as ``delivery_date``), ``source_kind`` ``agent``, the checked record's
|
|
892
|
+
deal and operator, and the claim's sealed ``at`` as its time. An entry
|
|
893
|
+
counts only in its agreed shape: ``capsule_id``, ``record_digest`` and
|
|
894
|
+
``text_commitment`` 64 lowercase hex, a non-empty ``class``,
|
|
895
|
+
``source_kind`` ``agent``, and ``at`` an RFC 3339 time with a zone at or
|
|
896
|
+
before the checked capsule's ``timestamp``. Any other entry, a
|
|
897
|
+
``deal_claims`` that is not a list, or a checked record whose deal cannot
|
|
898
|
+
be read (unbound, or naming two deals) is ignored, and ``ignored`` says
|
|
899
|
+
so; nothing it holds is echoed. An empty list is no claims. The same
|
|
900
|
+
claim (one ``capsule_id``) given twice is one statement.
|
|
901
|
+
|
|
902
|
+
The entries are capsulectl's word, on the user's own device: an entry
|
|
903
|
+
names no deal of its own, so it is taken as the checked record's, and
|
|
904
|
+
its ``record_digest`` is checked for shape only, since a live check has
|
|
905
|
+
no claim record to recompute it from. A replay of the bundle reads the
|
|
906
|
+
sealed claims themselves. The time rule is "at or before", as for a
|
|
907
|
+
claim in the history: capsule times are whole seconds, and capsulectl
|
|
908
|
+
seals a claim and the step after it in one second."""
|
|
909
|
+
claims = entry.get(_CLAIMS_INPUT)
|
|
910
|
+
if claims is None:
|
|
911
|
+
return DealClaims(statements=(), ignored=False)
|
|
912
|
+
capsule, agent_input = _entry_parts(entry)
|
|
913
|
+
deal = _deal_id(agent_input).value if agent_input is not None and _bound(capsule, agent_input) else None
|
|
914
|
+
checked_at = _aware(capsule.get("timestamp"))
|
|
915
|
+
if not isinstance(claims, list) or deal is None or checked_at is None:
|
|
916
|
+
return DealClaims(statements=(), ignored=True)
|
|
917
|
+
statements: dict[str, StatementRecord] = {}
|
|
918
|
+
ignored = False
|
|
919
|
+
for claim in claims:
|
|
920
|
+
at = _aware(claim.get("at")) if isinstance(claim, dict) else None
|
|
921
|
+
if at is None or at > checked_at or not _claim_in_shape(claim):
|
|
922
|
+
ignored = True
|
|
923
|
+
continue
|
|
924
|
+
stated = make_statement_record(
|
|
925
|
+
operator=str(capsule.get("operator", "")), timestamp=claim["at"], sealed_class=claim["class"],
|
|
926
|
+
source_kind=_AGENT, deal_id=deal, claim=claim["capsule_id"],
|
|
927
|
+
)
|
|
928
|
+
statements.setdefault(claim_of(stated), stated)
|
|
929
|
+
return DealClaims(statements=tuple(statements.values()), ignored=ignored)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _claim_in_shape(claim: dict) -> bool:
|
|
933
|
+
"""Whether a ``deal_claims`` entry has its agreed members, each in shape
|
|
934
|
+
(``deal_claim_statements``); its ``at`` is checked by the caller."""
|
|
935
|
+
hexes = (claim.get(k) for k in ("capsule_id", "record_digest", "text_commitment"))
|
|
936
|
+
return (all(isinstance(v, str) and _HEX64.fullmatch(v) for v in hexes)
|
|
937
|
+
and _text(claim.get("class")) is not None and claim.get("source_kind") == _AGENT)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _aware(value: object) -> datetime | None:
|
|
941
|
+
"""``value`` as an aware time, when it is an RFC 3339 string with a zone."""
|
|
942
|
+
if not isinstance(value, str):
|
|
943
|
+
return None
|
|
944
|
+
try:
|
|
945
|
+
at = _parse_ts(value)
|
|
946
|
+
except ValueError:
|
|
947
|
+
# Not a time: the caller ignores what it dates.
|
|
948
|
+
return None
|
|
949
|
+
return at if at.tzinfo is not None else None
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def action_for_history_entry(entry: dict) -> Action | None:
|
|
953
|
+
"""The act a ``history`` entry of an external-check-input/v0 envelope
|
|
954
|
+
records: its sealed act, a typed act record (``action-record/v0``) or an
|
|
955
|
+
``x-deal-v0`` action step, bridged as a deal check is
|
|
956
|
+
(``_bridge_deal_check``), with the task authority a typed record's
|
|
957
|
+
``authority_basis`` names, the act an action step reverses read from its
|
|
958
|
+
``refs`` (``rel`` ``reverses``), and the inputs beside it read as
|
|
959
|
+
``action_for_check_input`` reads them (the entry's own ``item_ref``),
|
|
960
|
+
plus the entry's ``counterparty``: who the act was with, its target as
|
|
961
|
+
its check's (``_bridge_deal_check``). ``counterparty_profile`` still
|
|
962
|
+
replaces that target, as it does a check's. ``None`` when the entry is
|
|
963
|
+
not an act its capsule binds, or
|
|
964
|
+
names no class or taxonomy, or states an act taken without a check:
|
|
965
|
+
what it records cannot be read as a checked act."""
|
|
966
|
+
capsule, agent_input = _entry_parts(entry)
|
|
967
|
+
action = _bridge_deal_check(
|
|
968
|
+
capsule, agent_input, entry.get(_PROFILE_INPUT), history=True, act_counterparty=entry.get(_COUNTERPARTY_INPUT)
|
|
969
|
+
)
|
|
970
|
+
return _with_inputs(action, entry, None) if action is not None else None
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def bound_agent_input(entry: dict) -> dict | None:
|
|
974
|
+
"""An envelope entry's disclosed ``agent_input`` when its capsule binds
|
|
975
|
+
it, else ``None``."""
|
|
976
|
+
capsule, agent_input = _entry_parts(entry)
|
|
977
|
+
return agent_input if agent_input is not None and _bound(capsule, agent_input) else None
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def statement_for_history_entry(entry: dict) -> StatementRecord | None:
|
|
981
|
+
"""The statement a ``history`` entry of an external-check-input/v0
|
|
982
|
+
envelope records (``statement_record``), read from its sealed capsule and
|
|
983
|
+
disclosed ``agent_input``; ``None`` when it records none."""
|
|
984
|
+
capsule, agent_input = _entry_parts(entry)
|
|
985
|
+
return statement_record(capsule, agent_input)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _entry_parts(entry: dict) -> tuple[dict, dict | None]:
|
|
989
|
+
"""An envelope entry's capsule, without the members capsulectl passes
|
|
990
|
+
beside it, and its disclosed ``agent_input``."""
|
|
991
|
+
capsule = {
|
|
992
|
+
k: v
|
|
993
|
+
for k, v in entry.items()
|
|
994
|
+
if k not in ("agent_input", _PROFILE_INPUT, _PROPOSAL_INPUT, _ITEM_INPUT, _COUNTERPARTY_INPUT, _CLAIMS_INPUT)
|
|
995
|
+
}
|
|
996
|
+
agent_input = entry.get("agent_input")
|
|
997
|
+
return capsule, agent_input if isinstance(agent_input, dict) else None
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _with_inputs(action: Action, entry: dict, item_ref: object) -> Action:
|
|
1001
|
+
"""``action`` with the ``item_ref`` and ``proposal_at`` passed beside
|
|
1002
|
+
``entry`` (``action_for_check_input``)."""
|
|
1003
|
+
ignored = list(action.ignored_inputs)
|
|
1004
|
+
if item_ref is None:
|
|
1005
|
+
item_ref = entry.get(_ITEM_INPUT)
|
|
1006
|
+
if item_ref is not None:
|
|
1007
|
+
if isinstance(item_ref, str) and _HEX64.fullmatch(item_ref):
|
|
1008
|
+
action = replace(action, item_ref=item_ref)
|
|
1009
|
+
else:
|
|
1010
|
+
ignored.append(_ITEM_INPUT)
|
|
1011
|
+
proposal_at = entry.get(_PROPOSAL_INPUT)
|
|
1012
|
+
if proposal_at is not None:
|
|
1013
|
+
if isinstance(proposal_at, str):
|
|
1014
|
+
action = replace(action, proposal_at=proposal_at)
|
|
1015
|
+
else:
|
|
1016
|
+
ignored.append(_PROPOSAL_INPUT)
|
|
1017
|
+
return replace(action, ignored_inputs=tuple(ignored))
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _companion_profiles(records: list[dict], disclosed: dict[str, dict]) -> dict[str, object]:
|
|
1021
|
+
"""Each check's ``counterparty_profile`` block, by the check's record
|
|
1022
|
+
digest, from the bound companions among ``records``: a
|
|
1023
|
+
``counterparty_profile`` record with exactly one ref, ``rel`` ``about``,
|
|
1024
|
+
type ``deal-record``. A check two companions name gets their blocks as a
|
|
1025
|
+
list, which is no block, so it is ignored; a companion without one gets
|
|
1026
|
+
``{}``, ignored the same way."""
|
|
1027
|
+
found: dict[str, list[object]] = {}
|
|
1028
|
+
for record in records:
|
|
1029
|
+
shown = disclosed.get(record.get("capsule_id", ""))
|
|
1030
|
+
if shown is None or not _bound(record, shown):
|
|
1031
|
+
continue
|
|
1032
|
+
block = shown.get("x-deal-v0")
|
|
1033
|
+
if not isinstance(block, dict) or block.get("record_type") != _PROFILE_INPUT:
|
|
1034
|
+
continue
|
|
1035
|
+
refs = block.get("refs")
|
|
1036
|
+
if not isinstance(refs, list) or len(refs) != 1 or not isinstance(refs[0], dict):
|
|
1037
|
+
continue
|
|
1038
|
+
(ref,) = refs
|
|
1039
|
+
digest = _typed_ref_digest(ref)
|
|
1040
|
+
if ref.get("rel") != "about" or ref.get("type") != "deal-record" or digest is None:
|
|
1041
|
+
continue
|
|
1042
|
+
found.setdefault(digest, []).append(block.get(_PROFILE_INPUT) or {})
|
|
1043
|
+
return {digest: blocks[0] if len(blocks) == 1 else blocks for digest, blocks in found.items()}
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _deal_records(records: list[dict], disclosed: dict[str, dict]) -> dict[str, dict]:
|
|
1047
|
+
"""Each bound ``x-deal-v0`` record among ``records``, by its digest."""
|
|
1048
|
+
found: dict[str, dict] = {}
|
|
1049
|
+
for record in records:
|
|
1050
|
+
shown = disclosed.get(record.get("capsule_id", ""))
|
|
1051
|
+
if shown is not None and isinstance(shown.get("x-deal-v0"), dict) and _bound(record, shown):
|
|
1052
|
+
found[json_digest(shown)] = shown
|
|
1053
|
+
return found
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
# How a typed deal states a seller's offer, the buyer's acceptance of it and
|
|
1057
|
+
# the seller's commit (capsule-cli action_records.go).
|
|
1058
|
+
_PROPOSED_TYPE = "proposed-action/v0"
|
|
1059
|
+
_APPROVAL_TYPE = "action-approval/v0"
|
|
1060
|
+
_ACCEPTANCE_AUTHORITY = "counterparty_acceptance"
|
|
1061
|
+
_OFFER, _COMMIT = "offer", "commit"
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _changes_details(block: dict) -> bool:
|
|
1065
|
+
"""Whether the ``x-deal-v0`` record ``block`` changes the deal's details,
|
|
1066
|
+
so an earlier acceptance no longer stands: a change, or a message or
|
|
1067
|
+
evidence that names the counterparty (capsule-cli deal_rules.go,
|
|
1068
|
+
changesDetails)."""
|
|
1069
|
+
record_type = block.get("record_type")
|
|
1070
|
+
return record_type == "change" or (record_type in ("message", "evidence") and "counterparty" in block)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _accepted_offer_at(records: list[dict], disclosed: dict[str, dict]) -> dict[str, str]:
|
|
1074
|
+
"""The proposal each bound typed commit rests on, as the ``at`` that
|
|
1075
|
+
offer's record seals, by the commit's record digest (the value capsulectl
|
|
1076
|
+
sends live as ``record.proposal_at``; its capsule's ``timestamp`` is the
|
|
1077
|
+
same instant, from one clock read, but is not read here):
|
|
1078
|
+
``offer_expiry`` reads it as ``Action.proposal_at``. It is the deal's
|
|
1079
|
+
latest offer, when a bound acceptance (an ``action-approval/v0`` of
|
|
1080
|
+
authority ``counterparty_acceptance``) names that offer's record by
|
|
1081
|
+
``proposed_action_ref`` and nothing after it changed the details
|
|
1082
|
+
(``_changes_details``), as capsule-cli's offerAccepted reads it. A later
|
|
1083
|
+
offer supersedes an earlier one and its acceptance. Every record is read
|
|
1084
|
+
within the deal it names (``_deal_id``), in ledger order. A commit with
|
|
1085
|
+
no such offer is left out, so its proposal's age stays unestablished.
|
|
1086
|
+
|
|
1087
|
+
A typed offer that names the counterparty's message it answers by its one
|
|
1088
|
+
``source`` ref gets that message's time (the value capsulectl sends live
|
|
1089
|
+
on the offer): the message record's sealed ``sent_at``, else its sealed
|
|
1090
|
+
``at``, only when ``sent_at`` is absent (``_message_time``). The message
|
|
1091
|
+
must be a bound ``x-deal-v0`` ``message`` from the
|
|
1092
|
+
counterparty, earlier in ledger order and in the same deal. A withheld,
|
|
1093
|
+
unbound or unresolved message, or more than one ``source`` ref, leaves
|
|
1094
|
+
the offer out, so its proposal's age stays unestablished."""
|
|
1095
|
+
latest: dict[str, tuple[str, str | None]] = {}
|
|
1096
|
+
accepted: dict[str, str | None] = {}
|
|
1097
|
+
messages: dict[str, tuple[str, str | None]] = {}
|
|
1098
|
+
found: dict[str, str] = {}
|
|
1099
|
+
for record in records:
|
|
1100
|
+
shown = disclosed.get(record.get("capsule_id", ""))
|
|
1101
|
+
if shown is None or not _bound(record, shown):
|
|
1102
|
+
continue
|
|
1103
|
+
deal = _deal_id(shown).value
|
|
1104
|
+
if deal is None:
|
|
1105
|
+
continue
|
|
1106
|
+
body = shown.get("body") if isinstance(shown.get("body"), dict) else {}
|
|
1107
|
+
block = shown.get("x-deal-v0")
|
|
1108
|
+
if isinstance(block, dict) and block.get("record_type") == _MESSAGE and body.get("from") == _COUNTERPARTY:
|
|
1109
|
+
messages[json_digest(shown)] = (deal, _message_time(block, body))
|
|
1110
|
+
if shown.get("type") == _PROPOSED_TYPE and body.get("action") == _OFFER:
|
|
1111
|
+
digest = json_digest(shown)
|
|
1112
|
+
latest[deal] = (digest, _text(shown.get("at")))
|
|
1113
|
+
accepted.pop(deal, None)
|
|
1114
|
+
answered = messages.get(_answered_message(shown) or "")
|
|
1115
|
+
if answered is not None and answered[0] == deal and answered[1] is not None:
|
|
1116
|
+
found[digest] = answered[1]
|
|
1117
|
+
elif shown.get("type") == _PROPOSED_TYPE and body.get("action") == _COMMIT:
|
|
1118
|
+
at = accepted.get(deal)
|
|
1119
|
+
if at is not None:
|
|
1120
|
+
found[json_digest(shown)] = at
|
|
1121
|
+
elif shown.get("type") == _APPROVAL_TYPE and body.get("authority") == _ACCEPTANCE_AUTHORITY:
|
|
1122
|
+
offer = latest.get(deal)
|
|
1123
|
+
if offer is not None and _typed_ref_digest(body.get("proposed_action_ref")) == offer[0]:
|
|
1124
|
+
accepted[deal] = offer[1]
|
|
1125
|
+
elif isinstance(block, dict) and _changes_details(block):
|
|
1126
|
+
accepted.pop(deal, None)
|
|
1127
|
+
return found
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
# A deal's message record, and who sent it, as capsulectl seals it.
|
|
1131
|
+
_MESSAGE, _COUNTERPARTY = "message", "counterparty"
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
# A sealed time as capsulectl writes it: RFC 3339 UTC, whole seconds.
|
|
1135
|
+
_SEALED_TIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _sealed_time(value: object) -> datetime | None:
|
|
1139
|
+
if not isinstance(value, str) or not _SEALED_TIME.match(value):
|
|
1140
|
+
return None
|
|
1141
|
+
try:
|
|
1142
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
1143
|
+
except ValueError:
|
|
1144
|
+
# The shape matched but the date does not exist: no time.
|
|
1145
|
+
return None
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _message_time(block: dict, body: dict) -> str | None:
|
|
1149
|
+
"""When a counterparty's message was sent: its sealed ``sent_at`` when the
|
|
1150
|
+
member is present, else its sealed ``at`` (when it was noted). A present
|
|
1151
|
+
``sent_at`` that is not a real UTC time, or that is later than the
|
|
1152
|
+
message's own ``at``, gives no time: it is never replaced by ``at``."""
|
|
1153
|
+
at = _sealed_time(block.get("at"))
|
|
1154
|
+
if "sent_at" not in body:
|
|
1155
|
+
return block["at"] if at is not None else None
|
|
1156
|
+
sent = _sealed_time(body["sent_at"])
|
|
1157
|
+
if sent is None or at is None or sent > at:
|
|
1158
|
+
return None
|
|
1159
|
+
return body["sent_at"]
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _answered_message(shown: dict) -> str | None:
|
|
1163
|
+
"""The digest a typed offer's one ``source`` ref names, when it has
|
|
1164
|
+
exactly one and it is a SHA-256 typed ref; else ``None``."""
|
|
1165
|
+
refs = shown.get("refs")
|
|
1166
|
+
found = [r for r in refs if isinstance(r, dict) and r.get("rel") == _SOURCE] if isinstance(refs, list) else []
|
|
1167
|
+
return _typed_ref_digest(found[0]) if len(found) == 1 else None
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _record_type(shown: dict) -> str | None:
|
|
1171
|
+
record_type = shown["x-deal-v0"].get("record_type")
|
|
1172
|
+
return record_type if isinstance(record_type, str) else None
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _only_ref(shown: dict, rel: str) -> str | None:
|
|
1176
|
+
"""The digest of the record ``shown``'s one ``rel`` ref names, when it has
|
|
1177
|
+
exactly one and it is a SHA-256 ``deal-record`` ref; else ``None``."""
|
|
1178
|
+
refs = shown["x-deal-v0"].get("refs")
|
|
1179
|
+
found = [r for r in refs if isinstance(r, dict) and r.get("rel") == rel] if isinstance(refs, list) else []
|
|
1180
|
+
if len(found) != 1 or found[0].get("type") != "deal-record":
|
|
1181
|
+
return None
|
|
1182
|
+
return _typed_ref_digest(found[0])
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
# The sealed approvers whose approval carries an act out: the user, or the
|
|
1186
|
+
# user's standing intent. Any other (``agent_card``: a click on a card the
|
|
1187
|
+
# agent composed) certifies no one's consent, so the act stays unseen. This is
|
|
1188
|
+
# the rule capsulectl's deal disposition applies live (capsule-cli
|
|
1189
|
+
# internal/cli/deal.go, dealDisposition), so replay never counts as seen a
|
|
1190
|
+
# counterparty the live path would not.
|
|
1191
|
+
_CONSENTING_APPROVERS = frozenset({"user", "standing_intent"})
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
@dataclass(frozen=True)
|
|
1195
|
+
class _CarriedOut:
|
|
1196
|
+
"""An executed action step's chain back to its check, as digests."""
|
|
1197
|
+
|
|
1198
|
+
check: str
|
|
1199
|
+
approval: str
|
|
1200
|
+
action: str
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _carried_out(action_digest: str, deal: dict[str, dict]) -> _CarriedOut | None:
|
|
1204
|
+
"""The chain by which the bound action step ``action_digest`` carried out
|
|
1205
|
+
a check, read from sealed records only: its one ``authorized_by`` ref
|
|
1206
|
+
names an approval whose body seals ``proceed: true`` and an approver in
|
|
1207
|
+
``_CONSENTING_APPROVERS``, and that approval's one ``approves`` ref names
|
|
1208
|
+
the check, or the verdict whose one ``checks`` ref names it. Every record
|
|
1209
|
+
in the chain names the action's deal (``_deal_id``). ``None`` when any
|
|
1210
|
+
link is missing, unbound, declined, approved by no one who consents for
|
|
1211
|
+
the user, of another kind, or names two deals."""
|
|
1212
|
+
action = deal.get(action_digest)
|
|
1213
|
+
if action is None or _record_type(action) != "action":
|
|
1214
|
+
return None
|
|
1215
|
+
deal_id = _deal_id(action)
|
|
1216
|
+
if deal_id.conflict:
|
|
1217
|
+
return None
|
|
1218
|
+
approval_digest = _only_ref(action, "authorized_by")
|
|
1219
|
+
approval = deal.get(approval_digest) if approval_digest is not None else None
|
|
1220
|
+
if approval_digest is None or approval is None or _record_type(approval) != "approval":
|
|
1221
|
+
return None
|
|
1222
|
+
body = approval.get("body")
|
|
1223
|
+
if _deal_id(approval) != deal_id or not isinstance(body, dict) or body.get("proceed") is not True:
|
|
1224
|
+
return None
|
|
1225
|
+
if _text(body.get("approver")) not in _CONSENTING_APPROVERS:
|
|
1226
|
+
return None
|
|
1227
|
+
check_digest = _only_ref(approval, "approves")
|
|
1228
|
+
approved = deal.get(check_digest) if check_digest is not None else None
|
|
1229
|
+
if approved is not None and _record_type(approved) == "verdict":
|
|
1230
|
+
check_digest = _only_ref(approved, "checks")
|
|
1231
|
+
approved = deal.get(check_digest) if check_digest is not None else None
|
|
1232
|
+
if check_digest is None or approved is None or _record_type(approved) != "check":
|
|
1233
|
+
return None
|
|
1234
|
+
if _deal_id(approved) != deal_id:
|
|
1235
|
+
return None
|
|
1236
|
+
return _CarriedOut(check=check_digest, approval=approval_digest, action=action_digest)
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
# The ``disposition.decision`` of the record a replay writes to its own view
|
|
1240
|
+
# for an act carried out (counterparty.seen_before/3.0.0 counts it). No guard
|
|
1241
|
+
# decision carries it, so no fold that reads accepted decisions counts it.
|
|
1242
|
+
_CARRIED_OUT = "carried_out"
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
class _CarriedOutCitation(TypedDict):
|
|
1246
|
+
decision: str
|
|
1247
|
+
check: str
|
|
1248
|
+
approval: str
|
|
1249
|
+
action: str
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
class _CarriedOutPayload(TypedDict):
|
|
1253
|
+
target: str | None
|
|
1254
|
+
carried_out: _CarriedOutCitation
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
class _Disposition(TypedDict):
|
|
1258
|
+
decision: str
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
class _CarriedOutBody(TypedDict):
|
|
1262
|
+
operator: str
|
|
1263
|
+
timestamp: str | None
|
|
1264
|
+
disposition: _Disposition
|
|
1265
|
+
asg_payload: _CarriedOutPayload
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
class _CarriedOutRecord(_CarriedOutBody):
|
|
1269
|
+
capsule_id: str
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _carried_out_record(sourced: SourcedDecision, chain: _CarriedOut, at: str | None) -> _CarriedOutRecord:
|
|
1273
|
+
"""The record of ``sourced``'s act as carried out, citing its decision and
|
|
1274
|
+
the sealed chain by digest; its ``capsule_id`` is the digest of the rest.
|
|
1275
|
+
It is written to the replay's own view only."""
|
|
1276
|
+
citation = _CarriedOutCitation(
|
|
1277
|
+
decision=sourced.decision.capsule["capsule_id"], check=chain.check, approval=chain.approval, action=chain.action
|
|
1278
|
+
)
|
|
1279
|
+
body = _CarriedOutBody(
|
|
1280
|
+
operator=sourced.action.operator,
|
|
1281
|
+
timestamp=at,
|
|
1282
|
+
disposition=_Disposition(decision=_CARRIED_OUT),
|
|
1283
|
+
asg_payload=_CarriedOutPayload(target=sourced.action.target, carried_out=citation),
|
|
1284
|
+
)
|
|
1285
|
+
return _CarriedOutRecord(**body, capsule_id=json_digest(body))
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
# How a deal seals an act it executed: an ``x-deal-v0`` action step, or an
|
|
1289
|
+
# outcome stating an act taken without a check (``body.unchecked``); a typed
|
|
1290
|
+
# deal seals them as an ``action-record/v0`` and an ``action-outcome/v0``
|
|
1291
|
+
# (``body.attempted``) (capsule-cli deal_profile.go, action_records.go). A
|
|
1292
|
+
# typed action record of a disclosure (``body.disclosed``) moves no money.
|
|
1293
|
+
_ACTION_STEP, _OUTCOME_STEP = "action", "outcome"
|
|
1294
|
+
_UNCHECKED, _ATTEMPTED, _DISCLOSED = "unchecked", "attempted", "disclosed"
|
|
1295
|
+
_ACCEPT = "accept"
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
@dataclass(frozen=True)
|
|
1299
|
+
class _ExecutedAct:
|
|
1300
|
+
"""An act a deal executed, as its record seals it: the spend it took
|
|
1301
|
+
(``_executed_spend``), ``None`` when that cannot be read, and its class."""
|
|
1302
|
+
|
|
1303
|
+
spend_minor: int | None
|
|
1304
|
+
action_class: str | None
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _taken(shown: dict, *, every_record_is_an_act: bool) -> tuple[dict, bool] | None:
|
|
1308
|
+
"""The body stating the act the bound record ``shown`` records as
|
|
1309
|
+
executed, and whether it was taken without a check; ``None`` for a
|
|
1310
|
+
record that states no executed act. A check input's history holds acts
|
|
1311
|
+
only (``every_record_is_an_act``), so any other record there is read as
|
|
1312
|
+
a checked act's body."""
|
|
1313
|
+
body = shown.get("body")
|
|
1314
|
+
if not isinstance(body, dict):
|
|
1315
|
+
return None
|
|
1316
|
+
block = shown.get("x-deal-v0")
|
|
1317
|
+
record_type = block.get("record_type") if isinstance(block, dict) else shown.get("type")
|
|
1318
|
+
if record_type in (_OUTCOME_STEP, "action-outcome/v0") or _UNCHECKED in body or _ATTEMPTED in body:
|
|
1319
|
+
taken = body.get(_UNCHECKED, body.get(_ATTEMPTED))
|
|
1320
|
+
return (taken, True) if isinstance(taken, dict) else None
|
|
1321
|
+
if record_type in (_ACTION_STEP, _ACT_RECORD_TYPE) or every_record_is_an_act:
|
|
1322
|
+
return body, False
|
|
1323
|
+
return None
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _executed_spend(body: dict, *, unchecked: bool, accepted: bool) -> int | None:
|
|
1327
|
+
"""The spend the act ``body`` states took, or ``None`` when it cannot be
|
|
1328
|
+
read. Money moved in (``MONEY_IN``) is ``0``, decided or not, and a
|
|
1329
|
+
disclosure moves none. An accepted act is counted at its
|
|
1330
|
+
``amount_minor``, as the fold counts an accepted decision. An act taken
|
|
1331
|
+
without a check is sealed before its spend is classed, so its
|
|
1332
|
+
``amount_minor`` stands in for a missing ``spend_minor``. Any other act
|
|
1333
|
+
is counted at its ``spend_minor``, which must be an integer of at least
|
|
1334
|
+
0."""
|
|
1335
|
+
if body.get("direction") == MONEY_IN or _DISCLOSED in body:
|
|
1336
|
+
return 0
|
|
1337
|
+
if accepted:
|
|
1338
|
+
return _minor(body.get("amount_minor"))
|
|
1339
|
+
spend = _minor(body.get("spend_minor"))
|
|
1340
|
+
if unchecked:
|
|
1341
|
+
return spend if spend is not None else _minor(body.get("amount_minor"))
|
|
1342
|
+
return spend if spend is not None and spend >= 0 else None
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def _executed_act(capsule: dict, shown: dict | None, *, every_record_is_an_act: bool = False) -> _ExecutedAct | None:
|
|
1346
|
+
"""The act the record ``shown``, bound by ``capsule``, states was
|
|
1347
|
+
executed (``_taken``), or ``None`` for one that states none. An act whose
|
|
1348
|
+
record is not given cannot be read: its spend is ``None``."""
|
|
1349
|
+
if shown is None:
|
|
1350
|
+
return _ExecutedAct(spend_minor=None, action_class=None)
|
|
1351
|
+
taken = _taken(shown, every_record_is_an_act=every_record_is_an_act)
|
|
1352
|
+
if taken is None:
|
|
1353
|
+
return None
|
|
1354
|
+
body, unchecked = taken
|
|
1355
|
+
spend = _executed_spend(body, unchecked=unchecked, accepted=_sealed_accept(capsule))
|
|
1356
|
+
return _ExecutedAct(spend_minor=spend, action_class=_text(body.get("action_class")))
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _sealed_accept(capsule: dict) -> bool:
|
|
1360
|
+
disposition = capsule.get("disposition")
|
|
1361
|
+
return isinstance(disposition, dict) and disposition.get("decision") == _ACCEPT
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _act_ids(capsule: dict) -> frozenset[str]:
|
|
1365
|
+
"""The identities an act's capsule carries: its ``capsule_id`` and
|
|
1366
|
+
``action_id``."""
|
|
1367
|
+
return frozenset(v for v in (capsule.get("capsule_id"), capsule.get("action_id")) if isinstance(v, str) and v)
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
class ExecutedActs:
|
|
1371
|
+
"""Each executed act counted once in the spend window. An accepted act
|
|
1372
|
+
always counts. Any other act that shares a ``capsule_id`` or an
|
|
1373
|
+
``action_id`` with an accepted one, or with an act already counted, is the
|
|
1374
|
+
same act and adds nothing. ``capsules`` are every act's capsule the
|
|
1375
|
+
window may hold, so the accepted ones are known whatever their order."""
|
|
1376
|
+
|
|
1377
|
+
def __init__(self, capsules: Sequence[dict]) -> None:
|
|
1378
|
+
self._accepted = frozenset().union(*(_act_ids(c) for c in capsules if _sealed_accept(c)))
|
|
1379
|
+
self._counted: set[str] = set()
|
|
1380
|
+
|
|
1381
|
+
def record(self, capsule: dict, shown: dict | None, act_digest: str, *,
|
|
1382
|
+
every_record_is_an_act: bool = False) -> _ExecutedRecord | None:
|
|
1383
|
+
"""The spend window's record of the act ``capsule`` binds
|
|
1384
|
+
(``_executed_record``), or ``None`` when it states no executed act
|
|
1385
|
+
or was counted already."""
|
|
1386
|
+
act = _executed_act(capsule, shown, every_record_is_an_act=every_record_is_an_act)
|
|
1387
|
+
if act is None:
|
|
1388
|
+
return None
|
|
1389
|
+
ids = _act_ids(capsule)
|
|
1390
|
+
if not _sealed_accept(capsule):
|
|
1391
|
+
if ids & (self._accepted | self._counted):
|
|
1392
|
+
return None
|
|
1393
|
+
self._counted |= ids
|
|
1394
|
+
return _executed_record(capsule, act, act_digest)
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
class _ExecutedCitation(TypedDict):
|
|
1398
|
+
act: str
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
class _ExecutedPayload(TypedDict, total=False):
|
|
1402
|
+
amount_minor: int
|
|
1403
|
+
action_class: str
|
|
1404
|
+
spend_unreadable: bool
|
|
1405
|
+
executed: _ExecutedCitation
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
class _ExecutedBody(TypedDict):
|
|
1409
|
+
operator: str | None
|
|
1410
|
+
timestamp: str | None
|
|
1411
|
+
disposition: _Disposition
|
|
1412
|
+
asg_payload: _ExecutedPayload
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
class _ExecutedRecord(_ExecutedBody):
|
|
1416
|
+
capsule_id: str
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _executed_record(capsule: dict, act: _ExecutedAct, act_digest: str) -> _ExecutedRecord:
|
|
1420
|
+
"""The record of an executed act for the spend window
|
|
1421
|
+
(``spend.weekly/3.1.0``), under its capsule's operator and time, citing
|
|
1422
|
+
the act's record by digest; its ``capsule_id`` is the digest of the rest.
|
|
1423
|
+
It carries the act's spend as ``amount_minor``, or ``spend_unreadable``
|
|
1424
|
+
and no amount when the spend cannot be read (``_executed_spend``), so
|
|
1425
|
+
the window counts the act or is known not to. It is never a decision
|
|
1426
|
+
and names no target, so it is never seen, and never the earlier act a
|
|
1427
|
+
repeat duplicates."""
|
|
1428
|
+
payload = _ExecutedPayload(executed=_ExecutedCitation(act=act_digest))
|
|
1429
|
+
if act.spend_minor is None:
|
|
1430
|
+
payload[SPEND_UNREADABLE] = True
|
|
1431
|
+
else:
|
|
1432
|
+
payload["amount_minor"] = act.spend_minor
|
|
1433
|
+
if act.action_class is not None:
|
|
1434
|
+
payload["action_class"] = act.action_class
|
|
1435
|
+
body = _ExecutedBody(
|
|
1436
|
+
operator=_text(capsule.get("operator")),
|
|
1437
|
+
timestamp=_text(capsule.get("timestamp")),
|
|
1438
|
+
disposition=_Disposition(decision=EXECUTED_DECISION),
|
|
1439
|
+
asg_payload=payload,
|
|
1440
|
+
)
|
|
1441
|
+
return _ExecutedRecord(**body, capsule_id=json_digest(body))
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
@dataclass(frozen=True)
|
|
1445
|
+
class SourcedDecision:
|
|
1446
|
+
"""One replayed record, its resulting ``Action``, its real
|
|
1447
|
+
``GuardDecision``, and (when a check matched a prior record) that prior
|
|
1448
|
+
record's full capsule -- fetched from the replay's own ephemeral store
|
|
1449
|
+
while it is still open, so the report can carry it for re-verification."""
|
|
1450
|
+
|
|
1451
|
+
record: dict
|
|
1452
|
+
action: Action
|
|
1453
|
+
decision: GuardDecision
|
|
1454
|
+
cited_capsule: dict | None
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
@dataclass(frozen=True)
|
|
1458
|
+
class SaleCheck:
|
|
1459
|
+
"""A replayed check is one of a sale's (``report/sale_bundle.py``):
|
|
1460
|
+
``item_ref`` is the sale's key, ``None`` when it cannot be read."""
|
|
1461
|
+
|
|
1462
|
+
item_ref: str | None
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
# Writes the history of the sale a replayed record is a check of into the
|
|
1466
|
+
# ledger it is given, and says so; ``None`` for a record of no such sale. The
|
|
1467
|
+
# third argument, when not ``None``, cuts the history to the acts sealed in
|
|
1468
|
+
# that many days before the check, as a live check's history is cut.
|
|
1469
|
+
SaleHistory = Callable[[str, LedgerAPI, "int | None"], "SaleCheck | None"]
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
@dataclass(frozen=True)
|
|
1473
|
+
class ReplayResult:
|
|
1474
|
+
"""``decisions`` are the records that state an act, in order;
|
|
1475
|
+
``undecided`` the records the replay gave no decision
|
|
1476
|
+
(``_gets_no_decision``). ``record_range`` spans both."""
|
|
1477
|
+
|
|
1478
|
+
decisions: tuple[SourcedDecision, ...]
|
|
1479
|
+
record_range: tuple[int, int]
|
|
1480
|
+
undecided: tuple[dict, ...] = ()
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def replay(
|
|
1484
|
+
records: list[dict],
|
|
1485
|
+
*,
|
|
1486
|
+
caps_fold: FoldDefinition | None,
|
|
1487
|
+
caps_minor: dict[str, int] | None = None,
|
|
1488
|
+
manifest_digest: str | None = None,
|
|
1489
|
+
per_action_minor: dict[str, int] | None = None,
|
|
1490
|
+
per_action_reads: str | None = None,
|
|
1491
|
+
disclosed: dict[str, dict] | None = None,
|
|
1492
|
+
withheld: frozenset[str] = frozenset(),
|
|
1493
|
+
wickets: tuple[WicketDefinition, ...] = (),
|
|
1494
|
+
pack: PackDefinition | None = None,
|
|
1495
|
+
sale_history: SaleHistory | None = None,
|
|
1496
|
+
representing_openings: Mapping[str, dict] | None = None,
|
|
1497
|
+
) -> ReplayResult:
|
|
1498
|
+
"""Feed every record through a fresh ``GuardEngine`` in dry-run mode, in
|
|
1499
|
+
order. Never blocks (``dry_run=True``) -- see ``engine.py``'s own
|
|
1500
|
+
guarantee that a dry-run decision still produces and appends a real,
|
|
1501
|
+
signed capsule. ``record_range`` on the result is this replayed set's own
|
|
1502
|
+
1-based position range (i.e. positions within the ``--since``-filtered
|
|
1503
|
+
window actually replayed, not the source ledger's absolute positions).
|
|
1504
|
+
``wickets`` are configured checks run on every decision, as
|
|
1505
|
+
``GuardEngine(wickets=...)`` runs them; none by default. ``pack`` is the
|
|
1506
|
+
pack those wickets were installed from: given, the engine asks an approver
|
|
1507
|
+
on the same failures ``packs.build_engine`` does (``engine_ask_sets``);
|
|
1508
|
+
without it every gate or wicket failure refuses.
|
|
1509
|
+
``withheld`` are the records a bundle carries without disclosing
|
|
1510
|
+
(``load_withheld``); like every record that states no act, each gets no
|
|
1511
|
+
decision.
|
|
1512
|
+
When a deal's sealed records show that an act whose check this replay
|
|
1513
|
+
decided (allowed, or asked) was approved to proceed, by the user or under
|
|
1514
|
+
the user's standing intent, and then executed (``_carried_out``), the replay writes that act to its own view as
|
|
1515
|
+
carried out (``_carried_out_record``), once, when it reaches the action
|
|
1516
|
+
step, so a later check counts it as an earlier act with that
|
|
1517
|
+
counterparty. A refused check is never counted.
|
|
1518
|
+
Under a caps fold that counts executed acts (``spend.weekly/3.1.0``),
|
|
1519
|
+
every act a deal executed is also written to the view when the replay
|
|
1520
|
+
reaches it (``_executed_record``), whoever approved it and whether or not
|
|
1521
|
+
it was checked, so a later check's spend window counts it once. The
|
|
1522
|
+
replay's own decisions are dry runs and never count, so no act counts
|
|
1523
|
+
twice. Under any other fold nothing more is written, and a replay keeps
|
|
1524
|
+
the bytes it had.
|
|
1525
|
+
A claim the user's agent made to the counterparty is written to that view
|
|
1526
|
+
as a statement (``statement_record``) when the replay reaches it, so a
|
|
1527
|
+
later check in the same deal finds it made first. A claim the sources
|
|
1528
|
+
give more than once is written once.
|
|
1529
|
+
``representing_openings`` are the openings of represent checks' words
|
|
1530
|
+
the bundles' deal reports carry, by commitment
|
|
1531
|
+
(``load_representing_openings``); a check whose sealed commitment one
|
|
1532
|
+
recomputes is decided with it, as a live check is with its words.
|
|
1533
|
+
A check citing a task authority (``task_authority_ref``) is decided with
|
|
1534
|
+
the task-authority record the bundle discloses under that digest, when
|
|
1535
|
+
it discloses one (``_decide``), as a live check gets it beside its input.
|
|
1536
|
+
A seller's typed commit is decided with ``proposal_at`` the sealed time
|
|
1537
|
+
of the accepted offer it rests on, and a typed offer with the time of the
|
|
1538
|
+
counterparty's message it answers by its ``source`` ref
|
|
1539
|
+
(``_accepted_offer_at``), so ``offer_expiry`` reads that proposal's age.
|
|
1540
|
+
Each record is evaluated under the action taxonomy it was sealed with,
|
|
1541
|
+
where the engine carries that version, so history at a carried version is
|
|
1542
|
+
never refused for its version. A record naming one it does not carry
|
|
1543
|
+
still gets a decision: its class-keyed checks are held ``n/a`` with
|
|
1544
|
+
evidence naming both versions, and when nothing else fails it is refused
|
|
1545
|
+
(``GuardEngine``), so no later check counts it as seen or as spend.
|
|
1546
|
+
``sale_history`` is given for a replay of a sale bundle
|
|
1547
|
+
(``report/sale_bundle.py``, ``replay_sale``). A check it says is the
|
|
1548
|
+
sale's is decided with the sale's key as ``item_ref``, and
|
|
1549
|
+
``single_commitment`` reads the sale's acceptance from a fresh ledger it
|
|
1550
|
+
writes for that check alone (``GuardEngine.check``'s ``sale_history``),
|
|
1551
|
+
never from the replay's own view. A ``release_on_acceptance`` that binds
|
|
1552
|
+
the acceptance from the history (1.2.0) reads it too, cut to the acts of
|
|
1553
|
+
the last ``RULES_HISTORY_DAYS`` days before the check, as live
|
|
1554
|
+
(``GuardEngine.check``'s ``release_history``); within that window the
|
|
1555
|
+
same completeness rule holds."""
|
|
1556
|
+
if not records:
|
|
1557
|
+
return ReplayResult(decisions=(), record_range=(0, -1))
|
|
1558
|
+
|
|
1559
|
+
signer = LocalSigner(key_id="dry-run-report", secret=b"asg-guard-dry-run-report")
|
|
1560
|
+
|
|
1561
|
+
gate_selectors, ask_checks = engine_ask_sets(pack, wickets) if pack is not None else (frozenset(), frozenset())
|
|
1562
|
+
sourced: list[SourcedDecision] = []
|
|
1563
|
+
undecided: list[dict] = []
|
|
1564
|
+
with tempfile.TemporaryDirectory() as tmp, LedgerStore(tmp) as store:
|
|
1565
|
+
engine = GuardEngine(
|
|
1566
|
+
ledger=store,
|
|
1567
|
+
caps_fold=caps_fold,
|
|
1568
|
+
signer_provider=lambda: signer,
|
|
1569
|
+
caps_minor=caps_minor or {},
|
|
1570
|
+
per_action_minor=per_action_minor,
|
|
1571
|
+
per_action_reads=per_action_reads,
|
|
1572
|
+
manifest_digest=manifest_digest,
|
|
1573
|
+
wickets=wickets,
|
|
1574
|
+
ask_gate_selectors=gate_selectors,
|
|
1575
|
+
ask_wickets=ask_checks,
|
|
1576
|
+
evaluate_under_record_taxonomy=True,
|
|
1577
|
+
)
|
|
1578
|
+
profiles = _companion_profiles(records, disclosed or {})
|
|
1579
|
+
deal = _deal_records(records, disclosed or {})
|
|
1580
|
+
offered_at = _accepted_offer_at(records, disclosed or {})
|
|
1581
|
+
authorities = _task_authorities(disclosed or {})
|
|
1582
|
+
decided_checks: dict[str, SourcedDecision] = {}
|
|
1583
|
+
counts_spend = counts_executed_acts(caps_fold)
|
|
1584
|
+
executed_acts = ExecutedActs(records)
|
|
1585
|
+
written_statements: set[str] = set()
|
|
1586
|
+
for record in records:
|
|
1587
|
+
shown = (disclosed or {}).get(record.get("capsule_id", ""))
|
|
1588
|
+
digest = json_digest(shown) if shown is not None else None
|
|
1589
|
+
if _gets_no_decision(record, shown, withheld):
|
|
1590
|
+
undecided.append(record)
|
|
1591
|
+
stated = statement_record(record, shown)
|
|
1592
|
+
if stated is not None and claim_of(stated) not in written_statements:
|
|
1593
|
+
# A claim given twice (overlapping sources) is one statement.
|
|
1594
|
+
written_statements.add(claim_of(stated))
|
|
1595
|
+
store.append(dict(stated), consequential=False)
|
|
1596
|
+
chain = _carried_out(digest, deal) if digest is not None else None
|
|
1597
|
+
checked = decided_checks.pop(chain.check, None) if chain is not None else None
|
|
1598
|
+
if chain is not None and checked is not None and checked.decision.outcome in (ALLOW, ESCALATE):
|
|
1599
|
+
store.append(dict(_carried_out_record(checked, chain, _text(record.get("timestamp")))), consequential=False)
|
|
1600
|
+
if counts_spend and digest is not None and _bound(record, shown):
|
|
1601
|
+
executed = executed_acts.record(record, shown, digest)
|
|
1602
|
+
if executed is not None:
|
|
1603
|
+
store.append(dict(executed), consequential=False)
|
|
1604
|
+
continue
|
|
1605
|
+
profile = profiles.get(digest) if profiles and digest is not None else None
|
|
1606
|
+
action = action_for_record(record, shown, counterparty_profile=profile)
|
|
1607
|
+
if digest in offered_at:
|
|
1608
|
+
action = replace(action, proposal_at=offered_at[digest])
|
|
1609
|
+
authority = authorities.get(action.task_authority_ref) if action.task_authority_ref else None
|
|
1610
|
+
opening = (representing_openings or {}).get(action.representation_text_commitment or "")
|
|
1611
|
+
action, decision = _decide(engine, action, record, sale_history, authority, opening)
|
|
1612
|
+
cited_capsule = None
|
|
1613
|
+
for constraint in decision.constraints:
|
|
1614
|
+
cited_id = (constraint.evidence or {}).get("matched_capsule_id") or (
|
|
1615
|
+
constraint.evidence or {}
|
|
1616
|
+
).get("cited_capsule_id")
|
|
1617
|
+
if cited_id:
|
|
1618
|
+
found = store.fetch(cited_id)
|
|
1619
|
+
if found is not None:
|
|
1620
|
+
cited_capsule = found.capsule
|
|
1621
|
+
break
|
|
1622
|
+
sourced.append(SourcedDecision(record=record, action=action, decision=decision, cited_capsule=cited_capsule))
|
|
1623
|
+
if digest in deal and decision.capsule is not None:
|
|
1624
|
+
decided_checks[digest] = sourced[-1]
|
|
1625
|
+
|
|
1626
|
+
return ReplayResult(decisions=tuple(sourced), record_range=(1, len(records)), undecided=tuple(undecided))
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _task_authorities(disclosed: dict[str, dict]) -> dict[str, dict]:
|
|
1630
|
+
"""The task-authority records a bundle discloses (``task-authority/v0``),
|
|
1631
|
+
by the SHA-256 digest of each, the digest a check's
|
|
1632
|
+
``task_authority_ref`` names. A record with no digest (a float, an
|
|
1633
|
+
unsafe integer) is left out: no reference can name it."""
|
|
1634
|
+
found: dict[str, dict] = {}
|
|
1635
|
+
for shown in disclosed.values():
|
|
1636
|
+
if isinstance(shown, dict) and shown.get("type") == _TASK_AUTHORITY:
|
|
1637
|
+
try:
|
|
1638
|
+
found[json_digest(shown)] = shown
|
|
1639
|
+
except (FloatInDigestError, UnsafeIntegerError, UnicodeEncodeError):
|
|
1640
|
+
continue
|
|
1641
|
+
return found
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _decide(engine: GuardEngine, action: Action, record: dict, sale_history: SaleHistory | None,
|
|
1645
|
+
task_authority: dict | None = None, opening: dict | None = None) -> tuple[Action, GuardDecision]:
|
|
1646
|
+
"""The dry-run decision on ``action``, and the action decided: with the
|
|
1647
|
+
sale's key and the sale's history for a check of a replayed sale
|
|
1648
|
+
(``replay``), as given otherwise. ``task_authority`` is the record the
|
|
1649
|
+
bundle discloses under the digest the action's ``task_authority_ref``
|
|
1650
|
+
names (``_task_authorities``); the engine reads it only once its own
|
|
1651
|
+
digest of it equals that reference, as live, so a missing record leaves
|
|
1652
|
+
it unread (capsule-engine#180). ``opening`` is the opening of the
|
|
1653
|
+
represent check's words the bundle's deal report carries
|
|
1654
|
+
(``representing_openings``), passed as ``representing_opening`` so
|
|
1655
|
+
``promise_never`` judges them as live; with none,
|
|
1656
|
+
``WORDS_NOT_IN_BUNDLE``: a claim attributed to the user is then not
|
|
1657
|
+
evaluable ("the words for this attributed claim are not in the bundle"),
|
|
1658
|
+
never a failure (``guards/checks/promise_never.py``)."""
|
|
1659
|
+
capsule_id = _text(record.get("capsule_id"))
|
|
1660
|
+
|
|
1661
|
+
def check(decided: Action, **ledgers: LedgerAPI) -> GuardDecision:
|
|
1662
|
+
return engine.check(decided, dry_run=True, task_authority_record=task_authority,
|
|
1663
|
+
representing_opening=opening if opening is not None else WORDS_NOT_IN_BUNDLE,
|
|
1664
|
+
**ledgers)
|
|
1665
|
+
|
|
1666
|
+
if sale_history is None or capsule_id is None:
|
|
1667
|
+
return action, check(action)
|
|
1668
|
+
with tempfile.TemporaryDirectory() as tmp, LedgerStore(tmp) as sale_ledger:
|
|
1669
|
+
sale = sale_history(capsule_id, sale_ledger, None)
|
|
1670
|
+
if sale is None:
|
|
1671
|
+
return action, check(action)
|
|
1672
|
+
action = replace(action, item_ref=sale.item_ref)
|
|
1673
|
+
if not engine.uses_release_history:
|
|
1674
|
+
return action, check(action, sale_history=sale_ledger)
|
|
1675
|
+
with tempfile.TemporaryDirectory() as window_tmp, LedgerStore(window_tmp) as window_ledger:
|
|
1676
|
+
sale_history(capsule_id, window_ledger, RULES_HISTORY_DAYS)
|
|
1677
|
+
return action, check(action, sale_history=sale_ledger, release_history=window_ledger)
|