apsimo 1.3.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.
- apsimo/__init__.py +38 -0
- apsimo/__main__.py +6 -0
- apsimo/agent/__init__.py +6 -0
- apsimo/agent/client.py +276 -0
- apsimo/agent/models.py +46 -0
- apsimo/agents/__init__.py +20 -0
- apsimo/agents/models.py +264 -0
- apsimo/agents/store.py +861 -0
- apsimo/agents/websocket.py +522 -0
- apsimo/api/__init__.py +1 -0
- apsimo/api/auth_telemetry.py +287 -0
- apsimo/api/authority.py +1203 -0
- apsimo/api/contact_grants.py +347 -0
- apsimo/api/middleware.py +483 -0
- apsimo/api/routers/__init__.py +1 -0
- apsimo/api/routers/commitment_work.py +265 -0
- apsimo/api/routers/context_gate.py +123 -0
- apsimo/api/routers/executions.py +140 -0
- apsimo/api/routers/followup_plans.py +147 -0
- apsimo/api/routers/governed_actions.py +162 -0
- apsimo/api/routers/host.py +14473 -0
- apsimo/api/routers/initiative_work.py +115 -0
- apsimo/api/routers/mining.py +104 -0
- apsimo/api/routers/observations.py +110 -0
- apsimo/api/routers/social_state.py +225 -0
- apsimo/api/routers/task_queue.py +2715 -0
- apsimo/api/routers/temporal_followups.py +251 -0
- apsimo/api/routers/transport.py +110 -0
- apsimo/api/routers/transport_ingress_api.py +240 -0
- apsimo/api/schemas/__init__.py +1 -0
- apsimo/api/schemas/host.py +1949 -0
- apsimo/autonomy/cli.py +110 -0
- apsimo/autonomy/condition_worker.py +437 -0
- apsimo/autonomy/config.py +424 -0
- apsimo/autonomy/loop.py +4316 -0
- apsimo/autonomy/registry.py +339 -0
- apsimo/autonomy/scheduler.py +1822 -0
- apsimo/autonomy/synthesis.py +449 -0
- apsimo/backup.py +962 -0
- apsimo/beliefs/__init__.py +23 -0
- apsimo/beliefs/contradictions.py +109 -0
- apsimo/beliefs/decay.py +61 -0
- apsimo/beliefs/engine.py +479 -0
- apsimo/beliefs/models.py +67 -0
- apsimo/beliefs/promotion.py +41 -0
- apsimo/beliefs/resolve.py +58 -0
- apsimo/beliefs/source_claims.py +690 -0
- apsimo/beliefs/source_projection.py +883 -0
- apsimo/beliefs/source_time.py +208 -0
- apsimo/beliefs/store.py +133 -0
- apsimo/briefings/aggregators.py +824 -0
- apsimo/briefings/composer.py +420 -0
- apsimo/briefings/config.py +55 -0
- apsimo/briefings/delivery.py +439 -0
- apsimo/briefings/engagement.py +97 -0
- apsimo/briefings/engine.py +274 -0
- apsimo/briefings/enhancer.py +99 -0
- apsimo/briefings/models.py +183 -0
- apsimo/briefings/scheduler.py +382 -0
- apsimo/briefings/store.py +435 -0
- apsimo/chain/__init__.py +48 -0
- apsimo/chain/block.py +100 -0
- apsimo/chain/cli.py +704 -0
- apsimo/chain/genesis.py +443 -0
- apsimo/chain/identity.py +416 -0
- apsimo/chain/keys.py +1025 -0
- apsimo/chain/local_keys.py +187 -0
- apsimo/chain/manager.py +290 -0
- apsimo/chain/node.py +163 -0
- apsimo/chain/plugin_transactions.py +371 -0
- apsimo/chain/protocol.py +220 -0
- apsimo/chain/state_machine.py +676 -0
- apsimo/chain/storage.py +503 -0
- apsimo/chain/transactions.py +250 -0
- apsimo/chain/validation.py +397 -0
- apsimo/channels/__init__.py +1 -0
- apsimo/channels/manifest.py +31 -0
- apsimo/channels/migrations/001_channels_schema.sql +12 -0
- apsimo/channels/phone_gateways.py +42 -0
- apsimo/channels/presence.py +188 -0
- apsimo/channels/router.py +235 -0
- apsimo/channels/store.py +231 -0
- apsimo/cli.py +2688 -0
- apsimo/cognition/__init__.py +11 -0
- apsimo/cognition/charter.py +398 -0
- apsimo/cognition/drive_governance.py +3530 -0
- apsimo/cognition/evidence_pipeline.py +1627 -0
- apsimo/cognition/external_events.py +932 -0
- apsimo/cognition/goal_spine.py +3488 -0
- apsimo/cognition/introspection.py +214 -0
- apsimo/cognition/prompt.py +150 -0
- apsimo/cognition/runtime.py +108 -0
- apsimo/cognition/trigger.py +154 -0
- apsimo/commitments/__init__.py +18 -0
- apsimo/commitments/local_work.py +355 -0
- apsimo/commitments/store.py +1052 -0
- apsimo/commitments/work.py +91 -0
- apsimo/compat.py +53 -0
- apsimo/compression/__init__.py +467 -0
- apsimo/connectors/__init__.py +21 -0
- apsimo/connectors/base.py +152 -0
- apsimo/connectors/caldav_calendar.py +125 -0
- apsimo/connectors/fs_documents.py +85 -0
- apsimo/connectors/imap_email.py +138 -0
- apsimo/connectors/manager.py +218 -0
- apsimo/connectors/webhook_pull.py +88 -0
- apsimo/contacts/__init__.py +33 -0
- apsimo/contacts/comms.py +357 -0
- apsimo/contacts/config.py +79 -0
- apsimo/contacts/exporters/__init__.py +1 -0
- apsimo/contacts/exporters/vcard.py +71 -0
- apsimo/contacts/identity_links.py +251 -0
- apsimo/contacts/importer.py +280 -0
- apsimo/contacts/importers/__init__.py +1 -0
- apsimo/contacts/importers/batch.py +43 -0
- apsimo/contacts/importers/macos_contacts.py +101 -0
- apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
- apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
- apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
- apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
- apsimo/contacts/migrations/005_identity_links.sql +27 -0
- apsimo/contacts/models.py +308 -0
- apsimo/contacts/scoring.py +16 -0
- apsimo/contacts/store.py +1623 -0
- apsimo/contacts/transport_ingress.py +252 -0
- apsimo/contacts/world_bridge.py +314 -0
- apsimo/contextgate/__init__.py +69 -0
- apsimo/contextgate/chunker.py +169 -0
- apsimo/contextgate/estimate.py +54 -0
- apsimo/contextgate/gate.py +313 -0
- apsimo/contextgate/retrieve.py +115 -0
- apsimo/delivery/__init__.py +16 -0
- apsimo/delivery/bridge.py +1260 -0
- apsimo/delivery/channels.py +526 -0
- apsimo/delivery/classification.py +50 -0
- apsimo/delivery/rate_limiter.py +268 -0
- apsimo/delivery/reachout_policy.py +206 -0
- apsimo/directed/__init__.py +22 -0
- apsimo/directed/audit.py +167 -0
- apsimo/directed/intake.py +95 -0
- apsimo/directed/models.py +191 -0
- apsimo/directed/service.py +509 -0
- apsimo/directives/__init__.py +25 -0
- apsimo/directives/evidence.py +87 -0
- apsimo/directives/extractor.py +188 -0
- apsimo/directives/guard.py +364 -0
- apsimo/directives/models.py +206 -0
- apsimo/directives/service.py +372 -0
- apsimo/directives/store.py +167 -0
- apsimo/doctor.py +2173 -0
- apsimo/environment.py +43 -0
- apsimo/events/__init__.py +33 -0
- apsimo/events/broadcaster.py +98 -0
- apsimo/events/bus.py +217 -0
- apsimo/events/journal.py +863 -0
- apsimo/events/stream.py +131 -0
- apsimo/events/types.py +150 -0
- apsimo/execution_results.py +357 -0
- apsimo/feedback/__init__.py +5 -0
- apsimo/feedback/store.py +76 -0
- apsimo/feeds/__init__.py +19 -0
- apsimo/feeds/cli.py +84 -0
- apsimo/feeds/engine.py +437 -0
- apsimo/feeds/example-feed.yaml +77 -0
- apsimo/feeds/hermes_cron.py +126 -0
- apsimo/feeds/manager.py +235 -0
- apsimo/feeds/spec.py +250 -0
- apsimo/feeds/template.py +202 -0
- apsimo/gate/__init__.py +18 -0
- apsimo/gate/audit.py +61 -0
- apsimo/gate/communication_policy.py +166 -0
- apsimo/gate/config.py +72 -0
- apsimo/gate/context_provenance.py +170 -0
- apsimo/gate/env_risk.py +226 -0
- apsimo/gate/guard_audit.py +353 -0
- apsimo/gate/layers/__init__.py +1 -0
- apsimo/gate/layers/base.py +15 -0
- apsimo/gate/layers/l1_recipient.py +66 -0
- apsimo/gate/layers/l2_pii.py +134 -0
- apsimo/gate/layers/l3_cross_context.py +50 -0
- apsimo/gate/layers/l4_trust_tier.py +78 -0
- apsimo/gate/layers/l5_injection.py +199 -0
- apsimo/gate/layers/l6_review.py +86 -0
- apsimo/gate/layers/l7_delay.py +100 -0
- apsimo/gate/layers/tom2_epistemic.py +185 -0
- apsimo/gate/models.py +64 -0
- apsimo/gate/pending_dispatch.py +5 -0
- apsimo/gate/pipeline.py +206 -0
- apsimo/gate/rejection.py +259 -0
- apsimo/gate/response_guard.py +700 -0
- apsimo/gate/rulesets/injection_v1.yaml +51 -0
- apsimo/gate/surface_policy.py +189 -0
- apsimo/gate/taint.py +226 -0
- apsimo/genesis.json +9 -0
- apsimo/goals/__init__.py +100 -0
- apsimo/goals/config.py +38 -0
- apsimo/goals/decomposer.py +421 -0
- apsimo/goals/engine.py +617 -0
- apsimo/goals/inference.py +354 -0
- apsimo/goals/models.py +302 -0
- apsimo/goals/priority.py +270 -0
- apsimo/goals/queue_bridge.py +149 -0
- apsimo/goals/replan.py +450 -0
- apsimo/goals/schema.sql +89 -0
- apsimo/goals/store.py +692 -0
- apsimo/governed_actions.py +1708 -0
- apsimo/harness_integration/__init__.py +45 -0
- apsimo/harness_integration/context.py +41 -0
- apsimo/harness_integration/skills.py +231 -0
- apsimo/identity/__init__.py +26 -0
- apsimo/identity/participants.py +181 -0
- apsimo/identity/resolver.py +329 -0
- apsimo/identity_bootstrap/__init__.py +5 -0
- apsimo/identity_bootstrap/builder.py +208 -0
- apsimo/identity_bootstrap/corpus.py +443 -0
- apsimo/identity_bootstrap/models.py +54 -0
- apsimo/identity_bootstrap/runner.py +353 -0
- apsimo/identity_bootstrap/seeders/__init__.py +25 -0
- apsimo/identity_bootstrap/seeders/briefings.py +109 -0
- apsimo/identity_bootstrap/seeders/chain.py +57 -0
- apsimo/identity_bootstrap/seeders/goals.py +128 -0
- apsimo/identity_bootstrap/seeders/memory.py +191 -0
- apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
- apsimo/identity_bootstrap/seeders/relationship.py +152 -0
- apsimo/identity_bootstrap/seeders/sessions.py +67 -0
- apsimo/identity_bootstrap/seeders/skills.py +92 -0
- apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
- apsimo/identity_bootstrap/seeders/world_model.py +143 -0
- apsimo/identity_bootstrap/self_query.py +92 -0
- apsimo/identity_bootstrap/self_reflection.py +155 -0
- apsimo/identity_bootstrap/skill.py +37 -0
- apsimo/identity_bootstrap/verifier.py +436 -0
- apsimo/initiatives/__init__.py +20 -0
- apsimo/initiatives/action_registry.py +454 -0
- apsimo/initiatives/approval_authority.py +2105 -0
- apsimo/initiatives/approval_policy.py +123 -0
- apsimo/initiatives/assignment.py +263 -0
- apsimo/initiatives/backup_evidence.py +100 -0
- apsimo/initiatives/context_freshness.py +103 -0
- apsimo/initiatives/models.py +318 -0
- apsimo/initiatives/native_work.py +270 -0
- apsimo/initiatives/standing_approvals.py +232 -0
- apsimo/initiatives/store.py +1081 -0
- apsimo/initiatives/temporal_followup.py +410 -0
- apsimo/intelligence/__init__.py +1 -0
- apsimo/intelligence/cognition/__init__.py +24 -0
- apsimo/intelligence/cognition/gap_detector.py +148 -0
- apsimo/intelligence/cognition/metalearner.py +547 -0
- apsimo/intelligence/cognition/metrics_collector.py +217 -0
- apsimo/intelligence/cognition/performance_index.py +299 -0
- apsimo/intelligence/cognition/registry.py +192 -0
- apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
- apsimo/intelligence/cognition/types.py +16 -0
- apsimo/intelligence/components/__init__.py +66 -0
- apsimo/intelligence/components/anomaly_detector.py +413 -0
- apsimo/intelligence/components/initiative_engine.py +2643 -0
- apsimo/intelligence/components/preference_learner.py +521 -0
- apsimo/intelligence/components/research_orchestrator.py +358 -0
- apsimo/intelligence/components/self_directed_thinker.py +221 -0
- apsimo/intelligence/components/self_reflector.py +252 -0
- apsimo/intelligence/components/session_continuity.py +154 -0
- apsimo/intelligence/components/task_planner.py +320 -0
- apsimo/intelligence/components/tool_learner.py +217 -0
- apsimo/intelligence/graph/__init__.py +79 -0
- apsimo/intelligence/graph/client.py +2483 -0
- apsimo/intelligence/graph/consolidator.py +405 -0
- apsimo/intelligence/graph/distiller.py +312 -0
- apsimo/intelligence/graph/migrations.py +129 -0
- apsimo/intelligence/graph/queries.py +248 -0
- apsimo/intelligence/graph/recall.py +281 -0
- apsimo/intelligence/graph/reconciler.py +144 -0
- apsimo/intelligence/graph/schema.py +337 -0
- apsimo/intelligence/graph/selection.py +252 -0
- apsimo/intelligence/learning/__init__.py +17 -0
- apsimo/intelligence/learning/continuous_learner.py +245 -0
- apsimo/intelligence/learning/feedback_store.py +321 -0
- apsimo/intelligence/mind_model/__init__.py +1 -0
- apsimo/intelligence/mind_model/graph_baseline.py +136 -0
- apsimo/intelligence/mind_model/signal_collector.py +361 -0
- apsimo/intelligence/relationships/__init__.py +11 -0
- apsimo/intelligence/relationships/profiler.py +389 -0
- apsimo/intelligence/relationships/scorer.py +560 -0
- apsimo/intelligence/relationships/signal_floor.py +66 -0
- apsimo/intelligence/relationships/trust_tiers.py +300 -0
- apsimo/intelligence/synthesis/__init__.py +40 -0
- apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
- apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
- apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
- apsimo/intelligence/synthesis/insight_store.py +79 -0
- apsimo/intelligence/synthesis/insight_validator.py +183 -0
- apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
- apsimo/intelligence/turn_middleware/__init__.py +15 -0
- apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
- apsimo/mcp/__init__.py +41 -0
- apsimo/mcp/__main__.py +6 -0
- apsimo/mcp/config.py +287 -0
- apsimo/mcp/server.py +501 -0
- apsimo/migrations.py +187 -0
- apsimo/mining/__init__.py +27 -0
- apsimo/mining/corpus.py +239 -0
- apsimo/mining/escalations.py +289 -0
- apsimo/mining/models.py +169 -0
- apsimo/mining/store.py +210 -0
- apsimo/models/__init__.py +30 -0
- apsimo/models/memory.py +80 -0
- apsimo/models/mesh.py +72 -0
- apsimo/models/person.py +104 -0
- apsimo/models/signal.py +108 -0
- apsimo/observations/__init__.py +15 -0
- apsimo/observations/store.py +277 -0
- apsimo/patterns/__init__.py +6 -0
- apsimo/patterns/extract.py +187 -0
- apsimo/patterns/store.py +227 -0
- apsimo/persona/__init__.py +1 -0
- apsimo/persona/engine.py +611 -0
- apsimo/persona/manifest.py +140 -0
- apsimo/projects/__init__.py +28 -0
- apsimo/projects/engine.py +1681 -0
- apsimo/projects/event_outbox.py +188 -0
- apsimo/projects/models.py +216 -0
- apsimo/projects/planner.py +181 -0
- apsimo/projects/store.py +1446 -0
- apsimo/proposals/__init__.py +12 -0
- apsimo/proposals/engine.py +114 -0
- apsimo/proposals/models.py +207 -0
- apsimo/qualification/__init__.py +1 -0
- apsimo/qualification/cases.py +75 -0
- apsimo/qualification/cli.py +51 -0
- apsimo/qualification/memory_cases.py +209 -0
- apsimo/qualification/records.py +92 -0
- apsimo/qualification/report.py +87 -0
- apsimo/qualification/runner.py +311 -0
- apsimo/qualification/structured_cases.py +131 -0
- apsimo/reasoning/__init__.py +13 -0
- apsimo/reasoning/executor.py +506 -0
- apsimo/reasoning/loop.py +373 -0
- apsimo/reasoning/native_tools/__init__.py +16 -0
- apsimo/reasoning/native_tools/calculate.py +141 -0
- apsimo/reasoning/native_tools/file_ops.py +150 -0
- apsimo/reasoning/native_tools/web_search.py +49 -0
- apsimo/reasoning/tool_policy.py +182 -0
- apsimo/redact/__init__.py +176 -0
- apsimo/repos/__init__.py +5 -0
- apsimo/repos/mirrors.py +204 -0
- apsimo/research/__init__.py +41 -0
- apsimo/research/artifact.py +482 -0
- apsimo/research/gatherer.py +387 -0
- apsimo/research/pipeline.py +513 -0
- apsimo/research/search/__init__.py +7 -0
- apsimo/research/search/base.py +41 -0
- apsimo/research/search/brave.py +59 -0
- apsimo/research/search/cache.py +51 -0
- apsimo/research/search/duckduckgo.py +103 -0
- apsimo/research/search/orchestrator.py +119 -0
- apsimo/research/search/serpapi.py +59 -0
- apsimo/research/search/tavily.py +59 -0
- apsimo/research/synthesizer.py +309 -0
- apsimo/router/__init__.py +30 -0
- apsimo/router/complexity_scorer.py +148 -0
- apsimo/router/endpoints.py +153 -0
- apsimo/router/fallback.py +58 -0
- apsimo/router/functions.py +243 -0
- apsimo/router/native_policy.py +52 -0
- apsimo/router/router.py +762 -0
- apsimo/router/self_learning.py +174 -0
- apsimo/router/tiers.py +677 -0
- apsimo/sandbox/__init__.py +21 -0
- apsimo/sandbox/backend.py +195 -0
- apsimo/sandbox/manager.py +173 -0
- apsimo/scope_bounds.py +7 -0
- apsimo/secrets/__init__.py +6 -0
- apsimo/secrets/backends/__init__.py +8 -0
- apsimo/secrets/backends/base.py +42 -0
- apsimo/secrets/backends/env.py +110 -0
- apsimo/secrets/backends/keyring.py +72 -0
- apsimo/secrets/backends/onepassword.py +232 -0
- apsimo/secrets/cli.py +191 -0
- apsimo/secrets/manager.py +160 -0
- apsimo/secrets/migration.py +101 -0
- apsimo/secrets/types.py +98 -0
- apsimo/seed.py +41 -0
- apsimo/self_model/__init__.py +37 -0
- apsimo/self_model/appraisals.py +673 -0
- apsimo/self_model/benchmark.py +1314 -0
- apsimo/self_model/brief.py +40 -0
- apsimo/self_model/event_concerns.py +1128 -0
- apsimo/self_model/execution_forecasts.py +353 -0
- apsimo/self_model/expectations.py +1595 -0
- apsimo/self_model/experiments.py +1150 -0
- apsimo/self_model/journal.py +148 -0
- apsimo/self_model/judgments.py +705 -0
- apsimo/self_model/native_outcomes.py +55 -0
- apsimo/self_model/params.py +220 -0
- apsimo/self_model/perspective.py +246 -0
- apsimo/self_model/reconcile.py +183 -0
- apsimo/self_model/reply_forecasts.py +381 -0
- apsimo/self_model/runtime_forecasts.py +296 -0
- apsimo/self_model/runtime_models.py +67 -0
- apsimo/self_model/settlement.py +207 -0
- apsimo/self_model/situation.py +1731 -0
- apsimo/self_model/store.py +883 -0
- apsimo/self_model/supervised.py +137 -0
- apsimo/self_model/thinker.py +99 -0
- apsimo/self_model/trust.py +388 -0
- apsimo/self_model/workspace.py +2388 -0
- apsimo/server.py +4197 -0
- apsimo/services/__init__.py +1 -0
- apsimo/services/agent_bridge.py +474 -0
- apsimo/services/initiative_executor.py +914 -0
- apsimo/services/instance.py +297 -0
- apsimo/sessions/__init__.py +22 -0
- apsimo/sessions/config.py +13 -0
- apsimo/sessions/context_loader.py +88 -0
- apsimo/sessions/federation_session.py +75 -0
- apsimo/sessions/isolated_session.py +98 -0
- apsimo/sessions/reports.py +84 -0
- apsimo/sessions/store.py +148 -0
- apsimo/setup.py +2818 -0
- apsimo/setup_hermes.py +879 -0
- apsimo/setup_local_work.py +218 -0
- apsimo/setup_native_goals.py +134 -0
- apsimo/setup_native_reviews.py +115 -0
- apsimo/skills/__init__.py +10 -0
- apsimo/skills/base.py +108 -0
- apsimo/skills/budget.py +28 -0
- apsimo/skills/executor.py +493 -0
- apsimo/skills/executors/__init__.py +1 -0
- apsimo/skills/executors/behavioral_correction.py +75 -0
- apsimo/skills/executors/capability_gap.py +38 -0
- apsimo/skills/executors/data_quality.py +163 -0
- apsimo/skills/executors/knowledge_acquisition.py +41 -0
- apsimo/skills/executors/operational_hygiene.py +185 -0
- apsimo/skills/executors/subsystem_health.py +169 -0
- apsimo/skills/hermes_export.py +431 -0
- apsimo/skills/index.py +123 -0
- apsimo/skills/learning/__init__.py +21 -0
- apsimo/skills/learning/novelty_detector.py +206 -0
- apsimo/skills/learning/pattern_extractor.py +199 -0
- apsimo/skills/learning/triggers.py +159 -0
- apsimo/skills/loader.py +246 -0
- apsimo/skills/migrations/002_progressive_loading.sql +6 -0
- apsimo/skills/migrations/backfill_triggers.py +20 -0
- apsimo/skills/models.py +202 -0
- apsimo/skills/packager.py +128 -0
- apsimo/skills/protocols.py +70 -0
- apsimo/skills/registry.py +191 -0
- apsimo/skills/runtime.py +58 -0
- apsimo/skills/sandbox_runner.py +229 -0
- apsimo/skills/scheduler.py +129 -0
- apsimo/skills/schema.py +79 -0
- apsimo/skills/security/__init__.py +12 -0
- apsimo/skills/security/guards.py +53 -0
- apsimo/skills/security/scanner.py +223 -0
- apsimo/skills_memory/__init__.py +26 -0
- apsimo/skills_memory/distill.py +159 -0
- apsimo/skills_memory/models.py +85 -0
- apsimo/skills_memory/retrieve.py +62 -0
- apsimo/skills_memory/store.py +172 -0
- apsimo/surprise/__init__.py +6 -0
- apsimo/surprise/accumulation.py +57 -0
- apsimo/surprise/scorer.py +102 -0
- apsimo/surprise/store.py +203 -0
- apsimo/task_queue/__init__.py +69 -0
- apsimo/task_queue/action_receipts.py +148 -0
- apsimo/task_queue/approval_relay_canary.py +108 -0
- apsimo/task_queue/config.py +85 -0
- apsimo/task_queue/contract.py +361 -0
- apsimo/task_queue/events.py +130 -0
- apsimo/task_queue/governor.py +1031 -0
- apsimo/task_queue/handlers/__init__.py +16 -0
- apsimo/task_queue/handlers/base.py +37 -0
- apsimo/task_queue/handlers/inference.py +640 -0
- apsimo/task_queue/handlers/monitoring.py +116 -0
- apsimo/task_queue/handlers/registry.py +75 -0
- apsimo/task_queue/handlers/subtask_handler.py +173 -0
- apsimo/task_queue/handlers/system_maintenance.py +147 -0
- apsimo/task_queue/mesh_integration.py +111 -0
- apsimo/task_queue/models.py +317 -0
- apsimo/task_queue/queue_manager.py +8286 -0
- apsimo/task_queue/routing.py +287 -0
- apsimo/task_queue/scheduler.py +252 -0
- apsimo/task_queue/schema.sql +197 -0
- apsimo/task_queue/work_control.py +342 -0
- apsimo/task_queue/worker.py +993 -0
- apsimo/telemetry.py +145 -0
- apsimo/tom/__init__.py +6 -0
- apsimo/tom/affect.py +387 -0
- apsimo/tom/approvals.py +171 -0
- apsimo/tom/arcs.py +896 -0
- apsimo/tom/asymmetry.py +131 -0
- apsimo/tom/eligibility.py +248 -0
- apsimo/tom/engagement.py +214 -0
- apsimo/tom/exposure.py +214 -0
- apsimo/tom/extractor.py +306 -0
- apsimo/tom/fact_adapters.py +144 -0
- apsimo/tom/facts.py +326 -0
- apsimo/tom/integration.py +592 -0
- apsimo/tom/leveled.py +118 -0
- apsimo/tom/levels.py +247 -0
- apsimo/tom/recipient_audit.py +995 -0
- apsimo/tom/recipient_simulator.py +593 -0
- apsimo/tom/source_lineage.py +93 -0
- apsimo/tom/tom2.py +277 -0
- apsimo/tom/visibility.py +559 -0
- apsimo/tom/visibility_store.py +414 -0
- apsimo/tools/__init__.py +0 -0
- apsimo/tools/definitions.py +740 -0
- apsimo/tools/handlers.py +943 -0
- apsimo/toolsmith/__init__.py +26 -0
- apsimo/toolsmith/authority.py +166 -0
- apsimo/toolsmith/engine.py +559 -0
- apsimo/toolsmith/integrity.py +100 -0
- apsimo/toolsmith/miner.py +145 -0
- apsimo/toolsmith/policy.py +110 -0
- apsimo/toolsmith/registry.py +635 -0
- apsimo/turns/__init__.py +17 -0
- apsimo/turns/audio.py +134 -0
- apsimo/turns/documents.py +235 -0
- apsimo/turns/executions.py +486 -0
- apsimo/turns/hermes_history.py +245 -0
- apsimo/turns/hermes_kanban.py +268 -0
- apsimo/turns/hermes_work.py +96 -0
- apsimo/turns/idempotency.py +752 -0
- apsimo/turns/local_work.py +115 -0
- apsimo/turns/media.py +581 -0
- apsimo/turns/reported_workers.py +196 -0
- apsimo/turns/source_annotations.py +283 -0
- apsimo/turns/source_attribution.py +154 -0
- apsimo/turns/source_read.py +351 -0
- apsimo/turns/source_vectors.py +263 -0
- apsimo/turns/video.py +210 -0
- apsimo/util/autonomy_preset.py +220 -0
- apsimo/util/instance.py +92 -0
- apsimo/util/model_output.py +25 -0
- apsimo/util/quiet_hours.py +27 -0
- apsimo/util/session_safety.py +37 -0
- apsimo/util/temporal.py +343 -0
- apsimo/vector/__init__.py +75 -0
- apsimo/vector/backfill.py +171 -0
- apsimo/vector/caption.py +114 -0
- apsimo/vector/collections.py +51 -0
- apsimo/vector/config.py +102 -0
- apsimo/vector/embedder.py +670 -0
- apsimo/vector/image_preprocess.py +406 -0
- apsimo/vector/image_store.py +296 -0
- apsimo/vector/indexes.py +162 -0
- apsimo/vector/migrate.py +334 -0
- apsimo/vector/multimodal_provider.py +417 -0
- apsimo/vector/multimodal_types.py +87 -0
- apsimo/vector/openai_provider.py +119 -0
- apsimo/vector/query.py +49 -0
- apsimo/vector/reranker.py +565 -0
- apsimo/vector/safety_image.py +159 -0
- apsimo/vector/scanner.py +197 -0
- apsimo/vector/setup.py +289 -0
- apsimo/vector/store.py +533 -0
- apsimo/vector/tiers.py +263 -0
- apsimo/work_orders.py +925 -0
- apsimo/workers/__init__.py +21 -0
- apsimo/workers/agent_bridge.py +640 -0
- apsimo/workers/colony_worker.py +382 -0
- apsimo/workers/queue_worker.py +441 -0
- apsimo/workers/skills_sync.py +152 -0
- apsimo/world_model/__init__.py +71 -0
- apsimo/world_model/causal_maintenance.py +131 -0
- apsimo/world_model/causal_policy.py +43 -0
- apsimo/world_model/causal_query.py +125 -0
- apsimo/world_model/confidence.py +54 -0
- apsimo/world_model/config.py +64 -0
- apsimo/world_model/constants.py +97 -0
- apsimo/world_model/entities.py +145 -0
- apsimo/world_model/expectation_resolvers.py +177 -0
- apsimo/world_model/extraction/__init__.py +7 -0
- apsimo/world_model/extraction/base.py +62 -0
- apsimo/world_model/extraction/conversation_extractor.py +262 -0
- apsimo/world_model/extraction/detector.py +74 -0
- apsimo/world_model/extraction/document_extractor.py +78 -0
- apsimo/world_model/extraction/formats/__init__.py +24 -0
- apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
- apsimo/world_model/extraction/formats/html_fmt.py +72 -0
- apsimo/world_model/extraction/formats/json_fmt.py +68 -0
- apsimo/world_model/extraction/formats/pdf.py +43 -0
- apsimo/world_model/extraction/formats/text.py +27 -0
- apsimo/world_model/extraction/llm_extractor.py +164 -0
- apsimo/world_model/extraction/pipeline.py +73 -0
- apsimo/world_model/integrations/__init__.py +5 -0
- apsimo/world_model/integrations/mind_model_bridge.py +115 -0
- apsimo/world_model/integrations/social_intel_bridge.py +120 -0
- apsimo/world_model/jobs/__init__.py +4 -0
- apsimo/world_model/jobs/extraction_job.py +168 -0
- apsimo/world_model/llm_extract.py +572 -0
- apsimo/world_model/neo4j/__init__.py +5 -0
- apsimo/world_model/neo4j/backend.py +654 -0
- apsimo/world_model/observations.py +155 -0
- apsimo/world_model/populator.py +307 -0
- apsimo/world_model/postgres/__init__.py +1 -0
- apsimo/world_model/postgres/backend.py +683 -0
- apsimo/world_model/relationships.py +25 -0
- apsimo/world_model/resolution/__init__.py +13 -0
- apsimo/world_model/resolution/entity_resolver.py +232 -0
- apsimo/world_model/resolution/merge_audit.py +16 -0
- apsimo/world_model/resolution/merge_workflow.py +117 -0
- apsimo/world_model/source_reports.py +121 -0
- apsimo/world_model/sqlite/__init__.py +4 -0
- apsimo/world_model/sqlite/backend.py +855 -0
- apsimo/world_model/sqlite/schema.sql +132 -0
- apsimo/world_model/store.py +545 -0
- apsimo-1.3.0.dist-info/METADATA +78 -0
- apsimo-1.3.0.dist-info/RECORD +614 -0
- apsimo-1.3.0.dist-info/WHEEL +5 -0
- apsimo-1.3.0.dist-info/entry_points.txt +11 -0
- apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
- apsimo-1.3.0.dist-info/top_level.txt +2 -0
- colony_sidecar/__init__.py +4 -0
|
@@ -0,0 +1,1731 @@
|
|
|
1
|
+
"""Evidence-derived, scoped present-tense situation spine (Mind M3/P6).
|
|
2
|
+
|
|
3
|
+
The situation spine is a reducer, not a second world model. It accepts only
|
|
4
|
+
typed observations backed by durable event/receipt references, keeps the
|
|
5
|
+
latest observation for a bounded set of present-tense categories, and emits
|
|
6
|
+
immutable snapshots. Model prose is never an observation and a situation
|
|
7
|
+
verdict never grants a capability or an action authority.
|
|
8
|
+
|
|
9
|
+
The module is deliberately deployment-neutral. ``JournalSituationAdapter``
|
|
10
|
+
understands Colony's durable host-journal envelope plus the structured event
|
|
11
|
+
families shared with the host. Deployments may construct ``SituationObservationV1``
|
|
12
|
+
directly for attested probes/transports without patching Colony.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import math
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import sqlite3
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Sequence
|
|
28
|
+
|
|
29
|
+
from apsimo.events.journal import current_sequence, replay_events
|
|
30
|
+
from apsimo.scope_bounds import VIEWER_SCOPE_MAX_CHARS
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
SITUATION_VERSION = 1
|
|
34
|
+
OBSERVATION_VERSION = 1
|
|
35
|
+
VERDICT_VERSION = 1
|
|
36
|
+
|
|
37
|
+
SITUATION_CATEGORIES = (
|
|
38
|
+
"owner_engagement",
|
|
39
|
+
"activity",
|
|
40
|
+
"conversation",
|
|
41
|
+
"person",
|
|
42
|
+
"channel",
|
|
43
|
+
"service",
|
|
44
|
+
"approval",
|
|
45
|
+
"commitment",
|
|
46
|
+
"project",
|
|
47
|
+
"resource",
|
|
48
|
+
"capability",
|
|
49
|
+
"relationship",
|
|
50
|
+
)
|
|
51
|
+
_CATEGORY_SET = frozenset(SITUATION_CATEGORIES)
|
|
52
|
+
_SHAREABILITY = frozenset(
|
|
53
|
+
{"owner_private", "subject_private", "shared", "public"}
|
|
54
|
+
)
|
|
55
|
+
_SOURCE_KINDS = frozenset({
|
|
56
|
+
"colony_event",
|
|
57
|
+
"host_event",
|
|
58
|
+
"service_probe",
|
|
59
|
+
"transport_receipt",
|
|
60
|
+
"presence_observation",
|
|
61
|
+
"approval_receipt",
|
|
62
|
+
"project_receipt",
|
|
63
|
+
"work_receipt",
|
|
64
|
+
})
|
|
65
|
+
_DISALLOWED_SOURCE_KINDS = frozenset(
|
|
66
|
+
{"model", "model_assertion", "inference", "prose", "llm"}
|
|
67
|
+
)
|
|
68
|
+
_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/+\-]{0,255}$")
|
|
69
|
+
_SAFE_STATE = re.compile(r"^[a-z][a-z0-9_.:\-]{0,63}$")
|
|
70
|
+
_SAFE_ATTRIBUTE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
|
|
71
|
+
_EVIDENCE_PREFIXES = frozenset({
|
|
72
|
+
"journal", "event", "receipt", "host-event", "colony-event",
|
|
73
|
+
"health", "presence", "approval", "project", "work-order",
|
|
74
|
+
"commitment", "channel", "service", "device", "capability",
|
|
75
|
+
"relationship", "situation-snapshot", "observation",
|
|
76
|
+
})
|
|
77
|
+
_JSON_SCALARS = (str, int, float, bool, type(None))
|
|
78
|
+
|
|
79
|
+
_DEFAULT_TTLS: Dict[str, float] = {
|
|
80
|
+
"owner_engagement": 120.0,
|
|
81
|
+
"activity": 300.0,
|
|
82
|
+
"conversation": 300.0,
|
|
83
|
+
"person": 300.0,
|
|
84
|
+
"channel": 180.0,
|
|
85
|
+
"service": 120.0,
|
|
86
|
+
"approval": 300.0,
|
|
87
|
+
"commitment": 3600.0,
|
|
88
|
+
"project": 300.0,
|
|
89
|
+
"resource": 300.0,
|
|
90
|
+
"capability": 300.0,
|
|
91
|
+
"relationship": 86400.0,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def situation_spine_mode() -> str:
|
|
96
|
+
"""Return the explicit migration mode; unset/invalid always fails off."""
|
|
97
|
+
|
|
98
|
+
value = os.environ.get("COLONY_SITUATION_SPINE", "off").strip().lower()
|
|
99
|
+
return value if value in {"off", "shadow", "live"} else "off"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def situation_spine_enabled() -> bool:
|
|
103
|
+
return situation_spine_mode() in {"shadow", "live"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _canonical(value: Any) -> str:
|
|
107
|
+
return json.dumps(
|
|
108
|
+
value,
|
|
109
|
+
ensure_ascii=False,
|
|
110
|
+
separators=(",", ":"),
|
|
111
|
+
sort_keys=True,
|
|
112
|
+
default=str,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _digest(value: Any) -> str:
|
|
117
|
+
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _epoch(value: Any, field: str) -> float:
|
|
121
|
+
if isinstance(value, bool):
|
|
122
|
+
raise ValueError(f"{field} must be a timestamp")
|
|
123
|
+
if isinstance(value, (int, float)):
|
|
124
|
+
result = float(value)
|
|
125
|
+
else:
|
|
126
|
+
raw = str(value or "").strip()
|
|
127
|
+
try:
|
|
128
|
+
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
129
|
+
except (TypeError, ValueError) as exc:
|
|
130
|
+
raise ValueError(f"{field} must be ISO-8601 or epoch seconds") from exc
|
|
131
|
+
if parsed.tzinfo is None:
|
|
132
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
133
|
+
result = parsed.timestamp()
|
|
134
|
+
if not math.isfinite(result) or result <= 0.0 or result > 32_503_680_000.0:
|
|
135
|
+
raise ValueError(f"{field} is outside the supported range")
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _iso(value: float) -> str:
|
|
140
|
+
return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _safe_id(value: Any, field: str, *, maximum: int = 256) -> str:
|
|
144
|
+
result = str(value or "").strip()
|
|
145
|
+
if len(result) > maximum:
|
|
146
|
+
raise ValueError(f"{field} exceeds {maximum} characters")
|
|
147
|
+
if not _SAFE_ID.fullmatch(result):
|
|
148
|
+
raise ValueError(f"{field} is invalid")
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _evidence_refs(values: Iterable[Any], *, maximum: int = 40) -> tuple[str, ...]:
|
|
153
|
+
refs: list[str] = []
|
|
154
|
+
for raw in values or ():
|
|
155
|
+
value = _safe_id(raw, "evidence reference")
|
|
156
|
+
if value.split(":", 1)[0] not in _EVIDENCE_PREFIXES:
|
|
157
|
+
raise ValueError("evidence reference has an unsupported origin")
|
|
158
|
+
if value not in refs:
|
|
159
|
+
refs.append(value)
|
|
160
|
+
if len(refs) > maximum:
|
|
161
|
+
raise ValueError(f"evidence references exceed {maximum}")
|
|
162
|
+
if not refs:
|
|
163
|
+
raise ValueError("at least one durable evidence reference is required")
|
|
164
|
+
return tuple(refs)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _attributes(value: Optional[Mapping[str, Any]]) -> tuple[tuple[str, Any], ...]:
|
|
168
|
+
if value is None:
|
|
169
|
+
return ()
|
|
170
|
+
if not isinstance(value, Mapping) or len(value) > 20:
|
|
171
|
+
raise ValueError("attributes must be an object with at most 20 fields")
|
|
172
|
+
result: list[tuple[str, Any]] = []
|
|
173
|
+
for raw_key, raw_value in sorted(value.items()):
|
|
174
|
+
key = str(raw_key or "").strip().lower()
|
|
175
|
+
if not _SAFE_ATTRIBUTE.fullmatch(key):
|
|
176
|
+
raise ValueError("attribute key is invalid")
|
|
177
|
+
if not isinstance(raw_value, _JSON_SCALARS):
|
|
178
|
+
raise ValueError("situation attributes must be JSON scalars")
|
|
179
|
+
if isinstance(raw_value, float) and not math.isfinite(raw_value):
|
|
180
|
+
raise ValueError("situation attributes must be finite")
|
|
181
|
+
item = raw_value[:300] if isinstance(raw_value, str) else raw_value
|
|
182
|
+
result.append((key, item))
|
|
183
|
+
return tuple(result)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _validate_scope(
|
|
187
|
+
subject_person_id: str,
|
|
188
|
+
viewer_scope: str,
|
|
189
|
+
shareability: str,
|
|
190
|
+
) -> tuple[str, str, str]:
|
|
191
|
+
subject = _safe_id(subject_person_id, "subject_person_id", maximum=128)
|
|
192
|
+
viewer = _safe_id(
|
|
193
|
+
viewer_scope, "viewer_scope", maximum=VIEWER_SCOPE_MAX_CHARS,
|
|
194
|
+
)
|
|
195
|
+
sharing = str(shareability or "").strip().lower()
|
|
196
|
+
if sharing not in _SHAREABILITY:
|
|
197
|
+
raise ValueError("invalid shareability")
|
|
198
|
+
if sharing == "owner_private" and viewer != "owner":
|
|
199
|
+
raise ValueError("owner-private observations require owner viewer scope")
|
|
200
|
+
if sharing == "subject_private" and viewer != f"person:{subject}":
|
|
201
|
+
raise ValueError("subject-private observations require exact subject scope")
|
|
202
|
+
if sharing == "public" and viewer != "public":
|
|
203
|
+
raise ValueError("public observations require public viewer scope")
|
|
204
|
+
if sharing == "shared" and not (
|
|
205
|
+
viewer == "shared" or viewer.startswith("shared:")
|
|
206
|
+
):
|
|
207
|
+
raise ValueError("shared observations require a shared viewer scope")
|
|
208
|
+
return subject, viewer, sharing
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class SituationObservationV1:
|
|
213
|
+
"""One typed, time-bounded observation with durable provenance."""
|
|
214
|
+
|
|
215
|
+
observation_id: str
|
|
216
|
+
category: str
|
|
217
|
+
entity_id: str
|
|
218
|
+
state: str
|
|
219
|
+
active: bool
|
|
220
|
+
observed_at: float
|
|
221
|
+
fresh_until: float
|
|
222
|
+
evidence_refs: tuple[str, ...]
|
|
223
|
+
source_kind: str
|
|
224
|
+
subject_person_id: str
|
|
225
|
+
viewer_scope: str
|
|
226
|
+
shareability: str
|
|
227
|
+
attributes: tuple[tuple[str, Any], ...] = ()
|
|
228
|
+
schema: str = "SituationObservationV1"
|
|
229
|
+
version: int = OBSERVATION_VERSION
|
|
230
|
+
|
|
231
|
+
def __post_init__(self) -> None:
|
|
232
|
+
_safe_id(self.observation_id, "observation_id")
|
|
233
|
+
if self.category not in _CATEGORY_SET:
|
|
234
|
+
raise ValueError("unsupported situation category")
|
|
235
|
+
_safe_id(self.entity_id, "entity_id")
|
|
236
|
+
if not _SAFE_STATE.fullmatch(self.state):
|
|
237
|
+
raise ValueError("invalid situation state")
|
|
238
|
+
if type(self.active) is not bool:
|
|
239
|
+
raise ValueError("active must be a boolean")
|
|
240
|
+
observed = _epoch(self.observed_at, "observed_at")
|
|
241
|
+
fresh = _epoch(self.fresh_until, "fresh_until")
|
|
242
|
+
if fresh < observed or fresh - observed > 31_536_000.0:
|
|
243
|
+
raise ValueError("freshness interval is invalid or unbounded")
|
|
244
|
+
if _evidence_refs(self.evidence_refs) != self.evidence_refs:
|
|
245
|
+
raise ValueError("evidence references are not canonical")
|
|
246
|
+
source = str(self.source_kind or "").strip().lower()
|
|
247
|
+
if source in _DISALLOWED_SOURCE_KINDS or source not in _SOURCE_KINDS:
|
|
248
|
+
raise ValueError("source_kind is not a trusted observation source")
|
|
249
|
+
if _validate_scope(
|
|
250
|
+
self.subject_person_id, self.viewer_scope, self.shareability,
|
|
251
|
+
) != (self.subject_person_id, self.viewer_scope, self.shareability):
|
|
252
|
+
raise ValueError("observation scope is not canonical")
|
|
253
|
+
if _attributes(dict(self.attributes)) != self.attributes:
|
|
254
|
+
raise ValueError("attributes are not canonical")
|
|
255
|
+
if self.schema != "SituationObservationV1" or self.version != 1:
|
|
256
|
+
raise ValueError("invalid situation observation schema")
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def create(
|
|
260
|
+
cls,
|
|
261
|
+
*,
|
|
262
|
+
observation_id: str,
|
|
263
|
+
category: str,
|
|
264
|
+
entity_id: str,
|
|
265
|
+
state: str,
|
|
266
|
+
active: bool,
|
|
267
|
+
observed_at: Any,
|
|
268
|
+
fresh_until: Any = None,
|
|
269
|
+
ttl_seconds: Optional[float] = None,
|
|
270
|
+
evidence_refs: Iterable[Any],
|
|
271
|
+
source_kind: str,
|
|
272
|
+
subject_person_id: str,
|
|
273
|
+
viewer_scope: str,
|
|
274
|
+
shareability: str,
|
|
275
|
+
attributes: Optional[Mapping[str, Any]] = None,
|
|
276
|
+
) -> "SituationObservationV1":
|
|
277
|
+
observed = _epoch(observed_at, "observed_at")
|
|
278
|
+
if fresh_until is None:
|
|
279
|
+
ttl = float(
|
|
280
|
+
ttl_seconds
|
|
281
|
+
if ttl_seconds is not None
|
|
282
|
+
else _DEFAULT_TTLS.get(category, 300.0)
|
|
283
|
+
)
|
|
284
|
+
if ttl < 1.0 or ttl > 31_536_000.0:
|
|
285
|
+
raise ValueError("ttl_seconds is outside the supported range")
|
|
286
|
+
fresh = observed + ttl
|
|
287
|
+
else:
|
|
288
|
+
fresh = _epoch(fresh_until, "fresh_until")
|
|
289
|
+
subject, viewer, sharing = _validate_scope(
|
|
290
|
+
subject_person_id, viewer_scope, shareability,
|
|
291
|
+
)
|
|
292
|
+
return cls(
|
|
293
|
+
observation_id=_safe_id(observation_id, "observation_id"),
|
|
294
|
+
category=str(category or "").strip().lower(),
|
|
295
|
+
entity_id=_safe_id(entity_id, "entity_id"),
|
|
296
|
+
state=str(state or "").strip().lower(),
|
|
297
|
+
active=active,
|
|
298
|
+
observed_at=observed,
|
|
299
|
+
fresh_until=fresh,
|
|
300
|
+
evidence_refs=_evidence_refs(evidence_refs),
|
|
301
|
+
source_kind=str(source_kind or "").strip().lower(),
|
|
302
|
+
subject_person_id=subject,
|
|
303
|
+
viewer_scope=viewer,
|
|
304
|
+
shareability=sharing,
|
|
305
|
+
attributes=_attributes(attributes),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def digest(self) -> str:
|
|
310
|
+
return _digest(self.payload())
|
|
311
|
+
|
|
312
|
+
def payload(self) -> Dict[str, Any]:
|
|
313
|
+
return {
|
|
314
|
+
"schema": self.schema,
|
|
315
|
+
"version": self.version,
|
|
316
|
+
"observation_id": self.observation_id,
|
|
317
|
+
"category": self.category,
|
|
318
|
+
"entity_id": self.entity_id,
|
|
319
|
+
"state": self.state,
|
|
320
|
+
"active": self.active,
|
|
321
|
+
"observed_at": self.observed_at,
|
|
322
|
+
"fresh_until": self.fresh_until,
|
|
323
|
+
"evidence_refs": list(self.evidence_refs),
|
|
324
|
+
"source_kind": self.source_kind,
|
|
325
|
+
"subject_person_id": self.subject_person_id,
|
|
326
|
+
"viewer_scope": self.viewer_scope,
|
|
327
|
+
"shareability": self.shareability,
|
|
328
|
+
"attributes": dict(self.attributes),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _queue_worker_count(value: Any, field: str) -> int:
|
|
333
|
+
if type(value) is not int or value < 0:
|
|
334
|
+
raise ValueError(f"{field} must be a non-negative integer")
|
|
335
|
+
return value
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def task_queue_resource_observation(
|
|
339
|
+
queue: Any,
|
|
340
|
+
*,
|
|
341
|
+
observed_at: Any = None,
|
|
342
|
+
) -> SituationObservationV1:
|
|
343
|
+
"""Project canonical queue readiness and worker liveness as one resource.
|
|
344
|
+
|
|
345
|
+
This is a direct probe of the queue's existing in-process readiness gate
|
|
346
|
+
and durable worker registry. It does not infer capacity from queued work
|
|
347
|
+
or configured workers: execution is available only when maintenance is
|
|
348
|
+
ready and at least one heartbeat-fresh worker is below capacity.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
readiness_fn = getattr(queue, "execution_readiness", None)
|
|
352
|
+
stats_fn = getattr(queue, "get_queue_stats", None)
|
|
353
|
+
if not callable(readiness_fn) or not callable(stats_fn):
|
|
354
|
+
raise ValueError(
|
|
355
|
+
"task queue resource probe requires readiness and worker truth"
|
|
356
|
+
)
|
|
357
|
+
readiness = readiness_fn()
|
|
358
|
+
if not isinstance(readiness, Mapping):
|
|
359
|
+
raise ValueError("task queue execution readiness must be an object")
|
|
360
|
+
execution_ready = readiness.get("ready")
|
|
361
|
+
if type(execution_ready) is not bool:
|
|
362
|
+
raise ValueError("task queue execution readiness must be boolean")
|
|
363
|
+
|
|
364
|
+
stats = await stats_fn()
|
|
365
|
+
registered = _queue_worker_count(
|
|
366
|
+
getattr(stats, "registered_workers", None), "registered_workers",
|
|
367
|
+
)
|
|
368
|
+
active = _queue_worker_count(
|
|
369
|
+
getattr(stats, "active_workers", None), "active_workers",
|
|
370
|
+
)
|
|
371
|
+
stale = _queue_worker_count(
|
|
372
|
+
getattr(stats, "stale_workers", None), "stale_workers",
|
|
373
|
+
)
|
|
374
|
+
available = _queue_worker_count(
|
|
375
|
+
getattr(stats, "available_workers", None), "available_workers",
|
|
376
|
+
)
|
|
377
|
+
if registered != active + stale or available > active:
|
|
378
|
+
raise ValueError("task queue worker freshness counts are inconsistent")
|
|
379
|
+
heartbeat_ttl = getattr(stats, "worker_heartbeat_ttl_secs", None)
|
|
380
|
+
if (
|
|
381
|
+
isinstance(heartbeat_ttl, bool)
|
|
382
|
+
or not isinstance(heartbeat_ttl, (int, float))
|
|
383
|
+
or not math.isfinite(float(heartbeat_ttl))
|
|
384
|
+
or float(heartbeat_ttl) < 1.0
|
|
385
|
+
):
|
|
386
|
+
raise ValueError("task queue worker heartbeat TTL is invalid")
|
|
387
|
+
|
|
388
|
+
observed = _epoch(
|
|
389
|
+
time.time() if observed_at is None else observed_at,
|
|
390
|
+
"observed_at",
|
|
391
|
+
)
|
|
392
|
+
capacity_available = execution_ready and available > 0
|
|
393
|
+
attributes = {
|
|
394
|
+
"active_workers": active,
|
|
395
|
+
"available_workers": available,
|
|
396
|
+
"capacity_available": capacity_available,
|
|
397
|
+
"execution_ready": execution_ready,
|
|
398
|
+
"registered_workers": registered,
|
|
399
|
+
"stale_workers": stale,
|
|
400
|
+
}
|
|
401
|
+
attestation = {
|
|
402
|
+
"schema": "TaskQueueExecutionReadinessProbeV1",
|
|
403
|
+
"version": 1,
|
|
404
|
+
"observed_at": observed,
|
|
405
|
+
"worker_heartbeat_ttl_secs": float(heartbeat_ttl),
|
|
406
|
+
**attributes,
|
|
407
|
+
}
|
|
408
|
+
attestation_digest = _digest(attestation)
|
|
409
|
+
owner = (
|
|
410
|
+
os.environ.get("COLONY_OWNER_PERSON_ID", "").strip()
|
|
411
|
+
or os.environ.get("COLONY_OWNER_CONTACT_ID", "").strip()
|
|
412
|
+
or "owner"
|
|
413
|
+
)
|
|
414
|
+
return SituationObservationV1.create(
|
|
415
|
+
observation_id=f"so-{attestation_digest[:24]}",
|
|
416
|
+
category="resource",
|
|
417
|
+
entity_id="task-queue-execution",
|
|
418
|
+
state="available" if capacity_available else "unavailable",
|
|
419
|
+
active=True,
|
|
420
|
+
observed_at=observed,
|
|
421
|
+
ttl_seconds=min(
|
|
422
|
+
_DEFAULT_TTLS["resource"], float(heartbeat_ttl),
|
|
423
|
+
),
|
|
424
|
+
evidence_refs=(
|
|
425
|
+
f"health:task-queue-execution:{attestation_digest[:24]}",
|
|
426
|
+
),
|
|
427
|
+
source_kind="service_probe",
|
|
428
|
+
subject_person_id=owner,
|
|
429
|
+
viewer_scope="owner",
|
|
430
|
+
shareability="owner_private",
|
|
431
|
+
attributes=attributes,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@dataclass(frozen=True)
|
|
436
|
+
class SituationFactV1:
|
|
437
|
+
observation_id: str
|
|
438
|
+
category: str
|
|
439
|
+
entity_id: str
|
|
440
|
+
state: str
|
|
441
|
+
active: bool
|
|
442
|
+
observed_at: float
|
|
443
|
+
fresh_until: float
|
|
444
|
+
freshness: str
|
|
445
|
+
evidence_refs: tuple[str, ...]
|
|
446
|
+
subject_person_id: str
|
|
447
|
+
viewer_scope: str
|
|
448
|
+
shareability: str
|
|
449
|
+
attributes: tuple[tuple[str, Any], ...] = ()
|
|
450
|
+
|
|
451
|
+
def public(self) -> Dict[str, Any]:
|
|
452
|
+
return {
|
|
453
|
+
"observation_id": self.observation_id,
|
|
454
|
+
"category": self.category,
|
|
455
|
+
"entity_id": self.entity_id,
|
|
456
|
+
"state": self.state,
|
|
457
|
+
"active": self.active,
|
|
458
|
+
"observed_at": self.observed_at,
|
|
459
|
+
"observed_at_iso": _iso(self.observed_at),
|
|
460
|
+
"fresh_until": self.fresh_until,
|
|
461
|
+
"fresh_until_iso": _iso(self.fresh_until),
|
|
462
|
+
"freshness": self.freshness,
|
|
463
|
+
"evidence_refs": list(self.evidence_refs),
|
|
464
|
+
"subject_person_id": self.subject_person_id,
|
|
465
|
+
"viewer_scope": self.viewer_scope,
|
|
466
|
+
"shareability": self.shareability,
|
|
467
|
+
"attributes": dict(self.attributes),
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@dataclass(frozen=True)
|
|
472
|
+
class SituationSnapshotV1:
|
|
473
|
+
snapshot_id: str
|
|
474
|
+
snapshot_digest: str
|
|
475
|
+
subject_person_id: str
|
|
476
|
+
viewer_scope: str
|
|
477
|
+
shareability: str
|
|
478
|
+
as_of: float
|
|
479
|
+
facts: tuple[SituationFactV1, ...]
|
|
480
|
+
coverage: tuple[tuple[str, str], ...]
|
|
481
|
+
omitted: tuple[tuple[str, int], ...]
|
|
482
|
+
evidence_refs: tuple[str, ...]
|
|
483
|
+
schema: str = "SituationSnapshotV1"
|
|
484
|
+
version: int = SITUATION_VERSION
|
|
485
|
+
|
|
486
|
+
def freshness(self, category: str) -> str:
|
|
487
|
+
return dict(self.coverage).get(category, "unknown")
|
|
488
|
+
|
|
489
|
+
def active_facts(self, category: str) -> tuple[SituationFactV1, ...]:
|
|
490
|
+
return tuple(
|
|
491
|
+
fact for fact in self.facts
|
|
492
|
+
if fact.category == category and fact.active
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
def fresh_active_facts(self, category: str) -> tuple[SituationFactV1, ...]:
|
|
496
|
+
"""Return only current active facts for an operational decision.
|
|
497
|
+
|
|
498
|
+
Stale facts remain visible through ``active_facts`` for explanation,
|
|
499
|
+
but they must not veto a decision merely because another entity made
|
|
500
|
+
the category-level coverage fresh.
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
return tuple(
|
|
504
|
+
fact for fact in self.facts
|
|
505
|
+
if fact.category == category
|
|
506
|
+
and fact.active
|
|
507
|
+
and fact.freshness == "fresh"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def public(self) -> Dict[str, Any]:
|
|
511
|
+
return {
|
|
512
|
+
"schema": self.schema,
|
|
513
|
+
"version": self.version,
|
|
514
|
+
"snapshot_id": self.snapshot_id,
|
|
515
|
+
"snapshot_digest": self.snapshot_digest,
|
|
516
|
+
"subject_person_id": self.subject_person_id,
|
|
517
|
+
"viewer_scope": self.viewer_scope,
|
|
518
|
+
"shareability": self.shareability,
|
|
519
|
+
"as_of": self.as_of,
|
|
520
|
+
"as_of_iso": _iso(self.as_of),
|
|
521
|
+
"coverage": dict(self.coverage),
|
|
522
|
+
"facts": [fact.public() for fact in self.facts],
|
|
523
|
+
"omitted": dict(self.omitted),
|
|
524
|
+
"evidence_refs": list(self.evidence_refs),
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _observation_from_payload(payload: Mapping[str, Any]) -> SituationObservationV1:
|
|
529
|
+
return SituationObservationV1.create(
|
|
530
|
+
observation_id=payload["observation_id"],
|
|
531
|
+
category=payload["category"],
|
|
532
|
+
entity_id=payload["entity_id"],
|
|
533
|
+
state=payload["state"],
|
|
534
|
+
active=payload["active"],
|
|
535
|
+
observed_at=payload["observed_at"],
|
|
536
|
+
fresh_until=payload["fresh_until"],
|
|
537
|
+
evidence_refs=payload["evidence_refs"],
|
|
538
|
+
source_kind=payload["source_kind"],
|
|
539
|
+
subject_person_id=payload["subject_person_id"],
|
|
540
|
+
viewer_scope=payload["viewer_scope"],
|
|
541
|
+
shareability=payload["shareability"],
|
|
542
|
+
attributes=payload.get("attributes") or {},
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _fact_from_payload(payload: Mapping[str, Any]) -> SituationFactV1:
|
|
547
|
+
return SituationFactV1(
|
|
548
|
+
observation_id=str(payload["observation_id"]),
|
|
549
|
+
category=str(payload["category"]),
|
|
550
|
+
entity_id=str(payload["entity_id"]),
|
|
551
|
+
state=str(payload["state"]),
|
|
552
|
+
active=bool(payload["active"]),
|
|
553
|
+
observed_at=float(payload["observed_at"]),
|
|
554
|
+
fresh_until=float(payload["fresh_until"]),
|
|
555
|
+
freshness=str(payload["freshness"]),
|
|
556
|
+
evidence_refs=tuple(payload.get("evidence_refs") or ()),
|
|
557
|
+
subject_person_id=str(payload["subject_person_id"]),
|
|
558
|
+
viewer_scope=str(payload["viewer_scope"]),
|
|
559
|
+
shareability=str(payload["shareability"]),
|
|
560
|
+
attributes=_attributes(payload.get("attributes") or {}),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class SituationStore:
|
|
565
|
+
"""SQLite ledger for observations, latest facts, and immutable snapshots."""
|
|
566
|
+
|
|
567
|
+
def __init__(self, db_path: str) -> None:
|
|
568
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
569
|
+
self._conn.row_factory = sqlite3.Row
|
|
570
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
571
|
+
self._lock = threading.RLock()
|
|
572
|
+
self._closed = False
|
|
573
|
+
self._conn.executescript(
|
|
574
|
+
"""
|
|
575
|
+
CREATE TABLE IF NOT EXISTS situation_observations (
|
|
576
|
+
observation_id TEXT PRIMARY KEY,
|
|
577
|
+
observation_digest TEXT NOT NULL,
|
|
578
|
+
category TEXT NOT NULL,
|
|
579
|
+
entity_id TEXT NOT NULL,
|
|
580
|
+
subject_person_id TEXT NOT NULL,
|
|
581
|
+
viewer_scope TEXT NOT NULL,
|
|
582
|
+
payload TEXT NOT NULL,
|
|
583
|
+
recorded_at REAL NOT NULL
|
|
584
|
+
);
|
|
585
|
+
CREATE TABLE IF NOT EXISTS situation_facts (
|
|
586
|
+
subject_person_id TEXT NOT NULL,
|
|
587
|
+
viewer_scope TEXT NOT NULL,
|
|
588
|
+
category TEXT NOT NULL,
|
|
589
|
+
entity_id TEXT NOT NULL,
|
|
590
|
+
observation_id TEXT NOT NULL,
|
|
591
|
+
observed_at REAL NOT NULL,
|
|
592
|
+
fresh_until REAL NOT NULL,
|
|
593
|
+
payload TEXT NOT NULL,
|
|
594
|
+
PRIMARY KEY(subject_person_id,viewer_scope,category,entity_id)
|
|
595
|
+
);
|
|
596
|
+
CREATE INDEX IF NOT EXISTS idx_situation_fact_scope
|
|
597
|
+
ON situation_facts(subject_person_id,viewer_scope,category,observed_at);
|
|
598
|
+
CREATE TABLE IF NOT EXISTS situation_snapshots (
|
|
599
|
+
snapshot_id TEXT PRIMARY KEY,
|
|
600
|
+
snapshot_digest TEXT NOT NULL UNIQUE,
|
|
601
|
+
subject_person_id TEXT NOT NULL,
|
|
602
|
+
viewer_scope TEXT NOT NULL,
|
|
603
|
+
as_of REAL NOT NULL,
|
|
604
|
+
payload TEXT NOT NULL,
|
|
605
|
+
created_at REAL NOT NULL
|
|
606
|
+
);
|
|
607
|
+
CREATE TABLE IF NOT EXISTS situation_event_cursors (
|
|
608
|
+
consumer_id TEXT PRIMARY KEY,
|
|
609
|
+
last_seq INTEGER NOT NULL,
|
|
610
|
+
bootstrap_mode TEXT NOT NULL,
|
|
611
|
+
last_error TEXT NOT NULL DEFAULT '',
|
|
612
|
+
updated_at REAL NOT NULL
|
|
613
|
+
);
|
|
614
|
+
CREATE TABLE IF NOT EXISTS situation_event_receipts (
|
|
615
|
+
consumer_id TEXT NOT NULL,
|
|
616
|
+
event_id TEXT NOT NULL,
|
|
617
|
+
event_seq INTEGER NOT NULL,
|
|
618
|
+
event_digest TEXT NOT NULL,
|
|
619
|
+
disposition TEXT NOT NULL,
|
|
620
|
+
observation_ids TEXT NOT NULL,
|
|
621
|
+
processed_at REAL NOT NULL,
|
|
622
|
+
PRIMARY KEY(consumer_id,event_id)
|
|
623
|
+
);
|
|
624
|
+
CREATE TABLE IF NOT EXISTS situation_event_gaps (
|
|
625
|
+
gap_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
626
|
+
consumer_id TEXT NOT NULL,
|
|
627
|
+
prior_cursor INTEGER NOT NULL,
|
|
628
|
+
resume_after INTEGER NOT NULL,
|
|
629
|
+
reason TEXT NOT NULL,
|
|
630
|
+
acknowledged_at REAL NOT NULL
|
|
631
|
+
);
|
|
632
|
+
"""
|
|
633
|
+
)
|
|
634
|
+
self._conn.commit()
|
|
635
|
+
|
|
636
|
+
def close(self) -> None:
|
|
637
|
+
"""Release the feature-owned SQLite handle; exact retries are safe."""
|
|
638
|
+
|
|
639
|
+
with self._lock:
|
|
640
|
+
if self._closed:
|
|
641
|
+
return
|
|
642
|
+
self._conn.close()
|
|
643
|
+
self._closed = True
|
|
644
|
+
|
|
645
|
+
def _apply_locked(self, observation: SituationObservationV1) -> str:
|
|
646
|
+
payload = _canonical(observation.payload())
|
|
647
|
+
existing = self._conn.execute(
|
|
648
|
+
"SELECT observation_digest FROM situation_observations "
|
|
649
|
+
"WHERE observation_id=?", (observation.observation_id,),
|
|
650
|
+
).fetchone()
|
|
651
|
+
if existing is not None:
|
|
652
|
+
if existing["observation_digest"] != observation.digest:
|
|
653
|
+
raise ValueError("observation id replayed with conflicting content")
|
|
654
|
+
return "duplicate"
|
|
655
|
+
|
|
656
|
+
current = self._conn.execute(
|
|
657
|
+
"SELECT observation_id,observed_at,payload FROM situation_facts "
|
|
658
|
+
"WHERE subject_person_id=? AND viewer_scope=? AND category=? "
|
|
659
|
+
"AND entity_id=?",
|
|
660
|
+
(
|
|
661
|
+
observation.subject_person_id, observation.viewer_scope,
|
|
662
|
+
observation.category, observation.entity_id,
|
|
663
|
+
),
|
|
664
|
+
).fetchone()
|
|
665
|
+
if current is not None and float(current["observed_at"]) == observation.observed_at:
|
|
666
|
+
current_payload = json.loads(current["payload"])
|
|
667
|
+
if _digest(current_payload) != observation.digest:
|
|
668
|
+
raise ValueError("same-time observation conflicts with current fact")
|
|
669
|
+
|
|
670
|
+
self._conn.execute(
|
|
671
|
+
"INSERT INTO situation_observations "
|
|
672
|
+
"(observation_id,observation_digest,category,entity_id,"
|
|
673
|
+
"subject_person_id,viewer_scope,payload,recorded_at) "
|
|
674
|
+
"VALUES (?,?,?,?,?,?,?,?)",
|
|
675
|
+
(
|
|
676
|
+
observation.observation_id, observation.digest,
|
|
677
|
+
observation.category, observation.entity_id,
|
|
678
|
+
observation.subject_person_id, observation.viewer_scope,
|
|
679
|
+
payload, time.time(),
|
|
680
|
+
),
|
|
681
|
+
)
|
|
682
|
+
if current is not None and float(current["observed_at"]) > observation.observed_at:
|
|
683
|
+
return "historical"
|
|
684
|
+
self._conn.execute(
|
|
685
|
+
"INSERT INTO situation_facts "
|
|
686
|
+
"(subject_person_id,viewer_scope,category,entity_id,observation_id,"
|
|
687
|
+
"observed_at,fresh_until,payload) VALUES (?,?,?,?,?,?,?,?) "
|
|
688
|
+
"ON CONFLICT(subject_person_id,viewer_scope,category,entity_id) "
|
|
689
|
+
"DO UPDATE SET observation_id=excluded.observation_id,"
|
|
690
|
+
"observed_at=excluded.observed_at,fresh_until=excluded.fresh_until,"
|
|
691
|
+
"payload=excluded.payload",
|
|
692
|
+
(
|
|
693
|
+
observation.subject_person_id, observation.viewer_scope,
|
|
694
|
+
observation.category, observation.entity_id,
|
|
695
|
+
observation.observation_id, observation.observed_at,
|
|
696
|
+
observation.fresh_until, payload,
|
|
697
|
+
),
|
|
698
|
+
)
|
|
699
|
+
return "applied"
|
|
700
|
+
|
|
701
|
+
def ingest(self, observation: SituationObservationV1) -> Dict[str, Any]:
|
|
702
|
+
with self._lock:
|
|
703
|
+
try:
|
|
704
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
705
|
+
disposition = self._apply_locked(observation)
|
|
706
|
+
self._conn.commit()
|
|
707
|
+
except Exception:
|
|
708
|
+
self._conn.rollback()
|
|
709
|
+
raise
|
|
710
|
+
return {
|
|
711
|
+
"disposition": disposition,
|
|
712
|
+
"observation_id": observation.observation_id,
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
@staticmethod
|
|
716
|
+
def _visible(row: sqlite3.Row, viewer_scope: str) -> bool:
|
|
717
|
+
payload = json.loads(row["payload"])
|
|
718
|
+
sharing = payload.get("shareability")
|
|
719
|
+
declared = payload.get("viewer_scope")
|
|
720
|
+
if sharing == "public":
|
|
721
|
+
return True
|
|
722
|
+
return declared == viewer_scope
|
|
723
|
+
|
|
724
|
+
def snapshot(
|
|
725
|
+
self,
|
|
726
|
+
*,
|
|
727
|
+
subject_person_id: str,
|
|
728
|
+
viewer_scope: str,
|
|
729
|
+
as_of: Optional[float] = None,
|
|
730
|
+
per_category_limit: int = 20,
|
|
731
|
+
total_limit: int = 160,
|
|
732
|
+
) -> SituationSnapshotV1:
|
|
733
|
+
subject = _safe_id(subject_person_id, "subject_person_id", maximum=128)
|
|
734
|
+
viewer = _safe_id(
|
|
735
|
+
viewer_scope, "viewer_scope", maximum=VIEWER_SCOPE_MAX_CHARS,
|
|
736
|
+
)
|
|
737
|
+
point = _epoch(as_of if as_of is not None else time.time(), "as_of")
|
|
738
|
+
per_limit = max(1, min(50, int(per_category_limit)))
|
|
739
|
+
overall = max(1, min(200, int(total_limit)))
|
|
740
|
+
with self._lock:
|
|
741
|
+
rows = self._conn.execute(
|
|
742
|
+
"SELECT * FROM situation_facts WHERE subject_person_id=? "
|
|
743
|
+
"ORDER BY category ASC,observed_at DESC,entity_id ASC",
|
|
744
|
+
(subject,),
|
|
745
|
+
).fetchall()
|
|
746
|
+
|
|
747
|
+
grouped: Dict[str, list[SituationFactV1]] = {
|
|
748
|
+
category: [] for category in SITUATION_CATEGORIES
|
|
749
|
+
}
|
|
750
|
+
omitted: Dict[str, int] = {}
|
|
751
|
+
for row in rows:
|
|
752
|
+
if not self._visible(row, viewer):
|
|
753
|
+
continue
|
|
754
|
+
payload = json.loads(row["payload"])
|
|
755
|
+
category = str(payload["category"])
|
|
756
|
+
if len(grouped[category]) >= per_limit:
|
|
757
|
+
omitted[category] = omitted.get(category, 0) + 1
|
|
758
|
+
continue
|
|
759
|
+
freshness = "fresh" if float(payload["fresh_until"]) >= point else "stale"
|
|
760
|
+
fact = SituationFactV1(
|
|
761
|
+
observation_id=str(payload["observation_id"]),
|
|
762
|
+
category=category,
|
|
763
|
+
entity_id=str(payload["entity_id"]),
|
|
764
|
+
state=str(payload["state"]),
|
|
765
|
+
active=bool(payload["active"]),
|
|
766
|
+
observed_at=float(payload["observed_at"]),
|
|
767
|
+
fresh_until=float(payload["fresh_until"]),
|
|
768
|
+
freshness=freshness,
|
|
769
|
+
evidence_refs=tuple(payload["evidence_refs"]),
|
|
770
|
+
subject_person_id=str(payload["subject_person_id"]),
|
|
771
|
+
viewer_scope=str(payload["viewer_scope"]),
|
|
772
|
+
shareability=str(payload["shareability"]),
|
|
773
|
+
attributes=_attributes(payload.get("attributes") or {}),
|
|
774
|
+
)
|
|
775
|
+
grouped[category].append(fact)
|
|
776
|
+
|
|
777
|
+
facts: list[SituationFactV1] = []
|
|
778
|
+
coverage: list[tuple[str, str]] = []
|
|
779
|
+
for category in SITUATION_CATEGORIES:
|
|
780
|
+
available = grouped[category]
|
|
781
|
+
if not available:
|
|
782
|
+
coverage.append((category, "unknown"))
|
|
783
|
+
continue
|
|
784
|
+
coverage.append((
|
|
785
|
+
category,
|
|
786
|
+
"fresh" if any(f.freshness == "fresh" for f in available)
|
|
787
|
+
else "stale",
|
|
788
|
+
))
|
|
789
|
+
for fact in available:
|
|
790
|
+
if len(facts) >= overall:
|
|
791
|
+
omitted[category] = omitted.get(category, 0) + 1
|
|
792
|
+
else:
|
|
793
|
+
facts.append(fact)
|
|
794
|
+
|
|
795
|
+
refs = tuple(dict.fromkeys(
|
|
796
|
+
ref for fact in facts for ref in fact.evidence_refs
|
|
797
|
+
))[:100]
|
|
798
|
+
sharing = (
|
|
799
|
+
"owner_private" if viewer == "owner"
|
|
800
|
+
else "subject_private" if viewer == f"person:{subject}"
|
|
801
|
+
else "public" if viewer == "public"
|
|
802
|
+
else "shared"
|
|
803
|
+
)
|
|
804
|
+
authority = {
|
|
805
|
+
"schema": "SituationSnapshotV1",
|
|
806
|
+
"version": 1,
|
|
807
|
+
"subject_person_id": subject,
|
|
808
|
+
"viewer_scope": viewer,
|
|
809
|
+
"shareability": sharing,
|
|
810
|
+
"as_of": point,
|
|
811
|
+
"facts": [fact.public() for fact in facts],
|
|
812
|
+
"coverage": dict(coverage),
|
|
813
|
+
"omitted": omitted,
|
|
814
|
+
"evidence_refs": list(refs),
|
|
815
|
+
}
|
|
816
|
+
digest = _digest(authority)
|
|
817
|
+
snapshot = SituationSnapshotV1(
|
|
818
|
+
snapshot_id=f"ss-{digest[:24]}",
|
|
819
|
+
snapshot_digest=digest,
|
|
820
|
+
subject_person_id=subject,
|
|
821
|
+
viewer_scope=viewer,
|
|
822
|
+
shareability=sharing,
|
|
823
|
+
as_of=point,
|
|
824
|
+
facts=tuple(facts),
|
|
825
|
+
coverage=tuple(coverage),
|
|
826
|
+
omitted=tuple(sorted(omitted.items())),
|
|
827
|
+
evidence_refs=refs,
|
|
828
|
+
)
|
|
829
|
+
with self._lock:
|
|
830
|
+
self._conn.execute(
|
|
831
|
+
"INSERT OR IGNORE INTO situation_snapshots "
|
|
832
|
+
"(snapshot_id,snapshot_digest,subject_person_id,viewer_scope,"
|
|
833
|
+
"as_of,payload,created_at) VALUES (?,?,?,?,?,?,?)",
|
|
834
|
+
(
|
|
835
|
+
snapshot.snapshot_id, snapshot.snapshot_digest, subject,
|
|
836
|
+
viewer, point, _canonical(snapshot.public()), time.time(),
|
|
837
|
+
),
|
|
838
|
+
)
|
|
839
|
+
self._conn.commit()
|
|
840
|
+
return snapshot
|
|
841
|
+
|
|
842
|
+
def get_snapshot(
|
|
843
|
+
self, snapshot_id: str, *, viewer_scope: str,
|
|
844
|
+
) -> Optional[SituationSnapshotV1]:
|
|
845
|
+
snapshot_key = _safe_id(snapshot_id, "snapshot_id")
|
|
846
|
+
viewer = _safe_id(
|
|
847
|
+
viewer_scope, "viewer_scope", maximum=VIEWER_SCOPE_MAX_CHARS,
|
|
848
|
+
)
|
|
849
|
+
with self._lock:
|
|
850
|
+
row = self._conn.execute(
|
|
851
|
+
"SELECT payload,viewer_scope FROM situation_snapshots "
|
|
852
|
+
"WHERE snapshot_id=?", (snapshot_key,),
|
|
853
|
+
).fetchone()
|
|
854
|
+
if row is None:
|
|
855
|
+
return None
|
|
856
|
+
payload = json.loads(row["payload"])
|
|
857
|
+
if row["viewer_scope"] != viewer and payload.get("shareability") != "public":
|
|
858
|
+
return None
|
|
859
|
+
return SituationSnapshotV1(
|
|
860
|
+
snapshot_id=payload["snapshot_id"],
|
|
861
|
+
snapshot_digest=payload["snapshot_digest"],
|
|
862
|
+
subject_person_id=payload["subject_person_id"],
|
|
863
|
+
viewer_scope=payload["viewer_scope"],
|
|
864
|
+
shareability=payload["shareability"],
|
|
865
|
+
as_of=float(payload["as_of"]),
|
|
866
|
+
facts=tuple(_fact_from_payload(fact) for fact in payload["facts"]),
|
|
867
|
+
coverage=tuple(
|
|
868
|
+
(category, (payload.get("coverage") or {}).get(category, "unknown"))
|
|
869
|
+
for category in SITUATION_CATEGORIES
|
|
870
|
+
),
|
|
871
|
+
omitted=tuple(sorted((payload.get("omitted") or {}).items())),
|
|
872
|
+
evidence_refs=tuple(payload.get("evidence_refs") or ()),
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
def initialize_cursor(
|
|
876
|
+
self, consumer_id: str, sequence: int, *, bootstrap_mode: str,
|
|
877
|
+
) -> int:
|
|
878
|
+
consumer = _safe_id(consumer_id, "consumer_id", maximum=128)
|
|
879
|
+
seq = max(0, int(sequence))
|
|
880
|
+
if bootstrap_mode not in {"tail", "replay"}:
|
|
881
|
+
raise ValueError("invalid bootstrap mode")
|
|
882
|
+
with self._lock:
|
|
883
|
+
self._conn.execute(
|
|
884
|
+
"INSERT OR IGNORE INTO situation_event_cursors "
|
|
885
|
+
"(consumer_id,last_seq,bootstrap_mode,updated_at) VALUES (?,?,?,?)",
|
|
886
|
+
(consumer, seq, bootstrap_mode, time.time()),
|
|
887
|
+
)
|
|
888
|
+
self._conn.commit()
|
|
889
|
+
row = self._conn.execute(
|
|
890
|
+
"SELECT last_seq FROM situation_event_cursors WHERE consumer_id=?",
|
|
891
|
+
(consumer,),
|
|
892
|
+
).fetchone()
|
|
893
|
+
return int(row["last_seq"])
|
|
894
|
+
|
|
895
|
+
def cursor(self, consumer_id: str) -> Optional[int]:
|
|
896
|
+
consumer = _safe_id(consumer_id, "consumer_id", maximum=128)
|
|
897
|
+
with self._lock:
|
|
898
|
+
row = self._conn.execute(
|
|
899
|
+
"SELECT last_seq FROM situation_event_cursors WHERE consumer_id=?",
|
|
900
|
+
(consumer,),
|
|
901
|
+
).fetchone()
|
|
902
|
+
return int(row["last_seq"]) if row is not None else None
|
|
903
|
+
|
|
904
|
+
def set_cursor_error(self, consumer_id: str, error: str) -> None:
|
|
905
|
+
consumer = _safe_id(consumer_id, "consumer_id", maximum=128)
|
|
906
|
+
with self._lock:
|
|
907
|
+
self._conn.execute(
|
|
908
|
+
"UPDATE situation_event_cursors SET last_error=?,updated_at=? "
|
|
909
|
+
"WHERE consumer_id=?", (str(error)[:200], time.time(), consumer),
|
|
910
|
+
)
|
|
911
|
+
self._conn.commit()
|
|
912
|
+
|
|
913
|
+
def acknowledge_gap(
|
|
914
|
+
self,
|
|
915
|
+
consumer_id: str,
|
|
916
|
+
*,
|
|
917
|
+
prior_cursor: int,
|
|
918
|
+
resume_after: int,
|
|
919
|
+
reason: str,
|
|
920
|
+
) -> int:
|
|
921
|
+
consumer = _safe_id(consumer_id, "consumer_id", maximum=128)
|
|
922
|
+
prior = int(prior_cursor)
|
|
923
|
+
resume = int(resume_after)
|
|
924
|
+
if resume <= prior:
|
|
925
|
+
raise ValueError("situation gap resume cursor must advance")
|
|
926
|
+
with self._lock:
|
|
927
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
928
|
+
try:
|
|
929
|
+
row = self._conn.execute(
|
|
930
|
+
"SELECT last_seq FROM situation_event_cursors "
|
|
931
|
+
"WHERE consumer_id=?", (consumer,),
|
|
932
|
+
).fetchone()
|
|
933
|
+
if row is None or int(row["last_seq"]) != prior:
|
|
934
|
+
raise ValueError("situation gap cursor changed")
|
|
935
|
+
now = time.time()
|
|
936
|
+
self._conn.execute(
|
|
937
|
+
"INSERT INTO situation_event_gaps "
|
|
938
|
+
"(consumer_id,prior_cursor,resume_after,reason,acknowledged_at) "
|
|
939
|
+
"VALUES (?,?,?,?,?)",
|
|
940
|
+
(consumer, prior, resume, str(reason)[:200], now),
|
|
941
|
+
)
|
|
942
|
+
self._conn.execute(
|
|
943
|
+
"UPDATE situation_event_cursors SET last_seq=?,last_error='',"
|
|
944
|
+
"updated_at=? WHERE consumer_id=?",
|
|
945
|
+
(resume, now, consumer),
|
|
946
|
+
)
|
|
947
|
+
self._conn.commit()
|
|
948
|
+
except Exception:
|
|
949
|
+
self._conn.rollback()
|
|
950
|
+
raise
|
|
951
|
+
return resume
|
|
952
|
+
|
|
953
|
+
def reduce_event(
|
|
954
|
+
self,
|
|
955
|
+
*,
|
|
956
|
+
consumer_id: str,
|
|
957
|
+
event_seq: int,
|
|
958
|
+
event_id: str,
|
|
959
|
+
event_digest: str,
|
|
960
|
+
observations: Sequence[SituationObservationV1],
|
|
961
|
+
disposition: str,
|
|
962
|
+
) -> Dict[str, Any]:
|
|
963
|
+
consumer = _safe_id(consumer_id, "consumer_id", maximum=128)
|
|
964
|
+
native_id = _safe_id(event_id, "event_id")
|
|
965
|
+
digest = _safe_id(event_digest, "event_digest")
|
|
966
|
+
seq = int(event_seq)
|
|
967
|
+
if seq < 1:
|
|
968
|
+
raise ValueError("event sequence must be positive")
|
|
969
|
+
with self._lock:
|
|
970
|
+
try:
|
|
971
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
972
|
+
prior = self._conn.execute(
|
|
973
|
+
"SELECT * FROM situation_event_receipts WHERE consumer_id=? "
|
|
974
|
+
"AND event_id=?", (consumer, native_id),
|
|
975
|
+
).fetchone()
|
|
976
|
+
cursor_row = self._conn.execute(
|
|
977
|
+
"SELECT last_seq FROM situation_event_cursors "
|
|
978
|
+
"WHERE consumer_id=?", (consumer,),
|
|
979
|
+
).fetchone()
|
|
980
|
+
cursor = (
|
|
981
|
+
int(cursor_row["last_seq"])
|
|
982
|
+
if cursor_row is not None else None
|
|
983
|
+
)
|
|
984
|
+
if prior is not None:
|
|
985
|
+
if (
|
|
986
|
+
int(prior["event_seq"]) != seq
|
|
987
|
+
or prior["event_digest"] != digest
|
|
988
|
+
):
|
|
989
|
+
raise ValueError(
|
|
990
|
+
"event id replayed with conflicting content",
|
|
991
|
+
)
|
|
992
|
+
self._conn.commit()
|
|
993
|
+
return {
|
|
994
|
+
"disposition": "duplicate_event",
|
|
995
|
+
"cursor": cursor,
|
|
996
|
+
"observation_ids": json.loads(prior["observation_ids"]),
|
|
997
|
+
}
|
|
998
|
+
if cursor is None:
|
|
999
|
+
raise ValueError("situation event cursor is not initialized")
|
|
1000
|
+
if seq != cursor + 1:
|
|
1001
|
+
raise ValueError("event sequence is not cursor-contiguous")
|
|
1002
|
+
applied = [self._apply_locked(item) for item in observations]
|
|
1003
|
+
ids = [item.observation_id for item in observations]
|
|
1004
|
+
self._conn.execute(
|
|
1005
|
+
"INSERT INTO situation_event_receipts "
|
|
1006
|
+
"(consumer_id,event_id,event_seq,event_digest,disposition,"
|
|
1007
|
+
"observation_ids,processed_at) VALUES (?,?,?,?,?,?,?)",
|
|
1008
|
+
(
|
|
1009
|
+
consumer, native_id, seq, digest, disposition,
|
|
1010
|
+
_canonical(ids), time.time(),
|
|
1011
|
+
),
|
|
1012
|
+
)
|
|
1013
|
+
self._conn.execute(
|
|
1014
|
+
"UPDATE situation_event_cursors SET last_seq=?,last_error='',"
|
|
1015
|
+
"updated_at=? WHERE consumer_id=?",
|
|
1016
|
+
(seq, time.time(), consumer),
|
|
1017
|
+
)
|
|
1018
|
+
self._conn.commit()
|
|
1019
|
+
except Exception:
|
|
1020
|
+
self._conn.rollback()
|
|
1021
|
+
raise
|
|
1022
|
+
return {
|
|
1023
|
+
"disposition": disposition,
|
|
1024
|
+
"cursor": seq,
|
|
1025
|
+
"observation_ids": ids,
|
|
1026
|
+
"observation_dispositions": applied,
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
def status(self, consumer_id: str) -> Dict[str, Any]:
|
|
1030
|
+
consumer = _safe_id(consumer_id, "consumer_id", maximum=128)
|
|
1031
|
+
with self._lock:
|
|
1032
|
+
row = self._conn.execute(
|
|
1033
|
+
"SELECT * FROM situation_event_cursors WHERE consumer_id=?",
|
|
1034
|
+
(consumer,),
|
|
1035
|
+
).fetchone()
|
|
1036
|
+
counts = self._conn.execute(
|
|
1037
|
+
"SELECT disposition,COUNT(*) n FROM situation_event_receipts "
|
|
1038
|
+
"WHERE consumer_id=? GROUP BY disposition", (consumer,),
|
|
1039
|
+
).fetchall()
|
|
1040
|
+
gaps = self._conn.execute(
|
|
1041
|
+
"SELECT COUNT(*) AS n,MAX(resume_after) AS last_resume "
|
|
1042
|
+
"FROM situation_event_gaps WHERE consumer_id=?", (consumer,),
|
|
1043
|
+
).fetchone()
|
|
1044
|
+
return {
|
|
1045
|
+
"initialized": row is not None,
|
|
1046
|
+
"cursor": int(row["last_seq"]) if row is not None else None,
|
|
1047
|
+
"bootstrap_mode": row["bootstrap_mode"] if row is not None else None,
|
|
1048
|
+
"last_error": row["last_error"] if row is not None else "",
|
|
1049
|
+
"gaps": {
|
|
1050
|
+
"count": int(gaps["n"] or 0),
|
|
1051
|
+
"last_resume": int(gaps["last_resume"] or 0),
|
|
1052
|
+
},
|
|
1053
|
+
"dispositions": {item["disposition"]: int(item["n"]) for item in counts},
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
class JournalSituationAdapter:
|
|
1058
|
+
"""Convert attested structured event families into observations.
|
|
1059
|
+
|
|
1060
|
+
Free-form fields such as ``summary`` and ``description`` are deliberately
|
|
1061
|
+
ignored. An event name/status may select a state; prose cannot.
|
|
1062
|
+
"""
|
|
1063
|
+
|
|
1064
|
+
_STATE_KEYS = ("state", "status", "availability", "health")
|
|
1065
|
+
_ENTITY_KEYS: Dict[str, tuple[str, ...]] = {
|
|
1066
|
+
"owner_engagement": ("owner_person_id", "person_id"),
|
|
1067
|
+
"activity": ("activity_id", "session_id", "id"),
|
|
1068
|
+
"conversation": ("conversation_id", "session_id", "channel_id"),
|
|
1069
|
+
"person": ("person_id", "contact_id"),
|
|
1070
|
+
"channel": ("channel_id", "channel", "transport", "surface_id"),
|
|
1071
|
+
"service": ("service_id", "service", "component"),
|
|
1072
|
+
"approval": ("approval_id", "action_digest"),
|
|
1073
|
+
"commitment": ("commitment_id",),
|
|
1074
|
+
"project": ("project_id",),
|
|
1075
|
+
"resource": ("resource_id", "device_id", "work_order_id", "id"),
|
|
1076
|
+
"capability": ("capability_id", "capability"),
|
|
1077
|
+
"relationship": ("relationship_id", "contact_id", "person_id"),
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
@staticmethod
|
|
1081
|
+
def _scope(data: Mapping[str, Any]) -> tuple[str, str, str]:
|
|
1082
|
+
subject = str(
|
|
1083
|
+
data.get("subject_person_id") or data.get("person_id")
|
|
1084
|
+
or data.get("contact_id")
|
|
1085
|
+
or os.environ.get("COLONY_OWNER_PERSON_ID", "").strip()
|
|
1086
|
+
or os.environ.get("COLONY_OWNER_CONTACT_ID", "").strip()
|
|
1087
|
+
or "owner"
|
|
1088
|
+
)
|
|
1089
|
+
sharing = str(
|
|
1090
|
+
data.get("shareability") or data.get("privacy_scope")
|
|
1091
|
+
or "owner_private"
|
|
1092
|
+
).strip().lower()
|
|
1093
|
+
if sharing in {"shared", "public"} and data.get("boundary_attested") is not True:
|
|
1094
|
+
sharing = "owner_private"
|
|
1095
|
+
if sharing == "subject_private":
|
|
1096
|
+
viewer = f"person:{subject}"
|
|
1097
|
+
elif sharing == "public":
|
|
1098
|
+
viewer = "public"
|
|
1099
|
+
elif sharing == "shared":
|
|
1100
|
+
group = str(data.get("viewer_group") or "").strip()
|
|
1101
|
+
viewer = f"shared:{group}" if group else "shared"
|
|
1102
|
+
else:
|
|
1103
|
+
sharing, viewer = "owner_private", "owner"
|
|
1104
|
+
return _validate_scope(subject, viewer, sharing)
|
|
1105
|
+
|
|
1106
|
+
@staticmethod
|
|
1107
|
+
def _state(data: Mapping[str, Any], suffix: str) -> str:
|
|
1108
|
+
candidate = ""
|
|
1109
|
+
for key in JournalSituationAdapter._STATE_KEYS:
|
|
1110
|
+
value = str(data.get(key) or "").strip().lower()
|
|
1111
|
+
if value:
|
|
1112
|
+
candidate = value
|
|
1113
|
+
break
|
|
1114
|
+
candidate = candidate or suffix.replace("-", "_") or "observed"
|
|
1115
|
+
candidate = re.sub(r"[^a-z0-9_.:\-]", "_", candidate)[:64]
|
|
1116
|
+
return candidate if _SAFE_STATE.fullmatch(candidate) else "observed"
|
|
1117
|
+
|
|
1118
|
+
@staticmethod
|
|
1119
|
+
def _category(event_type: str) -> Optional[str]:
|
|
1120
|
+
if event_type == "conversation.turn":
|
|
1121
|
+
return "conversation"
|
|
1122
|
+
prefix = event_type.split(".", 1)[0]
|
|
1123
|
+
return {
|
|
1124
|
+
"presence": "person",
|
|
1125
|
+
"person": "person",
|
|
1126
|
+
"owner": "owner_engagement",
|
|
1127
|
+
"activity": "activity",
|
|
1128
|
+
"conversation": "conversation",
|
|
1129
|
+
"channel": "channel",
|
|
1130
|
+
"delivery": "channel",
|
|
1131
|
+
"outreach": "channel",
|
|
1132
|
+
"service": "service",
|
|
1133
|
+
"health": "service",
|
|
1134
|
+
"approval": "approval",
|
|
1135
|
+
"commitment": "commitment",
|
|
1136
|
+
"project": "project",
|
|
1137
|
+
"work_order": "resource",
|
|
1138
|
+
"work": "resource",
|
|
1139
|
+
"resource": "resource",
|
|
1140
|
+
"device": "resource",
|
|
1141
|
+
"capability": "capability",
|
|
1142
|
+
"relationship": "relationship",
|
|
1143
|
+
"contact": "relationship",
|
|
1144
|
+
}.get(prefix)
|
|
1145
|
+
|
|
1146
|
+
@staticmethod
|
|
1147
|
+
def _active(category: str, state: str) -> bool:
|
|
1148
|
+
terminal: Dict[str, frozenset[str]] = {
|
|
1149
|
+
"conversation": frozenset({"ended", "closed"}),
|
|
1150
|
+
"person": frozenset({"left", "absent"}),
|
|
1151
|
+
"approval": frozenset({
|
|
1152
|
+
"approved", "denied", "expired", "cancelled", "consumed",
|
|
1153
|
+
}),
|
|
1154
|
+
"commitment": frozenset({"fulfilled", "cancelled", "resolved"}),
|
|
1155
|
+
"project": frozenset({
|
|
1156
|
+
"completed", "verified", "failed", "abandoned",
|
|
1157
|
+
"cancelled", "resolved",
|
|
1158
|
+
}),
|
|
1159
|
+
"relationship": frozenset({"ended", "closed", "resolved"}),
|
|
1160
|
+
}
|
|
1161
|
+
# Service/channel/resource facts remain active even when negative: a
|
|
1162
|
+
# current "offline" probe is precisely what policy must see.
|
|
1163
|
+
return state not in terminal.get(category, frozenset())
|
|
1164
|
+
|
|
1165
|
+
@classmethod
|
|
1166
|
+
def adapt(
|
|
1167
|
+
cls,
|
|
1168
|
+
event: Mapping[str, Any],
|
|
1169
|
+
*,
|
|
1170
|
+
source_kind: str = "colony_event",
|
|
1171
|
+
) -> tuple[tuple[SituationObservationV1, ...], str, str]:
|
|
1172
|
+
if not isinstance(event, Mapping):
|
|
1173
|
+
raise ValueError("event must be an object")
|
|
1174
|
+
seq = int(event.get("seq") or 0)
|
|
1175
|
+
event_id = _safe_id(
|
|
1176
|
+
event.get("ulid") or event.get("event_id") or f"journal-seq-{seq}",
|
|
1177
|
+
"event_id",
|
|
1178
|
+
)
|
|
1179
|
+
event_type = str(event.get("type") or "").strip().lower()
|
|
1180
|
+
if seq < 1 or not re.fullmatch(r"[a-z0-9][a-z0-9_.:\-]{0,127}", event_type):
|
|
1181
|
+
raise ValueError("malformed event identity")
|
|
1182
|
+
data = event.get("data")
|
|
1183
|
+
if not isinstance(data, Mapping):
|
|
1184
|
+
raise ValueError("event data must be an object")
|
|
1185
|
+
occurred = _epoch(
|
|
1186
|
+
event.get("occurredAt") or event.get("occurred_at")
|
|
1187
|
+
or event.get("recordedAt"),
|
|
1188
|
+
"occurred_at",
|
|
1189
|
+
)
|
|
1190
|
+
raw_digest = _digest({
|
|
1191
|
+
"type": event_type, "occurred_at": occurred, "data": data,
|
|
1192
|
+
})
|
|
1193
|
+
if event_type == "cognition.external.service_state":
|
|
1194
|
+
from apsimo.cognition.external_events import (
|
|
1195
|
+
validate_external_journal_projection,
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
projection = validate_external_journal_projection(event_type, data)
|
|
1199
|
+
external = projection["attributes"]
|
|
1200
|
+
entity = str(external["service"])
|
|
1201
|
+
state = str(external["state"])
|
|
1202
|
+
subject, viewer, sharing = _validate_scope(
|
|
1203
|
+
str(projection["subject_person_id"]),
|
|
1204
|
+
str(projection["viewer_scope"]),
|
|
1205
|
+
str(projection["shareability"]),
|
|
1206
|
+
)
|
|
1207
|
+
evidence = (f"journal:{seq}:{event_id}",)
|
|
1208
|
+
attributes: Dict[str, Any] = {"event_type": event_type}
|
|
1209
|
+
for key in ("latency_ms", "observed_samples"):
|
|
1210
|
+
if key in external:
|
|
1211
|
+
attributes[key] = external[key]
|
|
1212
|
+
observation = SituationObservationV1.create(
|
|
1213
|
+
observation_id=(
|
|
1214
|
+
f"so-{_digest((event_id, 'service', entity))[:24]}"
|
|
1215
|
+
),
|
|
1216
|
+
category="service",
|
|
1217
|
+
entity_id=entity,
|
|
1218
|
+
state=state,
|
|
1219
|
+
active=cls._active("service", state),
|
|
1220
|
+
observed_at=projection["external_occurred_at"],
|
|
1221
|
+
ttl_seconds=_DEFAULT_TTLS["service"],
|
|
1222
|
+
evidence_refs=evidence,
|
|
1223
|
+
source_kind=source_kind,
|
|
1224
|
+
subject_person_id=subject,
|
|
1225
|
+
viewer_scope=viewer,
|
|
1226
|
+
shareability=sharing,
|
|
1227
|
+
attributes=attributes,
|
|
1228
|
+
)
|
|
1229
|
+
return (observation,), "projected", raw_digest
|
|
1230
|
+
|
|
1231
|
+
category = cls._category(event_type)
|
|
1232
|
+
if category is None:
|
|
1233
|
+
return (), "unmapped_event_type", raw_digest
|
|
1234
|
+
|
|
1235
|
+
suffix = event_type.rsplit(".", 1)[-1]
|
|
1236
|
+
state = cls._state(data, suffix)
|
|
1237
|
+
subject, viewer, sharing = cls._scope(data)
|
|
1238
|
+
entity = ""
|
|
1239
|
+
for key in cls._ENTITY_KEYS[category]:
|
|
1240
|
+
raw = str(data.get(key) or "").strip()
|
|
1241
|
+
if raw and _SAFE_ID.fullmatch(raw):
|
|
1242
|
+
entity = raw
|
|
1243
|
+
break
|
|
1244
|
+
if not entity and category == "owner_engagement":
|
|
1245
|
+
entity = subject
|
|
1246
|
+
if not entity:
|
|
1247
|
+
return (), f"missing_{category}_entity", raw_digest
|
|
1248
|
+
evidence = (f"journal:{seq}:{event_id}",)
|
|
1249
|
+
active = cls._active(category, state)
|
|
1250
|
+
attributes: Dict[str, Any] = {"event_type": event_type}
|
|
1251
|
+
for key in (
|
|
1252
|
+
"channel_id", "channel", "surface_id", "transport", "interruption_cost",
|
|
1253
|
+
"pending_count", "capacity_available", "recipient_person_id",
|
|
1254
|
+
):
|
|
1255
|
+
value = data.get(key)
|
|
1256
|
+
if isinstance(value, _JSON_SCALARS) and value is not None:
|
|
1257
|
+
attributes[key] = value
|
|
1258
|
+
primary = SituationObservationV1.create(
|
|
1259
|
+
observation_id=f"so-{_digest((event_id, category, entity))[:24]}",
|
|
1260
|
+
category=category,
|
|
1261
|
+
entity_id=entity,
|
|
1262
|
+
state=state,
|
|
1263
|
+
active=active,
|
|
1264
|
+
observed_at=occurred,
|
|
1265
|
+
ttl_seconds=_DEFAULT_TTLS[category],
|
|
1266
|
+
evidence_refs=evidence,
|
|
1267
|
+
source_kind=source_kind,
|
|
1268
|
+
subject_person_id=subject,
|
|
1269
|
+
viewer_scope=viewer,
|
|
1270
|
+
shareability=sharing,
|
|
1271
|
+
attributes=attributes,
|
|
1272
|
+
)
|
|
1273
|
+
observations: list[SituationObservationV1] = [primary]
|
|
1274
|
+
|
|
1275
|
+
# A conversation turn is also real, short-lived evidence of the
|
|
1276
|
+
# participant and channel. It says nothing about room occupancy.
|
|
1277
|
+
if event_type == "conversation.turn":
|
|
1278
|
+
contact = str(data.get("contact_id") or "").strip()
|
|
1279
|
+
channel = str(data.get("channel_id") or "").strip()
|
|
1280
|
+
if contact and _SAFE_ID.fullmatch(contact):
|
|
1281
|
+
observations.append(SituationObservationV1.create(
|
|
1282
|
+
observation_id=f"so-{_digest((event_id, 'person', contact))[:24]}",
|
|
1283
|
+
category="person", entity_id=contact, state="recently_active",
|
|
1284
|
+
active=True, observed_at=occurred,
|
|
1285
|
+
ttl_seconds=_DEFAULT_TTLS["person"], evidence_refs=evidence,
|
|
1286
|
+
source_kind=source_kind, subject_person_id=subject,
|
|
1287
|
+
viewer_scope=viewer, shareability=sharing,
|
|
1288
|
+
attributes={"channel_id": channel} if channel else {},
|
|
1289
|
+
))
|
|
1290
|
+
if channel and _SAFE_ID.fullmatch(channel):
|
|
1291
|
+
observations.append(SituationObservationV1.create(
|
|
1292
|
+
observation_id=f"so-{_digest((event_id, 'channel', channel))[:24]}",
|
|
1293
|
+
category="channel", entity_id=channel, state="recently_active",
|
|
1294
|
+
active=True, observed_at=occurred,
|
|
1295
|
+
ttl_seconds=_DEFAULT_TTLS["channel"], evidence_refs=evidence,
|
|
1296
|
+
source_kind=source_kind, subject_person_id=subject,
|
|
1297
|
+
viewer_scope=viewer, shareability=sharing,
|
|
1298
|
+
))
|
|
1299
|
+
return tuple(observations), "projected", raw_digest
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
class SituationReducer:
|
|
1303
|
+
"""Idempotently consume the durable host journal into ``SituationStore``."""
|
|
1304
|
+
|
|
1305
|
+
def __init__(
|
|
1306
|
+
self,
|
|
1307
|
+
store: SituationStore,
|
|
1308
|
+
*,
|
|
1309
|
+
consumer_id: str = "situation-spine-v1",
|
|
1310
|
+
replay_fn: Callable[..., Mapping[str, Any]] = replay_events,
|
|
1311
|
+
current_sequence_fn: Callable[[], int] = current_sequence,
|
|
1312
|
+
adapter: Any = JournalSituationAdapter,
|
|
1313
|
+
) -> None:
|
|
1314
|
+
self.store = store
|
|
1315
|
+
self.consumer_id = consumer_id
|
|
1316
|
+
self._replay = replay_fn
|
|
1317
|
+
self._current_sequence = current_sequence_fn
|
|
1318
|
+
self._adapter = adapter
|
|
1319
|
+
|
|
1320
|
+
@property
|
|
1321
|
+
def mode(self) -> str:
|
|
1322
|
+
return situation_spine_mode()
|
|
1323
|
+
|
|
1324
|
+
def _bootstrap_mode(self) -> str:
|
|
1325
|
+
value = os.environ.get(
|
|
1326
|
+
"COLONY_SITUATION_BOOTSTRAP", "tail",
|
|
1327
|
+
).strip().lower()
|
|
1328
|
+
return value if value in {"tail", "replay"} else "tail"
|
|
1329
|
+
|
|
1330
|
+
@staticmethod
|
|
1331
|
+
def _replay_integrity_error(snapshot: Mapping[str, Any]) -> str:
|
|
1332
|
+
if snapshot.get("replayError"):
|
|
1333
|
+
return "journal_unavailable"
|
|
1334
|
+
try:
|
|
1335
|
+
corrupt = int(snapshot.get("corruptCount") or 0)
|
|
1336
|
+
first = int(snapshot.get("firstAvailableSeq") or 0)
|
|
1337
|
+
high = int(snapshot.get("journalLastSeq") or 0)
|
|
1338
|
+
except (TypeError, ValueError):
|
|
1339
|
+
return "journal_metadata_invalid"
|
|
1340
|
+
if corrupt < 0 or first < 0 or high < 0 or (first and first > high):
|
|
1341
|
+
return "journal_metadata_invalid"
|
|
1342
|
+
if corrupt:
|
|
1343
|
+
return f"journal_corruption_detected:{corrupt}"
|
|
1344
|
+
if not isinstance(snapshot.get("events") or (), (list, tuple)):
|
|
1345
|
+
return "journal_metadata_invalid"
|
|
1346
|
+
return ""
|
|
1347
|
+
|
|
1348
|
+
@staticmethod
|
|
1349
|
+
def _gap_policy() -> str:
|
|
1350
|
+
return os.environ.get(
|
|
1351
|
+
"COLONY_SITUATION_GAP_POLICY", "stop",
|
|
1352
|
+
).strip().lower()
|
|
1353
|
+
|
|
1354
|
+
def _stop(self, error: str, *, processed: int = 0,
|
|
1355
|
+
cursor: Optional[int] = None) -> Dict[str, Any]:
|
|
1356
|
+
self.store.set_cursor_error(self.consumer_id, error)
|
|
1357
|
+
result: Dict[str, Any] = {
|
|
1358
|
+
"enabled": True, "mode": self.mode,
|
|
1359
|
+
"processed": int(processed), "error": error,
|
|
1360
|
+
}
|
|
1361
|
+
if cursor is not None:
|
|
1362
|
+
result["cursor"] = int(cursor)
|
|
1363
|
+
return result
|
|
1364
|
+
|
|
1365
|
+
def run_once(self, *, limit: int = 100) -> Dict[str, Any]:
|
|
1366
|
+
if self.mode == "off":
|
|
1367
|
+
return {"enabled": False, "processed": 0, "mode": "off"}
|
|
1368
|
+
cursor = self.store.cursor(self.consumer_id)
|
|
1369
|
+
if cursor is None:
|
|
1370
|
+
head = self._replay(after_seq=0, limit=1)
|
|
1371
|
+
bootstrap = self._bootstrap_mode()
|
|
1372
|
+
integrity_error = self._replay_integrity_error(head)
|
|
1373
|
+
if integrity_error:
|
|
1374
|
+
initial = 0
|
|
1375
|
+
else:
|
|
1376
|
+
first = int(head.get("firstAvailableSeq") or 0)
|
|
1377
|
+
high = int(head.get("journalLastSeq") or 0)
|
|
1378
|
+
initial = high if bootstrap == "tail" else max(0, first - 1)
|
|
1379
|
+
cursor = self.store.initialize_cursor(
|
|
1380
|
+
self.consumer_id, initial, bootstrap_mode=bootstrap,
|
|
1381
|
+
)
|
|
1382
|
+
if integrity_error:
|
|
1383
|
+
return self._stop(integrity_error, cursor=cursor)
|
|
1384
|
+
if bootstrap == "tail":
|
|
1385
|
+
return {
|
|
1386
|
+
"enabled": True, "mode": self.mode, "processed": 0,
|
|
1387
|
+
"bootstrapped": True, "bootstrap_mode": bootstrap,
|
|
1388
|
+
"cursor": cursor,
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
batch = self._replay(
|
|
1392
|
+
after_seq=cursor, limit=max(1, min(500, int(limit))),
|
|
1393
|
+
)
|
|
1394
|
+
integrity_error = self._replay_integrity_error(batch)
|
|
1395
|
+
if integrity_error:
|
|
1396
|
+
return self._stop(integrity_error, cursor=cursor)
|
|
1397
|
+
journal_high = int(batch.get("journalLastSeq") or 0)
|
|
1398
|
+
if cursor > journal_high:
|
|
1399
|
+
return self._stop(
|
|
1400
|
+
f"event_journal_rewind:{cursor}:{journal_high}",
|
|
1401
|
+
cursor=cursor,
|
|
1402
|
+
)
|
|
1403
|
+
first = int(batch.get("firstAvailableSeq") or 0)
|
|
1404
|
+
if first and cursor < first - 1:
|
|
1405
|
+
error = f"journal_retention_gap:{cursor}:{first}"
|
|
1406
|
+
if self._gap_policy() != "acknowledge":
|
|
1407
|
+
return self._stop(error, cursor=cursor)
|
|
1408
|
+
cursor = self.store.acknowledge_gap(
|
|
1409
|
+
self.consumer_id,
|
|
1410
|
+
prior_cursor=cursor,
|
|
1411
|
+
resume_after=first - 1,
|
|
1412
|
+
reason=error,
|
|
1413
|
+
)
|
|
1414
|
+
batch = self._replay(
|
|
1415
|
+
after_seq=cursor, limit=max(1, min(500, int(limit))),
|
|
1416
|
+
)
|
|
1417
|
+
integrity_error = self._replay_integrity_error(batch)
|
|
1418
|
+
if integrity_error:
|
|
1419
|
+
return self._stop(integrity_error, cursor=cursor)
|
|
1420
|
+
journal_high = int(batch.get("journalLastSeq") or 0)
|
|
1421
|
+
if cursor > journal_high:
|
|
1422
|
+
return self._stop(
|
|
1423
|
+
f"event_journal_rewind:{cursor}:{journal_high}",
|
|
1424
|
+
cursor=cursor,
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
counts: Dict[str, int] = {}
|
|
1428
|
+
last_seq = cursor
|
|
1429
|
+
for event in batch.get("events") or ():
|
|
1430
|
+
try:
|
|
1431
|
+
if not isinstance(event, Mapping):
|
|
1432
|
+
raise ValueError("journal event is not an object")
|
|
1433
|
+
sequence = int(event.get("seq") or 0)
|
|
1434
|
+
if sequence <= last_seq:
|
|
1435
|
+
raise ValueError("journal event sequence is not increasing")
|
|
1436
|
+
if sequence != last_seq + 1:
|
|
1437
|
+
error = f"journal_sequence_gap:{last_seq}:{sequence}"
|
|
1438
|
+
if self._gap_policy() != "acknowledge":
|
|
1439
|
+
return self._stop(
|
|
1440
|
+
error, processed=sum(counts.values()),
|
|
1441
|
+
cursor=last_seq,
|
|
1442
|
+
)
|
|
1443
|
+
last_seq = self.store.acknowledge_gap(
|
|
1444
|
+
self.consumer_id,
|
|
1445
|
+
prior_cursor=last_seq,
|
|
1446
|
+
resume_after=sequence - 1,
|
|
1447
|
+
reason=error,
|
|
1448
|
+
)
|
|
1449
|
+
observations, disposition, digest = self._adapter.adapt(event)
|
|
1450
|
+
result = self.store.reduce_event(
|
|
1451
|
+
consumer_id=self.consumer_id,
|
|
1452
|
+
event_seq=int(event["seq"]),
|
|
1453
|
+
event_id=str(event.get("ulid") or f"journal-seq-{event['seq']}"),
|
|
1454
|
+
event_digest=digest,
|
|
1455
|
+
observations=observations,
|
|
1456
|
+
disposition=disposition,
|
|
1457
|
+
)
|
|
1458
|
+
key = str(result["disposition"])
|
|
1459
|
+
except Exception as exc:
|
|
1460
|
+
self.store.set_cursor_error(
|
|
1461
|
+
self.consumer_id, f"malformed_event:{type(exc).__name__}",
|
|
1462
|
+
)
|
|
1463
|
+
return {
|
|
1464
|
+
"enabled": True,
|
|
1465
|
+
"mode": self.mode,
|
|
1466
|
+
"processed": sum(counts.values()),
|
|
1467
|
+
"error": f"malformed_event:{type(exc).__name__}",
|
|
1468
|
+
}
|
|
1469
|
+
counts[key] = counts.get(key, 0) + 1
|
|
1470
|
+
last_seq = int(result.get("cursor") or last_seq)
|
|
1471
|
+
journal_high = int(batch.get("journalLastSeq") or 0)
|
|
1472
|
+
if not bool(batch.get("hasMore")) and journal_high > last_seq:
|
|
1473
|
+
error = f"journal_sequence_gap:{last_seq}:{journal_high + 1}"
|
|
1474
|
+
if self._gap_policy() != "acknowledge":
|
|
1475
|
+
return self._stop(
|
|
1476
|
+
error, processed=sum(counts.values()), cursor=last_seq,
|
|
1477
|
+
)
|
|
1478
|
+
last_seq = self.store.acknowledge_gap(
|
|
1479
|
+
self.consumer_id,
|
|
1480
|
+
prior_cursor=last_seq,
|
|
1481
|
+
resume_after=journal_high,
|
|
1482
|
+
reason=error,
|
|
1483
|
+
)
|
|
1484
|
+
return {
|
|
1485
|
+
"enabled": True,
|
|
1486
|
+
"mode": self.mode,
|
|
1487
|
+
"processed": sum(counts.values()),
|
|
1488
|
+
"dispositions": counts,
|
|
1489
|
+
"cursor": last_seq,
|
|
1490
|
+
"has_more": bool(batch.get("hasMore")),
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
def status(self) -> Dict[str, Any]:
|
|
1494
|
+
result = self.store.status(self.consumer_id)
|
|
1495
|
+
result.update({
|
|
1496
|
+
"mode": self.mode,
|
|
1497
|
+
"enabled": self.mode != "off",
|
|
1498
|
+
"healthy": not bool(result.get("last_error")),
|
|
1499
|
+
"journal_high_water": int(self._current_sequence()),
|
|
1500
|
+
})
|
|
1501
|
+
return result
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
@dataclass(frozen=True)
|
|
1505
|
+
class SituationPolicyVerdictV1:
|
|
1506
|
+
verdict_id: str
|
|
1507
|
+
action: str
|
|
1508
|
+
allowed: bool
|
|
1509
|
+
reason: str
|
|
1510
|
+
evidence_refs: tuple[str, ...]
|
|
1511
|
+
snapshot_id: str
|
|
1512
|
+
operation: str
|
|
1513
|
+
does_not_grant_authority: bool = True
|
|
1514
|
+
schema: str = "SituationPolicyVerdictV1"
|
|
1515
|
+
version: int = VERDICT_VERSION
|
|
1516
|
+
|
|
1517
|
+
def __post_init__(self) -> None:
|
|
1518
|
+
_safe_id(self.verdict_id, "verdict_id")
|
|
1519
|
+
_safe_id(self.snapshot_id, "snapshot_id")
|
|
1520
|
+
if self.action not in {"allow", "ask", "hold"}:
|
|
1521
|
+
raise ValueError("invalid situation policy action")
|
|
1522
|
+
if self.allowed is not (self.action == "allow"):
|
|
1523
|
+
raise ValueError("situation policy allowed/action mismatch")
|
|
1524
|
+
if self.does_not_grant_authority is not True:
|
|
1525
|
+
raise ValueError("situation verdict cannot grant authority")
|
|
1526
|
+
if _evidence_refs(self.evidence_refs) != self.evidence_refs:
|
|
1527
|
+
raise ValueError("situation verdict evidence is not canonical")
|
|
1528
|
+
if self.schema != "SituationPolicyVerdictV1" or self.version != 1:
|
|
1529
|
+
raise ValueError("invalid situation policy schema")
|
|
1530
|
+
|
|
1531
|
+
def as_policy_result(self) -> Dict[str, Any]:
|
|
1532
|
+
"""Shape consumed by P3's deterministic policy normalizer."""
|
|
1533
|
+
|
|
1534
|
+
return {
|
|
1535
|
+
"allowed": self.allowed,
|
|
1536
|
+
"reason": self.reason,
|
|
1537
|
+
"evidence_refs": list(self.evidence_refs),
|
|
1538
|
+
"situation_verdict_ref": f"situation-verdict:{self.verdict_id}",
|
|
1539
|
+
"does_not_grant_authority": True,
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
class AppropriatenessGate:
|
|
1544
|
+
"""Pure situational policy. It can hold/ask; it cannot authorize work."""
|
|
1545
|
+
|
|
1546
|
+
_DEGRADED = frozenset({
|
|
1547
|
+
"degraded", "down", "offline", "unavailable", "unhealthy", "failed",
|
|
1548
|
+
})
|
|
1549
|
+
_BUSY = frozenset({
|
|
1550
|
+
"busy", "do_not_disturb", "sleeping", "driving", "in_call",
|
|
1551
|
+
})
|
|
1552
|
+
|
|
1553
|
+
def __init__(self, *, mode_fn: Callable[[], str] = situation_spine_mode) -> None:
|
|
1554
|
+
self._mode_fn = mode_fn
|
|
1555
|
+
|
|
1556
|
+
@staticmethod
|
|
1557
|
+
def _verdict(
|
|
1558
|
+
snapshot: SituationSnapshotV1,
|
|
1559
|
+
operation: str,
|
|
1560
|
+
action: str,
|
|
1561
|
+
reason: str,
|
|
1562
|
+
refs: Iterable[str],
|
|
1563
|
+
) -> SituationPolicyVerdictV1:
|
|
1564
|
+
evidence = tuple(dict.fromkeys((
|
|
1565
|
+
f"situation-snapshot:{snapshot.snapshot_id}", *refs,
|
|
1566
|
+
)))[:40]
|
|
1567
|
+
authority = {
|
|
1568
|
+
"snapshot_id": snapshot.snapshot_id,
|
|
1569
|
+
"operation": operation,
|
|
1570
|
+
"action": action,
|
|
1571
|
+
"reason": reason,
|
|
1572
|
+
"evidence_refs": evidence,
|
|
1573
|
+
"does_not_grant_authority": True,
|
|
1574
|
+
}
|
|
1575
|
+
return SituationPolicyVerdictV1(
|
|
1576
|
+
verdict_id=_digest(authority)[:24],
|
|
1577
|
+
action=action,
|
|
1578
|
+
allowed=action == "allow",
|
|
1579
|
+
reason=reason,
|
|
1580
|
+
evidence_refs=evidence,
|
|
1581
|
+
snapshot_id=snapshot.snapshot_id,
|
|
1582
|
+
operation=operation,
|
|
1583
|
+
)
|
|
1584
|
+
|
|
1585
|
+
def evaluate(
|
|
1586
|
+
self,
|
|
1587
|
+
snapshot: SituationSnapshotV1,
|
|
1588
|
+
*,
|
|
1589
|
+
operation: str,
|
|
1590
|
+
required_categories: Iterable[str] = (),
|
|
1591
|
+
target_channel: str = "",
|
|
1592
|
+
recipient_person_id: str = "",
|
|
1593
|
+
) -> SituationPolicyVerdictV1:
|
|
1594
|
+
op = str(operation or "").strip().lower()
|
|
1595
|
+
if not _SAFE_STATE.fullmatch(op):
|
|
1596
|
+
raise ValueError("operation is invalid")
|
|
1597
|
+
mode = self._mode_fn()
|
|
1598
|
+
if mode != "live":
|
|
1599
|
+
reason = "situation_spine_off" if mode == "off" else "situation_shadow_observation_only"
|
|
1600
|
+
return self._verdict(snapshot, op, "hold", reason, ())
|
|
1601
|
+
|
|
1602
|
+
required = tuple(dict.fromkeys(str(item).strip() for item in required_categories))
|
|
1603
|
+
if any(item not in _CATEGORY_SET for item in required):
|
|
1604
|
+
raise ValueError("required category is unsupported")
|
|
1605
|
+
for category in required:
|
|
1606
|
+
freshness = snapshot.freshness(category)
|
|
1607
|
+
if freshness == "unknown":
|
|
1608
|
+
return self._verdict(
|
|
1609
|
+
snapshot, op, "ask", f"situation_unknown:{category}", (),
|
|
1610
|
+
)
|
|
1611
|
+
if freshness == "stale":
|
|
1612
|
+
stale = snapshot.active_facts(category)
|
|
1613
|
+
return self._verdict(
|
|
1614
|
+
snapshot, op, "hold", f"situation_stale:{category}",
|
|
1615
|
+
(ref for fact in stale for ref in fact.evidence_refs),
|
|
1616
|
+
)
|
|
1617
|
+
|
|
1618
|
+
if "service" in required:
|
|
1619
|
+
service_facts = snapshot.fresh_active_facts("service")
|
|
1620
|
+
degraded = [
|
|
1621
|
+
fact for fact in service_facts if fact.state in self._DEGRADED
|
|
1622
|
+
]
|
|
1623
|
+
if degraded:
|
|
1624
|
+
return self._verdict(
|
|
1625
|
+
snapshot, op, "hold", "required_service_degraded",
|
|
1626
|
+
(ref for fact in degraded for ref in fact.evidence_refs),
|
|
1627
|
+
)
|
|
1628
|
+
|
|
1629
|
+
for category in ("resource", "capability"):
|
|
1630
|
+
if category not in required:
|
|
1631
|
+
continue
|
|
1632
|
+
unavailable = [
|
|
1633
|
+
fact for fact in snapshot.fresh_active_facts(category)
|
|
1634
|
+
if fact.state in self._DEGRADED
|
|
1635
|
+
or dict(fact.attributes).get("capacity_available") is False
|
|
1636
|
+
]
|
|
1637
|
+
if unavailable:
|
|
1638
|
+
return self._verdict(
|
|
1639
|
+
snapshot, op, "hold", f"required_{category}_unavailable",
|
|
1640
|
+
(ref for fact in unavailable for ref in fact.evidence_refs),
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
if "approval" in required:
|
|
1644
|
+
pending_approvals = [
|
|
1645
|
+
fact for fact in snapshot.fresh_active_facts("approval")
|
|
1646
|
+
if fact.state in {
|
|
1647
|
+
"pending", "required", "awaiting_approval", "requested",
|
|
1648
|
+
}
|
|
1649
|
+
]
|
|
1650
|
+
if pending_approvals:
|
|
1651
|
+
return self._verdict(
|
|
1652
|
+
snapshot, op, "ask", "pending_approval_requires_decision",
|
|
1653
|
+
(
|
|
1654
|
+
ref for fact in pending_approvals
|
|
1655
|
+
for ref in fact.evidence_refs
|
|
1656
|
+
),
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
if target_channel:
|
|
1660
|
+
channel = [
|
|
1661
|
+
fact for fact in snapshot.active_facts("channel")
|
|
1662
|
+
if fact.entity_id == target_channel
|
|
1663
|
+
]
|
|
1664
|
+
if not channel:
|
|
1665
|
+
return self._verdict(
|
|
1666
|
+
snapshot, op, "ask", "target_channel_state_unknown", (),
|
|
1667
|
+
)
|
|
1668
|
+
if channel[0].freshness != "fresh" or channel[0].state in self._DEGRADED:
|
|
1669
|
+
return self._verdict(
|
|
1670
|
+
snapshot, op, "hold", "target_channel_unavailable",
|
|
1671
|
+
channel[0].evidence_refs,
|
|
1672
|
+
)
|
|
1673
|
+
|
|
1674
|
+
if op in {"outreach", "proactive_delivery", "interrupt"}:
|
|
1675
|
+
engagement = snapshot.fresh_active_facts("owner_engagement")
|
|
1676
|
+
if not engagement or snapshot.freshness("owner_engagement") != "fresh":
|
|
1677
|
+
return self._verdict(
|
|
1678
|
+
snapshot, op, "ask", "owner_availability_unknown", (),
|
|
1679
|
+
)
|
|
1680
|
+
busy = [fact for fact in engagement if fact.state in self._BUSY]
|
|
1681
|
+
if busy:
|
|
1682
|
+
return self._verdict(
|
|
1683
|
+
snapshot, op, "hold", "owner_interruption_cost_high",
|
|
1684
|
+
(ref for fact in busy for ref in fact.evidence_refs),
|
|
1685
|
+
)
|
|
1686
|
+
active_conversations = snapshot.fresh_active_facts("conversation")
|
|
1687
|
+
if active_conversations:
|
|
1688
|
+
return self._verdict(
|
|
1689
|
+
snapshot, op, "hold", "active_conversation_in_progress",
|
|
1690
|
+
(ref for fact in active_conversations for ref in fact.evidence_refs),
|
|
1691
|
+
)
|
|
1692
|
+
if recipient_person_id:
|
|
1693
|
+
others = [
|
|
1694
|
+
fact for fact in snapshot.fresh_active_facts("person")
|
|
1695
|
+
if fact.entity_id != recipient_person_id
|
|
1696
|
+
]
|
|
1697
|
+
if others:
|
|
1698
|
+
return self._verdict(
|
|
1699
|
+
snapshot, op, "hold", "recipient_privacy_context_uncertain",
|
|
1700
|
+
(ref for fact in others for ref in fact.evidence_refs),
|
|
1701
|
+
)
|
|
1702
|
+
|
|
1703
|
+
return self._verdict(
|
|
1704
|
+
snapshot, op, "allow", "fresh_situation_allows", snapshot.evidence_refs,
|
|
1705
|
+
)
|
|
1706
|
+
|
|
1707
|
+
def for_goal_proposal(
|
|
1708
|
+
self,
|
|
1709
|
+
proposal: Any,
|
|
1710
|
+
concern: Any,
|
|
1711
|
+
snapshot: SituationSnapshotV1,
|
|
1712
|
+
*,
|
|
1713
|
+
required_categories: Iterable[str] = ("resource", "service"),
|
|
1714
|
+
) -> Dict[str, Any]:
|
|
1715
|
+
"""P3 adapter; scope equality is checked before situational policy."""
|
|
1716
|
+
|
|
1717
|
+
if (
|
|
1718
|
+
str(getattr(proposal, "subject_person_id", ""))
|
|
1719
|
+
!= snapshot.subject_person_id
|
|
1720
|
+
or str(getattr(proposal, "viewer_scope", "")) != snapshot.viewer_scope
|
|
1721
|
+
or str(getattr(concern, "subject_person_id", ""))
|
|
1722
|
+
!= snapshot.subject_person_id
|
|
1723
|
+
):
|
|
1724
|
+
return self._verdict(
|
|
1725
|
+
snapshot, "project_start", "hold", "situation_scope_mismatch", (),
|
|
1726
|
+
).as_policy_result()
|
|
1727
|
+
return self.evaluate(
|
|
1728
|
+
snapshot,
|
|
1729
|
+
operation="project_start",
|
|
1730
|
+
required_categories=required_categories,
|
|
1731
|
+
).as_policy_result()
|