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,45 @@
|
|
|
1
|
+
{"metric": "install_seen", "arm": "guards-only", "install_id": "synthetic-g1", "value": true, "emitted_at": "2026-08-05T00:00:00Z"}
|
|
2
|
+
{"metric": "install_seen", "arm": "guards-only", "install_id": "synthetic-g2", "value": true, "emitted_at": "2026-08-05T00:00:01Z"}
|
|
3
|
+
{"metric": "install_seen", "arm": "guards-only", "install_id": "synthetic-g3", "value": true, "emitted_at": "2026-08-05T00:00:02Z"}
|
|
4
|
+
{"metric": "install_seen", "arm": "guards-only", "install_id": "synthetic-g4", "value": true, "emitted_at": "2026-08-05T00:00:03Z"}
|
|
5
|
+
{"metric": "install_seen", "arm": "guards-only", "install_id": "synthetic-g5", "value": true, "emitted_at": "2026-08-05T00:00:04Z"}
|
|
6
|
+
{"metric": "install_seen", "arm": "guards-only", "install_id": "synthetic-g6", "value": true, "emitted_at": "2026-08-05T00:00:05Z"}
|
|
7
|
+
{"metric": "install_seen", "arm": "guards-only", "install_id": "synthetic-g7", "value": true, "emitted_at": "2026-08-05T00:00:06Z"}
|
|
8
|
+
{"metric": "install_seen", "arm": "guards-only", "install_id": "synthetic-g8", "value": true, "emitted_at": "2026-08-05T00:00:07Z"}
|
|
9
|
+
{"metric": "install_seen", "arm": "guards-only", "install_id": "synthetic-g9", "value": true, "emitted_at": "2026-08-05T00:00:08Z"}
|
|
10
|
+
{"metric": "install_seen", "arm": "guards-only", "install_id": "synthetic-g10", "value": true, "emitted_at": "2026-08-05T00:00:09Z"}
|
|
11
|
+
{"metric": "m1_activation", "arm": "guards-only", "install_id": "synthetic-g1", "value": true, "emitted_at": "2026-08-05T04:00:00Z"}
|
|
12
|
+
{"metric": "m1_activation", "arm": "guards-only", "install_id": "synthetic-g2", "value": true, "emitted_at": "2026-08-05T04:00:01Z"}
|
|
13
|
+
{"metric": "m1_activation", "arm": "guards-only", "install_id": "synthetic-g3", "value": true, "emitted_at": "2026-08-05T04:00:02Z"}
|
|
14
|
+
{"metric": "m1_activation", "arm": "guards-only", "install_id": "synthetic-g4", "value": true, "emitted_at": "2026-08-05T04:00:03Z"}
|
|
15
|
+
{"metric": "m2_enforcement_on", "arm": "guards-only", "install_id": "synthetic-g1", "value": true, "emitted_at": "2026-08-12T00:00:00Z"}
|
|
16
|
+
{"metric": "m2_enforcement_on", "arm": "guards-only", "install_id": "synthetic-g2", "value": true, "emitted_at": "2026-08-12T00:00:01Z"}
|
|
17
|
+
{"metric": "m3_day14_alive", "arm": "guards-only", "install_id": "synthetic-g1", "value": true, "emitted_at": "2026-08-18T00:00:00Z"}
|
|
18
|
+
{"metric": "m3_day14_alive", "arm": "guards-only", "install_id": "synthetic-g2", "value": true, "emitted_at": "2026-08-18T00:00:01Z"}
|
|
19
|
+
{"metric": "m3_day14_alive", "arm": "guards-only", "install_id": "synthetic-g3", "value": true, "emitted_at": "2026-08-18T00:00:02Z"}
|
|
20
|
+
{"metric": "install_seen", "arm": "full", "install_id": "synthetic-f1", "value": true, "emitted_at": "2026-08-05T00:00:00Z"}
|
|
21
|
+
{"metric": "install_seen", "arm": "full", "install_id": "synthetic-f2", "value": true, "emitted_at": "2026-08-05T00:00:01Z"}
|
|
22
|
+
{"metric": "install_seen", "arm": "full", "install_id": "synthetic-f3", "value": true, "emitted_at": "2026-08-05T00:00:02Z"}
|
|
23
|
+
{"metric": "install_seen", "arm": "full", "install_id": "synthetic-f4", "value": true, "emitted_at": "2026-08-05T00:00:03Z"}
|
|
24
|
+
{"metric": "install_seen", "arm": "full", "install_id": "synthetic-f5", "value": true, "emitted_at": "2026-08-05T00:00:04Z"}
|
|
25
|
+
{"metric": "install_seen", "arm": "full", "install_id": "synthetic-f6", "value": true, "emitted_at": "2026-08-05T00:00:05Z"}
|
|
26
|
+
{"metric": "install_seen", "arm": "full", "install_id": "synthetic-f7", "value": true, "emitted_at": "2026-08-05T00:00:06Z"}
|
|
27
|
+
{"metric": "install_seen", "arm": "full", "install_id": "synthetic-f8", "value": true, "emitted_at": "2026-08-05T00:00:07Z"}
|
|
28
|
+
{"metric": "install_seen", "arm": "full", "install_id": "synthetic-f9", "value": true, "emitted_at": "2026-08-05T00:00:08Z"}
|
|
29
|
+
{"metric": "install_seen", "arm": "full", "install_id": "synthetic-f10", "value": true, "emitted_at": "2026-08-05T00:00:09Z"}
|
|
30
|
+
{"metric": "m1_activation", "arm": "full", "install_id": "synthetic-f1", "value": true, "emitted_at": "2026-08-05T04:00:00Z"}
|
|
31
|
+
{"metric": "m1_activation", "arm": "full", "install_id": "synthetic-f2", "value": true, "emitted_at": "2026-08-05T04:00:01Z"}
|
|
32
|
+
{"metric": "m1_activation", "arm": "full", "install_id": "synthetic-f3", "value": true, "emitted_at": "2026-08-05T04:00:02Z"}
|
|
33
|
+
{"metric": "m1_activation", "arm": "full", "install_id": "synthetic-f4", "value": true, "emitted_at": "2026-08-05T04:00:03Z"}
|
|
34
|
+
{"metric": "m1_activation", "arm": "full", "install_id": "synthetic-f5", "value": true, "emitted_at": "2026-08-05T04:00:04Z"}
|
|
35
|
+
{"metric": "m2_enforcement_on", "arm": "full", "install_id": "synthetic-f1", "value": true, "emitted_at": "2026-08-12T00:00:00Z"}
|
|
36
|
+
{"metric": "m3_day14_alive", "arm": "full", "install_id": "synthetic-f1", "value": true, "emitted_at": "2026-08-18T00:00:00Z"}
|
|
37
|
+
{"metric": "m3_day14_alive", "arm": "full", "install_id": "synthetic-f2", "value": true, "emitted_at": "2026-08-18T00:00:01Z"}
|
|
38
|
+
{"metric": "m5_evidence_pull", "arm": "full", "install_id": "synthetic-f1", "value": true, "emitted_at": "2026-08-06T00:00:00Z"}
|
|
39
|
+
{"metric": "m5_evidence_pull", "arm": "full", "install_id": "synthetic-f3", "value": true, "emitted_at": "2026-08-06T00:00:01Z"}
|
|
40
|
+
{"metric": "m6_report_opened", "report_token": "synthetic-tok-A", "emitted_at": "2026-08-06T00:00:00Z"}
|
|
41
|
+
{"metric": "m6_report_opened", "report_token": "synthetic-tok-A", "emitted_at": "2026-08-06T01:00:00Z"}
|
|
42
|
+
{"metric": "m6_report_opened", "report_token": "synthetic-tok-A", "emitted_at": "2026-08-06T02:00:00Z"}
|
|
43
|
+
{"metric": "m6_report_opened", "report_token": "synthetic-tok-B", "emitted_at": "2026-08-07T00:00:00Z"}
|
|
44
|
+
{"metric": "m6_report_opened", "report_token": "synthetic-tok-B", "emitted_at": "2026-08-07T05:00:00Z"}
|
|
45
|
+
{"metric": "m6_report_opened", "report_token": "synthetic-tok-C", "emitted_at": "2026-08-08T00:00:00Z"}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The 6-metric funnel report: a reporting tool over already-collected
|
|
3
|
+
telemetry events, not the collection itself. Renders exactly the six
|
|
4
|
+
metrics this package's instrumentation emits, as raw counts/rates -- and
|
|
5
|
+
nothing else. No pass/worry/fail banding, no thresholds, no verdict:
|
|
6
|
+
turning a rate into a judgment is a decision made elsewhere, later,
|
|
7
|
+
against a table this repository does not carry. See ``events.py``'s module
|
|
8
|
+
docstring for the same point made about the events themselves.
|
|
9
|
+
|
|
10
|
+
Two arms are always both present in the output (even a synthetic run with
|
|
11
|
+
data for only one arm shows the other at 0/0) because the report's own
|
|
12
|
+
shape -- what gets rendered -- must not depend on which arm happens to have
|
|
13
|
+
data; only the numbers may vary.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from ..packaging import FULL, GUARDS_ONLY
|
|
22
|
+
from .events import ALLOWED_FIELDS
|
|
23
|
+
|
|
24
|
+
ARMS = (GUARDS_ONLY, FULL)
|
|
25
|
+
|
|
26
|
+
__all__ = ["ArmMetric", "FunnelReport", "compute_funnel", "render_funnel_report"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ArmMetric:
|
|
31
|
+
"""A raw count out of a raw count. ``rate`` is arithmetic only --
|
|
32
|
+
``None`` (not 0) when the denominator is 0, so a report never implies
|
|
33
|
+
"0%" for a metric that simply has no data yet."""
|
|
34
|
+
|
|
35
|
+
numerator: int
|
|
36
|
+
denominator: int
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def rate(self) -> float | None:
|
|
40
|
+
return (self.numerator / self.denominator) if self.denominator else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class FunnelReport:
|
|
45
|
+
m1_activation: dict[str, ArmMetric]
|
|
46
|
+
m2_enforcement_on: dict[str, ArmMetric]
|
|
47
|
+
m3_day14_alive: dict[str, ArmMetric]
|
|
48
|
+
m4_evidence_tax: float | None
|
|
49
|
+
m5_evidence_pull: ArmMetric
|
|
50
|
+
m6_viral_unit: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _validate(raw: dict) -> None:
|
|
54
|
+
"""Same schema check ``sink.emit`` applies on the way out -- re-applied
|
|
55
|
+
on the way in, so a funnel run over an untrusted/hand-edited events file
|
|
56
|
+
still cannot smuggle ledger-shaped content into the report."""
|
|
57
|
+
if set(raw) - {"report_token"} - ALLOWED_FIELDS:
|
|
58
|
+
raise ValueError(f"telemetry event carries unexpected fields: {set(raw) - ALLOWED_FIELDS - {'report_token'}}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def compute_funnel(raw_events: Iterable[dict]) -> FunnelReport:
|
|
62
|
+
installs_seen: dict[str, set[str]] = defaultdict(set) # arm -> install_ids
|
|
63
|
+
activated: dict[str, set[str]] = defaultdict(set)
|
|
64
|
+
enforced: dict[str, set[str]] = defaultdict(set)
|
|
65
|
+
alive: dict[str, set[str]] = defaultdict(set)
|
|
66
|
+
evidence_pulled: set[str] = set()
|
|
67
|
+
report_opens: dict[str, int] = defaultdict(int)
|
|
68
|
+
|
|
69
|
+
for raw in raw_events:
|
|
70
|
+
_validate(raw)
|
|
71
|
+
metric = raw.get("metric")
|
|
72
|
+
arm = raw.get("arm")
|
|
73
|
+
install_id = raw.get("install_id")
|
|
74
|
+
value = raw.get("value")
|
|
75
|
+
|
|
76
|
+
if metric == "m6_report_opened":
|
|
77
|
+
token = raw.get("report_token")
|
|
78
|
+
if token:
|
|
79
|
+
report_opens[token] += 1
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if arm not in ARMS or not install_id:
|
|
83
|
+
continue
|
|
84
|
+
if metric == "install_seen":
|
|
85
|
+
installs_seen[arm].add(install_id)
|
|
86
|
+
elif metric == "m1_activation" and value:
|
|
87
|
+
activated[arm].add(install_id)
|
|
88
|
+
elif metric == "m2_enforcement_on" and value:
|
|
89
|
+
enforced[arm].add(install_id)
|
|
90
|
+
elif metric == "m3_day14_alive" and value:
|
|
91
|
+
alive[arm].add(install_id)
|
|
92
|
+
elif metric == "m5_evidence_pull" and value:
|
|
93
|
+
evidence_pulled.add(install_id)
|
|
94
|
+
|
|
95
|
+
m1 = {arm: ArmMetric(len(activated[arm]), len(installs_seen[arm])) for arm in ARMS}
|
|
96
|
+
m2 = {arm: ArmMetric(len(enforced[arm]), len(activated[arm])) for arm in ARMS}
|
|
97
|
+
m3 = {arm: ArmMetric(len(alive[arm]), len(activated[arm])) for arm in ARMS}
|
|
98
|
+
|
|
99
|
+
guards_only_rate = m1["guards-only"].rate
|
|
100
|
+
full_rate = m1["full"].rate
|
|
101
|
+
m4 = (full_rate / guards_only_rate) if guards_only_rate else None
|
|
102
|
+
|
|
103
|
+
m5 = ArmMetric(len(evidence_pulled & activated["full"]), len(activated["full"]))
|
|
104
|
+
|
|
105
|
+
m6 = sum(1 for count in report_opens.values() if count >= 2)
|
|
106
|
+
|
|
107
|
+
return FunnelReport(
|
|
108
|
+
m1_activation=m1,
|
|
109
|
+
m2_enforcement_on=m2,
|
|
110
|
+
m3_day14_alive=m3,
|
|
111
|
+
m4_evidence_tax=m4,
|
|
112
|
+
m5_evidence_pull=m5,
|
|
113
|
+
m6_viral_unit=m6,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _fmt_rate(metric: ArmMetric) -> str:
|
|
118
|
+
if metric.rate is None:
|
|
119
|
+
return f"n/a ({metric.numerator}/{metric.denominator})"
|
|
120
|
+
return f"{metric.rate:.1%} ({metric.numerator}/{metric.denominator})"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def render_funnel_report(report: FunnelReport) -> str:
|
|
124
|
+
"""Plain text: the six metrics, and nothing else."""
|
|
125
|
+
lines = ["capsule-ledger two-arm funnel report", "=" * 33, ""]
|
|
126
|
+
|
|
127
|
+
def arm_line(label: str, metrics: dict[str, ArmMetric]) -> None:
|
|
128
|
+
lines.append(label)
|
|
129
|
+
for arm in ARMS:
|
|
130
|
+
lines.append(f" {arm:<11} {_fmt_rate(metrics[arm])}")
|
|
131
|
+
|
|
132
|
+
arm_line("M1 activation (>=1 guard configured near install)", report.m1_activation)
|
|
133
|
+
lines.append("")
|
|
134
|
+
arm_line("M2 enforcement-on (dry_run -> enforce, of activated)", report.m2_enforcement_on)
|
|
135
|
+
lines.append("")
|
|
136
|
+
arm_line("M3 day-14-alive (guard evaluated recently, of activated)", report.m3_day14_alive)
|
|
137
|
+
lines.append("")
|
|
138
|
+
lines.append("M4 evidence tax (full M1 rate / guards-only M1 rate)")
|
|
139
|
+
lines.append(f" {report.m4_evidence_tax:.2f}" if report.m4_evidence_tax is not None else " n/a")
|
|
140
|
+
lines.append("")
|
|
141
|
+
lines.append("M5 evidence-pull (full arm only, of activated)")
|
|
142
|
+
lines.append(f" {_fmt_rate(report.m5_evidence_pull)}")
|
|
143
|
+
lines.append("")
|
|
144
|
+
lines.append("M6 viral unit (share link opened by a distinct second party)")
|
|
145
|
+
lines.append(f" {report.m6_viral_unit} distinct instance(s)")
|
|
146
|
+
lines.append("")
|
|
147
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""CLI-facing hooks: update local state idempotently, emit the corresponding
|
|
3
|
+
metric event -- all of it, including the local state file itself, gated on
|
|
4
|
+
``consent.is_opted_in()``. An install that never opts in leaves no
|
|
5
|
+
telemetry footprint at all: no state file, no local bookkeeping, nothing.
|
|
6
|
+
|
|
7
|
+
Each function only ever fires its event the first time the underlying fact
|
|
8
|
+
becomes true for this install (a guard is "configured" once; re-running
|
|
9
|
+
``guard dry-run --cap ...`` a second time updates nothing and emits
|
|
10
|
+
nothing new) -- these are one-shot facts, matching what ``events.py``'s
|
|
11
|
+
docstring says M1/M2/M5 are. ``record_guard_evaluated`` re-affirms
|
|
12
|
+
"alive" on every real evaluation (the state timestamp always advances) but
|
|
13
|
+
is still summarized centrally as a single per-install yes/no fact (M3), not
|
|
14
|
+
a per-evaluation count. ``record_install_seen`` is not one of the six
|
|
15
|
+
metrics -- it is the denominator bookkeeping (this install exists, in this
|
|
16
|
+
arm) the central funnel report needs to turn the six one-shot facts into
|
|
17
|
+
rates; see ``funnel.py``.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
|
|
23
|
+
from . import events
|
|
24
|
+
from .consent import is_opted_in
|
|
25
|
+
from .sink import EventSink, emit
|
|
26
|
+
from .state import TelemetryState, load_state, save_state
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"record_install_seen",
|
|
30
|
+
"record_guard_configured",
|
|
31
|
+
"record_enforcement_flip",
|
|
32
|
+
"record_guard_evaluated",
|
|
33
|
+
"record_evidence_touch",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
_INSTALL_SEEN_METRIC = "install_seen"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _now_utc() -> str:
|
|
40
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _emit_bookkeeping(install_id: str, arm: str, *, sink: EventSink | None) -> None:
|
|
44
|
+
"""Not one of the six metrics -- the denominator fact an install exists
|
|
45
|
+
in a given arm, so the funnel report can turn one-shot facts into rates
|
|
46
|
+
without needing an external cohort list."""
|
|
47
|
+
emit(
|
|
48
|
+
events.MetricEvent(metric=_INSTALL_SEEN_METRIC, arm=arm, install_id=install_id, value=True, emitted_at=_now_utc()),
|
|
49
|
+
opted_in=True,
|
|
50
|
+
sink=sink,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def record_install_seen(arm: str, *, sink: EventSink | None = None) -> None:
|
|
55
|
+
if not is_opted_in():
|
|
56
|
+
return
|
|
57
|
+
s = load_state()
|
|
58
|
+
_emit_bookkeeping(s.install_id, arm, sink=sink)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def record_guard_configured(arm: str, *, sink: EventSink | None = None, state: TelemetryState | None = None) -> None:
|
|
62
|
+
if not is_opted_in():
|
|
63
|
+
return
|
|
64
|
+
s = state or load_state()
|
|
65
|
+
if s.first_guard_configured_at is not None:
|
|
66
|
+
return
|
|
67
|
+
s.first_guard_configured_at = _now_utc()
|
|
68
|
+
save_state(s)
|
|
69
|
+
emit(events.m1_activation_event(install_id=s.install_id, arm=arm), opted_in=True, sink=sink)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def record_enforcement_flip(arm: str, *, sink: EventSink | None = None, state: TelemetryState | None = None) -> None:
|
|
73
|
+
if not is_opted_in():
|
|
74
|
+
return
|
|
75
|
+
s = state or load_state()
|
|
76
|
+
if s.enforce_flipped_at is not None:
|
|
77
|
+
return
|
|
78
|
+
s.enforce_flipped_at = _now_utc()
|
|
79
|
+
save_state(s)
|
|
80
|
+
emit(events.m2_enforcement_on_event(install_id=s.install_id, arm=arm), opted_in=True, sink=sink)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def record_guard_evaluated(arm: str, *, sink: EventSink | None = None, state: TelemetryState | None = None) -> None:
|
|
84
|
+
if not is_opted_in():
|
|
85
|
+
return
|
|
86
|
+
s = state or load_state()
|
|
87
|
+
already_alive = s.last_guard_evaluated_at is not None
|
|
88
|
+
s.last_guard_evaluated_at = _now_utc()
|
|
89
|
+
save_state(s)
|
|
90
|
+
if not already_alive:
|
|
91
|
+
emit(events.m3_day14_alive_event(install_id=s.install_id, arm=arm), opted_in=True, sink=sink)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def record_evidence_touch(arm: str, *, sink: EventSink | None = None, state: TelemetryState | None = None) -> None:
|
|
95
|
+
"""M5, full arm only -- callers already gate this on the arm (the
|
|
96
|
+
evidence-surfacing commands are only registered in the full arm), but
|
|
97
|
+
this function re-asserts it so a future caller can't emit a
|
|
98
|
+
meaningless guards-only M5 fact by mistake."""
|
|
99
|
+
if arm != "full" or not is_opted_in():
|
|
100
|
+
return
|
|
101
|
+
s = state or load_state()
|
|
102
|
+
if s.evidence_touched_at is not None:
|
|
103
|
+
return
|
|
104
|
+
s.evidence_touched_at = _now_utc()
|
|
105
|
+
save_state(s)
|
|
106
|
+
emit(events.m5_evidence_pull_event(install_id=s.install_id, arm=arm), opted_in=True, sink=sink)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Where an opted-in event goes. No sink here ever makes a network call --
|
|
3
|
+
this package ships no telemetry backend and points at none by default; a
|
|
4
|
+
real transport is a deployment-time concern for whoever operates one,
|
|
5
|
+
wired in by passing a different ``EventSink``, never by editing this file.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from ..envcompat import env_get
|
|
14
|
+
from .events import ALLOWED_FIELDS, MetricEvent
|
|
15
|
+
|
|
16
|
+
__all__ = ["EventSink", "NullSink", "LocalJSONLSink", "default_sink", "emit"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EventSink(Protocol):
|
|
20
|
+
def write(self, event: MetricEvent) -> None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NullSink:
|
|
24
|
+
"""The default: opted-in or not, nothing leaves this process."""
|
|
25
|
+
|
|
26
|
+
def write(self, event: MetricEvent) -> None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LocalJSONLSink:
|
|
31
|
+
"""Append-only local file, one JSON object per line. Intended for
|
|
32
|
+
operators who want to inspect or forward their own install's telemetry
|
|
33
|
+
themselves -- this package never reads this file back or transmits it."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, path: str | Path) -> None:
|
|
36
|
+
self._path = Path(path)
|
|
37
|
+
|
|
38
|
+
def write(self, event: MetricEvent) -> None:
|
|
39
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with self._path.open("a", encoding="utf-8") as fh:
|
|
41
|
+
fh.write(json.dumps(event.to_dict(), sort_keys=True) + "\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def default_sink() -> EventSink:
|
|
45
|
+
path = env_get("CAPSULE_LEDGER_TELEMETRY_SINK_PATH")
|
|
46
|
+
return LocalJSONLSink(path) if path else NullSink()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def emit(event: MetricEvent, *, opted_in: bool, sink: EventSink | None = None) -> bool:
|
|
50
|
+
"""Write ``event`` to ``sink`` iff ``opted_in``. Returns whether it was
|
|
51
|
+
written. Re-validates the payload's keys against the fixed schema before
|
|
52
|
+
writing -- belt and suspenders alongside ``MetricEvent`` itself being a
|
|
53
|
+
closed dataclass, so a future edit to this module can't silently widen
|
|
54
|
+
what a payload may carry."""
|
|
55
|
+
if not opted_in:
|
|
56
|
+
return False
|
|
57
|
+
payload = event.to_dict()
|
|
58
|
+
if set(payload) != ALLOWED_FIELDS:
|
|
59
|
+
raise ValueError(f"telemetry payload carries unexpected fields: {set(payload) - ALLOWED_FIELDS}")
|
|
60
|
+
(sink or default_sink()).write(event)
|
|
61
|
+
return True
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Local, per-install state the six metrics are computed from.
|
|
3
|
+
|
|
4
|
+
Stored as a small JSON file outside the ledger and outside any repo --
|
|
5
|
+
never ledger data, never PII: a random install id and a handful of "first
|
|
6
|
+
time this happened" timestamps. Written regardless of telemetry opt-in (the
|
|
7
|
+
facts are needed locally either way, e.g. for ``capsule telemetry status``);
|
|
8
|
+
whether they are ever *emitted* anywhere is gated separately by
|
|
9
|
+
``consent.is_opted_in()``.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import asdict, dataclass, fields
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..envcompat import env_get
|
|
20
|
+
|
|
21
|
+
__all__ = ["TelemetryState", "state_path", "load_state", "save_state"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _utc_now() -> str:
|
|
25
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def state_path() -> Path:
|
|
29
|
+
override = env_get("CAPSULE_LEDGER_STATE_DIR")
|
|
30
|
+
base = Path(override) if override else Path.home() / ".local" / "state" / "capsule-ledger"
|
|
31
|
+
return base / "telemetry_state.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class TelemetryState:
|
|
36
|
+
install_id: str
|
|
37
|
+
install_at: str
|
|
38
|
+
first_guard_configured_at: str | None = None
|
|
39
|
+
enforce_flipped_at: str | None = None
|
|
40
|
+
last_guard_evaluated_at: str | None = None
|
|
41
|
+
evidence_touched_at: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_state(path: Path | None = None) -> TelemetryState:
|
|
45
|
+
"""Load the local state, creating it (a fresh random install id, "now"
|
|
46
|
+
as the install timestamp) on first ever call."""
|
|
47
|
+
p = path or state_path()
|
|
48
|
+
if p.exists():
|
|
49
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
50
|
+
known = {f.name for f in fields(TelemetryState)}
|
|
51
|
+
return TelemetryState(**{k: v for k, v in raw.items() if k in known})
|
|
52
|
+
state = TelemetryState(install_id=str(uuid.uuid4()), install_at=_utc_now())
|
|
53
|
+
save_state(state, p)
|
|
54
|
+
return state
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save_state(state: TelemetryState, path: Path | None = None) -> None:
|
|
58
|
+
p = path or state_path()
|
|
59
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
p.write_text(json.dumps(asdict(state), indent=2, sort_keys=True), encoding="utf-8")
|