hecks 1.3.0 → 1.5.0
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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/claude_code.rb +72 -7
- data/lib/hecks/adapters/driven/d1.rb +187 -23
- data/lib/hecks/adapters/driven/folder.rb +83 -10
- data/lib/hecks/adapters/driven/google_authentication.rb +33 -12
- data/lib/hecks/adapters/driven/governance_authorization.rb +87 -18
- data/lib/hecks/adapters/driven/heki/journal.rb +13 -4
- data/lib/hecks/adapters/driven/heki/saga_store.rb +56 -10
- data/lib/hecks/adapters/driven/heki/snapshot.rb +1 -1
- data/lib/hecks/adapters/driven/heki.rb +106 -9
- data/lib/hecks/adapters/driven/identity_registry.rb +12 -2
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +25 -3
- data/lib/hecks/adapters/driven/in_process_key_vault.adapter +3 -0
- data/lib/hecks/adapters/driven/in_process_key_vault.rb +53 -0
- data/lib/hecks/adapters/driven/lambda/client.rb +67 -14
- data/lib/hecks/adapters/driven/lambda.rb +82 -35
- data/lib/hecks/adapters/driven/local_storage.rb +83 -10
- data/lib/hecks/adapters/driven/memory.rb +205 -9
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +21 -1
- data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
- data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
- data/lib/hecks/adapters/driven/postgres.rb +175 -28
- data/lib/hecks/adapters/driven/prism.rb +50 -11
- data/lib/hecks/adapters/driven/secure_random_identity.rb +3 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
- data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
- data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
- data/lib/hecks/adapters/driven/sqlite.rb +181 -21
- data/lib/hecks/adapters/driven/system_clock.rb +3 -0
- data/lib/hecks/adapters/driven/tenant_provisioner.adapter +3 -0
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +66 -0
- data/lib/hecks/adapters/driven.rb +6 -4
- data/lib/hecks/adapters/driving/github_webhook.rb +31 -18
- data/lib/hecks/behaviors/dsl.rb +60 -2
- data/lib/hecks/behaviors/expectations.rb +190 -29
- data/lib/hecks/behaviors/ir.rb +12 -1
- data/lib/hecks/behaviors/rspec.rb +9 -1
- data/lib/hecks/behaviors/runner.rb +21 -2
- data/lib/hecks/behaviors.rb +9 -1
- data/lib/hecks/bluebook/aggregate.rb +43 -13
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +17 -10
- data/lib/hecks/bluebook/assembly/build.rb +26 -5
- data/lib/hecks/bluebook/assembly/contract.rb +98 -23
- data/lib/hecks/bluebook/assembly/contracts.rb +59 -52
- data/lib/hecks/bluebook/assembly/marks.rb +159 -30
- data/lib/hecks/bluebook/assembly/specializer.rb +38 -21
- data/lib/hecks/bluebook/assembly.rb +32 -14
- data/lib/hecks/bluebook/attribute.rb +26 -12
- data/lib/hecks/bluebook/behaviour/aggregate.rb +43 -11
- data/lib/hecks/bluebook/behaviour/attribute.rb +18 -5
- data/lib/hecks/bluebook/behaviour/chapter.rb +76 -5
- data/lib/hecks/bluebook/behaviour/command.rb +55 -25
- data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -7
- data/lib/hecks/bluebook/behaviour/entity.rb +20 -8
- data/lib/hecks/bluebook/behaviour/hexagon.rb +30 -4
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +27 -6
- data/lib/hecks/bluebook/behaviour/policy.rb +42 -17
- data/lib/hecks/bluebook/behaviour/process_manager.rb +39 -8
- data/lib/hecks/bluebook/behaviour/query.rb +6 -1
- data/lib/hecks/bluebook/behaviour/read_model.rb +29 -8
- data/lib/hecks/bluebook/behaviour/traits.rb +48 -12
- data/lib/hecks/bluebook/behaviour/value_object.rb +21 -9
- data/lib/hecks/bluebook/capabilities.rb +27 -0
- data/lib/hecks/bluebook/chapter.rb +51 -9
- data/lib/hecks/bluebook/command.rb +62 -17
- data/lib/hecks/bluebook/domain_port.rb +34 -9
- data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
- data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +282 -123
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +112 -75
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +81 -2
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +486 -117
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +179 -47
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +284 -122
- data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
- data/lib/hecks/bluebook/dsl/entity_builder.rb +191 -61
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +130 -30
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +38 -21
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
- data/lib/hecks/bluebook/dsl/policy_builder.rb +86 -36
- data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +111 -47
- data/lib/hecks/bluebook/dsl/query_builder.rb +37 -8
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +127 -52
- data/lib/hecks/bluebook/dsl/rule_reference.rb +97 -43
- data/lib/hecks/bluebook/dsl/translation_builder.rb +150 -44
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +68 -20
- data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
- data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
- data/lib/hecks/bluebook/entity.rb +40 -11
- data/lib/hecks/bluebook/expression/ast_json.rb +128 -36
- data/lib/hecks/bluebook/expression/ast_reader.rb +32 -3
- data/lib/hecks/bluebook/expression/canonical_form.rb +55 -16
- data/lib/hecks/bluebook/expression/evaluator.rb +221 -43
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +54 -18
- data/lib/hecks/bluebook/expression/resolver.rb +369 -128
- data/lib/hecks/bluebook/hexagon.rb +35 -1
- data/lib/hecks/bluebook/lifecycle.rb +12 -1
- data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +2 -1
- data/lib/hecks/bluebook/meta_validator/judge.rb +126 -108
- data/lib/hecks/bluebook/meta_validator/plan.rb +81 -46
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +3 -2
- data/lib/hecks/bluebook/meta_validator/readings.rb +200 -50
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +68 -41
- data/lib/hecks/bluebook/meta_validator/shapes.rb +166 -21
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +286 -46
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +11 -10
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -5
- data/lib/hecks/bluebook/meta_validator.rb +235 -139
- data/lib/hecks/bluebook/model_check.rb +434 -104
- data/lib/hecks/bluebook/pattern_subset.rb +32 -10
- data/lib/hecks/bluebook/policy.rb +15 -13
- data/lib/hecks/bluebook/process_manager.rb +27 -14
- data/lib/hecks/bluebook/project_discovery.rb +5 -0
- data/lib/hecks/bluebook/project_loader.rb +40 -0
- data/lib/hecks/bluebook/project_register.rb +50 -6
- data/lib/hecks/bluebook/query.rb +31 -4
- data/lib/hecks/bluebook/read_model.rb +35 -15
- data/lib/hecks/bluebook/reference.rb +26 -13
- data/lib/hecks/bluebook/smoke_test.rb +46 -23
- data/lib/hecks/bluebook/synthesizer.rb +46 -12
- data/lib/hecks/bluebook/translation.rb +34 -5
- data/lib/hecks/bluebook/value_object.rb +29 -11
- data/lib/hecks/bluebook.rb +5 -6
- data/lib/hecks/codemod.rb +138 -50
- data/lib/hecks/construct.rb +21 -7
- data/lib/hecks/corpus.rb +438 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +19 -0
- data/lib/hecks/doc/reference.rb +200 -31
- data/lib/hecks/embryonaut_bluebook.rb +38 -15
- data/lib/hecks/facade/cli_door.rb +69 -10
- data/lib/hecks/facade/cli_runner.rb +105 -24
- data/lib/hecks/facade/command_request.rb +23 -0
- data/lib/hecks/facade/handle.rb +155 -35
- data/lib/hecks/facade/json_door.rb +106 -25
- data/lib/hecks/facade/surface/aggregate_door.rb +50 -27
- data/lib/hecks/facade/surface/chapter.rb +26 -17
- data/lib/hecks/facade/surface.rb +16 -3
- data/lib/hecks/facade.rb +15 -4
- data/lib/hecks/forms/app.rb +46 -30
- data/lib/hecks/forms/command_form_renderer.rb +70 -9
- data/lib/hecks/forms/field_renderer.rb +142 -6
- data/lib/hecks/forms/field_shape.rb +183 -19
- data/lib/hecks/forms/html.rb +51 -7
- data/lib/hecks/forms/index_renderer.rb +14 -2
- data/lib/hecks/forms/page.rb +14 -0
- data/lib/hecks/forms/params.rb +120 -23
- data/lib/hecks/forms/port_argument.rb +14 -2
- data/lib/hecks/forms/query_form_renderer.rb +65 -2
- data/lib/hecks/forms/record_renderer.rb +60 -2
- data/lib/hecks/forms/record_table.rb +28 -1
- data/lib/hecks/forms/reference_options.rb +24 -0
- data/lib/hecks/forms/value_object_shape.rb +13 -3
- data/lib/hecks/forms.rb +24 -4
- data/lib/hecks/fqn.rb +59 -1
- data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
- data/lib/hecks/framework/bluebook/privacy.bluebook +155 -0
- data/lib/hecks/framework/oidc.json +15 -0
- data/lib/hecks/framework.rb +79 -25
- data/lib/hecks/freezer.rb +27 -11
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +241 -106
- data/lib/hecks/fuzzing/combination_miner.rb +178 -0
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +241 -45
- data/lib/hecks/fuzzing/coverage_campaign.rb +161 -0
- data/lib/hecks/fuzzing/differential.rb +192 -0
- data/lib/hecks/fuzzing/domain_generator.rb +871 -0
- data/lib/hecks/fuzzing/era_boundary.rb +68 -19
- data/lib/hecks/fuzzing/form_census.rb +121 -20
- data/lib/hecks/fuzzing/generated_domain_check.rb +171 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +45 -6
- data/lib/hecks/fuzzing/isolated_boot.rb +137 -80
- data/lib/hecks/fuzzing/nondeterministic.rb +79 -0
- data/lib/hecks/fuzzing/persistence_parity.rb +111 -21
- data/lib/hecks/fuzzing/properties/corrections.rb +34 -9
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +300 -51
- data/lib/hecks/fuzzing/properties/guards.rb +86 -42
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +80 -32
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +40 -35
- data/lib/hecks/fuzzing/properties/outbox.rb +70 -32
- data/lib/hecks/fuzzing/properties/querying.rb +82 -28
- data/lib/hecks/fuzzing/properties.rb +84 -51
- data/lib/hecks/fuzzing/qa_settings.rb +164 -0
- data/lib/hecks/fuzzing/replay.rb +241 -133
- data/lib/hecks/fuzzing/rotation_priority.rb +48 -28
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +139 -0
- data/lib/hecks/fuzzing/self_consistency.rb +315 -137
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +46 -40
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +18 -11
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +13 -12
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +21 -12
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +59 -32
- data/lib/hecks/fuzzing/sequence_generator.rb +158 -23
- data/lib/hecks/fuzzing/shrinker.rb +309 -0
- data/lib/hecks/fuzzing/structural_skips.rb +37 -130
- data/lib/hecks/fuzzing/sweep_depth.rb +12 -4
- data/lib/hecks/fuzzing/target_capabilities.rb +107 -29
- data/lib/hecks/fuzzing/value_generator.rb +110 -22
- data/lib/hecks/fuzzing.rb +5 -0
- data/lib/hecks/grammar/evolve.rb +188 -12
- data/lib/hecks/grammar.rb +53 -7
- data/lib/hecks/ir.rb +51 -20
- data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
- data/lib/hecks/language/bluebook/policy.bluebook +11 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +348 -13
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +11 -0
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/literal.rb +41 -9
- data/lib/hecks/naming.rb +112 -31
- data/lib/hecks/ports/access_control.rb +53 -2
- data/lib/hecks/ports/agent/answers.rb +83 -6
- data/lib/hecks/ports/agent.rb +119 -35
- data/lib/hecks/ports/authentication.rb +44 -4
- data/lib/hecks/ports/authorization.rb +50 -11
- data/lib/hecks/ports/clock.rb +42 -23
- data/lib/hecks/ports/extraction.rb +16 -0
- data/lib/hecks/ports/identity_assignment.rb +23 -2
- data/lib/hecks/ports/identity_generation.rb +17 -3
- data/lib/hecks/ports/identity_resolution.rb +17 -1
- data/lib/hecks/ports/key_vault.port +6 -0
- data/lib/hecks/ports/key_vault.rb +58 -0
- data/lib/hecks/ports/loading.rb +4 -0
- data/lib/hecks/ports/persistence/append_only.rb +172 -8
- data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
- data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
- data/lib/hecks/ports/persistence/execution.rb +4 -0
- data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
- data/lib/hecks/ports/persistence/plugin.rb +42 -4
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
- data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +118 -66
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +34 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +33 -9
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +95 -6
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +314 -90
- data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +48 -8
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +58 -19
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +96 -5
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +15 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +9 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
- data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
- data/lib/hecks/ports/persistence/repository_factory.rb +23 -3
- data/lib/hecks/ports/persistence/state_codec.rb +319 -0
- data/lib/hecks/ports/persistence.rb +36 -1
- data/lib/hecks/ports/projection.rb +61 -7
- data/lib/hecks/ports/query/in_memory.rb +41 -8
- data/lib/hecks/ports/query/ordering.rb +21 -6
- data/lib/hecks/ports/query.rb +35 -0
- data/lib/hecks/ports.rb +1 -0
- data/lib/hecks/projections/bootstrap_table.rb +147 -0
- data/lib/hecks/projections/diagrams.rb +315 -79
- data/lib/hecks/projections/glossary/html.rb +91 -3
- data/lib/hecks/projections/glossary/markdown.rb +50 -3
- data/lib/hecks/projections/glossary/mermaid.rb +49 -1
- data/lib/hecks/projections/glossary/sections.rb +1 -1
- data/lib/hecks/projections/glossary/sentences.rb +110 -5
- data/lib/hecks/projections/glossary.rb +173 -31
- data/lib/hecks/projections/ir.rb +1 -1
- data/lib/hecks/projections/model/deviations.rb +62 -17
- data/lib/hecks/projections/model.rb +75 -21
- data/lib/hecks/projections/oidc.rb +45 -8
- data/lib/hecks/projections/parser_table.rb +53 -12
- data/lib/hecks/projections/reference.rb +15 -3
- data/lib/hecks/projections/rust_vocabulary.rb +646 -0
- data/lib/hecks/projections/shape.rb +10 -2
- data/lib/hecks/projections/statements.rb +69 -22
- data/lib/hecks/projections/vocabulary.rb +26 -9
- data/lib/hecks/projections.rb +5 -3
- data/lib/hecks/projector/cli_projector.rb +245 -37
- data/lib/hecks/projector/docs_projector.rb +154 -28
- data/lib/hecks/projector/exporter.rb +104 -29
- data/lib/hecks/projector/ir_projector.rb +7 -1
- data/lib/hecks/projector/narrate_projector.rb +144 -23
- data/lib/hecks/projector/target.rb +42 -18
- data/lib/hecks/projector.rb +86 -18
- data/lib/hecks/query_ir.rb +94 -47
- data/lib/hecks/query_specification/common/comparators.rb +19 -3
- data/lib/hecks/query_specification/common/comparison.rb +109 -27
- data/lib/hecks/query_specification/common/dsl.rb +65 -9
- data/lib/hecks/query_specification/common/null_policy.rb +61 -15
- data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
- data/lib/hecks/query_specification/common/options.rb +25 -0
- data/lib/hecks/query_specification/field_path.rb +69 -15
- data/lib/hecks/query_specification/hop_path.rb +57 -20
- data/lib/hecks/query_specification/read_model/specification.rb +4 -0
- data/lib/hecks/rendering.rb +9 -3
- data/lib/hecks/router/namespace_installer.rb +16 -3
- data/lib/hecks/router.rb +56 -1
- data/lib/hecks/runtime/aggregate_lock.rb +20 -11
- data/lib/hecks/runtime/boot_gates.rb +21 -3
- data/lib/hecks/runtime/caller.rb +40 -8
- data/lib/hecks/runtime/capability_graph.rb +13 -2
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +40 -41
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +45 -67
- data/lib/hecks/runtime/command_interpreter.rb +195 -113
- data/lib/hecks/runtime/command_rules/admissibility.rb +231 -80
- data/lib/hecks/runtime/command_rules/arithmetic.rb +154 -96
- data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
- data/lib/hecks/runtime/command_rules/emission.rb +18 -1
- data/lib/hecks/runtime/command_rules/references.rb +145 -55
- data/lib/hecks/runtime/command_rules.rb +3 -0
- data/lib/hecks/runtime/dependency_planning.rb +56 -11
- data/lib/hecks/runtime/dispatcher.rb +263 -92
- data/lib/hecks/runtime/entity_element.rb +304 -67
- data/lib/hecks/runtime/entity_interpreter.rb +149 -88
- data/lib/hecks/runtime/errors.rb +37 -23
- data/lib/hecks/runtime/event.rb +15 -10
- data/lib/hecks/runtime/identity.rb +91 -23
- data/lib/hecks/runtime/instance.rb +103 -18
- data/lib/hecks/runtime/interpreting.rb +24 -16
- data/lib/hecks/runtime/invocation.rb +358 -0
- data/lib/hecks/runtime/loader.rb +105 -19
- data/lib/hecks/runtime/outbox.rb +164 -26
- data/lib/hecks/runtime/policy_interpreter.rb +73 -60
- data/lib/hecks/runtime/port_operation_interpreter.rb +42 -19
- data/lib/hecks/runtime/query_interpreter.rb +93 -74
- data/lib/hecks/runtime/reaction_invocation.rb +73 -28
- data/lib/hecks/runtime/read_model_interpreter.rb +60 -44
- data/lib/hecks/runtime/rebuild_sweep.rb +32 -4
- data/lib/hecks/runtime/reference_hop.rb +48 -6
- data/lib/hecks/runtime/refusal_wording.rb +142 -115
- data/lib/hecks/runtime/registry/saga_persistence.rb +32 -21
- data/lib/hecks/runtime/registry/verification.rb +153 -28
- data/lib/hecks/runtime/registry.rb +202 -32
- data/lib/hecks/runtime/remote_dispatcher.rb +125 -24
- data/lib/hecks/runtime/routing.rb +36 -154
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +25 -27
- data/lib/hecks/runtime/saga_interpreter.rb +90 -76
- data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
- data/lib/hecks/runtime/tenant_check.rb +33 -13
- data/lib/hecks/runtime/tenant_scope.rb +23 -5
- data/lib/hecks/runtime/value/admission.rb +75 -30
- data/lib/hecks/runtime/value/coercion.rb +555 -142
- data/lib/hecks/runtime/value/entity_list_coercion.rb +132 -60
- data/lib/hecks/runtime/value.rb +71 -21
- data/lib/hecks/runtime.rb +39 -7
- data/lib/hecks/storehouse.rb +368 -72
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +130 -0
- data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +32 -0
- data/lib/hecks/version.rb +3 -3
- data/lib/hecks/vocabulary.rb +205 -4
- data/lib/hecks.rb +91 -11
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +19 -11
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +24 -12
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +38 -14
- metadata +28 -2
data/lib/hecks/storehouse.rb
CHANGED
|
@@ -8,20 +8,22 @@ require_relative "projector"
|
|
|
8
8
|
require_relative "runtime/errors"
|
|
9
9
|
|
|
10
10
|
module Hecks
|
|
11
|
-
#
|
|
11
|
+
# **The bus, not a door** — `docs/hecks-survey-what-we-wish-we-had.md` and
|
|
12
12
|
# `docs/future-features.md` both name the sibling project's own
|
|
13
13
|
# "Storehouse" the single highest-priority gap this repo had: "no
|
|
14
|
-
# per-command tool... the bluebook
|
|
15
|
-
# projects it." This module
|
|
14
|
+
# per-command tool... the bluebook is the contract, the [door] just
|
|
15
|
+
# projects it." This module is that bus, borrowing its name too — the
|
|
16
16
|
# dispatch/query/state/... surface below is a pure function of a
|
|
17
17
|
# `Runtime::Dispatcher` plus plain Ruby arguments, no IO, no protocol
|
|
18
|
-
# awareness. `bin/hecks_mcp_door` is
|
|
18
|
+
# awareness. `bin/hecks_mcp_door` is one projection of it — MCP over
|
|
19
19
|
# stdio — not the whole thing; a plain CLI door, an HTTP door, a second
|
|
20
20
|
# transport of any shape, would sit beside it on the exact same bus,
|
|
21
21
|
# sharing the same audit log and caller-identity handling, without
|
|
22
|
-
# ever needing to speak MCP. (This
|
|
23
|
-
#
|
|
24
|
-
#
|
|
22
|
+
# ever needing to speak MCP. (This bus is deliberately not
|
|
23
|
+
# `Facade::McpDoor` — folding it into `Facade` under that name would
|
|
24
|
+
# conflate the bus with its one built transport.)
|
|
25
|
+
#
|
|
26
|
+
# ## The surface
|
|
25
27
|
#
|
|
26
28
|
# catalog — what aggregates a domain declares, and what each can do
|
|
27
29
|
# describe — one aggregate's full command/query/refusal contract
|
|
@@ -30,21 +32,23 @@ module Hecks
|
|
|
30
32
|
# dispatch — issue a command (dry_run: preview, steps: batch)
|
|
31
33
|
# query — ask a question
|
|
32
34
|
# state — read what is actually stored, no verb involved
|
|
33
|
-
# events — what
|
|
34
|
-
# current state; events
|
|
35
|
+
# events — what happened, with payloads, to one record — not just its
|
|
36
|
+
# current state; events this bus witnessed, sourced from
|
|
35
37
|
# its own audit log, not a full event-sourcing replay
|
|
36
38
|
# history — the full append-only journal, not just current state
|
|
37
39
|
# behaviors — run a domain's hand-curated `.behaviors` examples
|
|
38
40
|
# follow — tail this bus's own dispatch/query/state audit log
|
|
39
41
|
#
|
|
40
|
-
#
|
|
42
|
+
# ## Auditing and caller identity
|
|
43
|
+
#
|
|
44
|
+
# `dispatch`/`query`/`state` each take a `summary` — the survey's "every
|
|
41
45
|
# audit row carries human intent for free" — and `dispatch`/`query`
|
|
42
46
|
# additionally take an optional `source:` (`SOURCE_TAGS`, the survey's
|
|
43
|
-
# `SourceTag`: who is calling)
|
|
47
|
+
# `SourceTag`: who is calling) and an optional `role:`/`actor_id:` —
|
|
44
48
|
# a real caller identity, bound for the call's duration via `Hecks.
|
|
45
49
|
# as_caller`, checked against a `role`-gated command's own declared
|
|
46
50
|
# role (`CommandRules::Authorization`) rather than merely documented by
|
|
47
|
-
# `describe`. `dispatch`
|
|
51
|
+
# `describe`. `dispatch` requires it for any command that declares a
|
|
48
52
|
# role — `require_caller_for_role_gated!` refuses an unbound dispatch
|
|
49
53
|
# against one rather than silently running it unchecked; `query`'s own
|
|
50
54
|
# authorization runs on a separate mechanism (`Runtime::TenantScope`)
|
|
@@ -54,21 +58,25 @@ module Hecks
|
|
|
54
58
|
# header for what that does and does not guarantee. Every call through
|
|
55
59
|
# those three, plus a dry run, is appended to a per-domain JSONL audit
|
|
56
60
|
# log (`record!`) `follow` tails back — a record of what the caller
|
|
57
|
-
#
|
|
61
|
+
# said it was, not independently verified identity. `catalog`/
|
|
58
62
|
# `describe`/`validate`/`domains`/`history`/`behaviors`/`events` need
|
|
59
63
|
# neither — they change nothing and commit nothing to any log.
|
|
60
64
|
#
|
|
61
|
-
#
|
|
65
|
+
# ## Booting is the caller's job
|
|
66
|
+
#
|
|
67
|
+
# A caller hands in an already-booted `runtime`, the same division of
|
|
62
68
|
# labor `Facade::CliRunner` already keeps against `bin/run`: booting a
|
|
63
69
|
# domain from a path is IO the calling `bin/` script owns, this stays a
|
|
64
70
|
# pure function of a `Runtime::Dispatcher` plus plain Ruby arguments —
|
|
65
71
|
# no different from `CliRunner.call(runtime:, argv:)` itself.
|
|
66
72
|
# `validate` and `domains` are the two exceptions: `validate`'s whole
|
|
67
73
|
# job is to attempt the boot and report whether it survived, so it
|
|
68
|
-
# takes the domain
|
|
74
|
+
# takes the domain path instead and boots it; `domains` has no
|
|
69
75
|
# domain to be handed one of yet, that's what it's answering.
|
|
70
76
|
#
|
|
71
|
-
#
|
|
77
|
+
# ## Built from existing doors
|
|
78
|
+
#
|
|
79
|
+
# No new vocabulary otherwise. Every method here composes doors that
|
|
72
80
|
# already exist — `Projector.call(:cli, ...)` for verb/question alias
|
|
73
81
|
# resolution (the identical table `CliRunner` itself resolves against),
|
|
74
82
|
# `Projector.call(:docs, ...)` for `describe`, `Facade::JsonDoor` for
|
|
@@ -82,21 +90,21 @@ module Hecks
|
|
|
82
90
|
module Storehouse
|
|
83
91
|
module_function
|
|
84
92
|
|
|
85
|
-
#
|
|
86
|
-
# `SourceTag` names —
|
|
93
|
+
# The same closed set `docs/hecks-survey-what-we-wish-we-had.md`'s
|
|
94
|
+
# `SourceTag` names — who dispatched, not what. Optional: a caller
|
|
87
95
|
# that omits it gets `source: nil` recorded, honestly, rather than a
|
|
88
96
|
# guessed default.
|
|
89
97
|
SOURCE_TAGS = %w[process-manager operator hook sidequest-agent cascade daemon].freeze
|
|
90
98
|
|
|
91
|
-
#
|
|
99
|
+
# **The bus's own audit trail** — a JSONL file per domain, one line per
|
|
92
100
|
# `dispatch`/`query`/`state`/dry-run call, `follow` tails it back.
|
|
93
|
-
# `tmp/`, not the domain's own directory: this is the
|
|
101
|
+
# `tmp/`, not the domain's own directory: this is the bus's record
|
|
94
102
|
# of what was asked of it, not part of the domain's own persisted
|
|
95
103
|
# state, and `tmp/` is already gitignored for exactly this kind of
|
|
96
104
|
# local, disposable-but-useful-while-it-lasts file.
|
|
97
105
|
LOG_ROOT = File.expand_path("../../tmp/storehouse", __dir__)
|
|
98
106
|
|
|
99
|
-
#
|
|
107
|
+
# The root every `domain:`/`under:` must resolve under — the project
|
|
100
108
|
# directory by default, `HECKS_STOREHOUSE_ROOT` to widen or move it.
|
|
101
109
|
# `Hecks.boot` `Kernel.load`s the `.hecksagon`/`.bluebook`/`.world`
|
|
102
110
|
# files a domain path resolves to, and those are Ruby, not a data
|
|
@@ -108,11 +116,19 @@ module Hecks
|
|
|
108
116
|
# project tree.
|
|
109
117
|
BOOT_ROOT = File.expand_path(ENV["HECKS_STOREHOUSE_ROOT"] || File.expand_path("../..", __dir__))
|
|
110
118
|
|
|
111
|
-
#
|
|
119
|
+
# Resolves `path` against `BOOT_ROOT` and refuses one that escapes it.
|
|
120
|
+
#
|
|
121
|
+
# **Refused, not silently clamped** — a path outside `BOOT_ROOT` is either
|
|
112
122
|
# an honest mistake (a relative path typed against the wrong cwd) or
|
|
113
123
|
# the exact thing this check exists to catch, and both deserve the
|
|
114
124
|
# same clear refusal rather than a silent rewrite to something the
|
|
115
125
|
# caller didn't ask for.
|
|
126
|
+
#
|
|
127
|
+
# @param path [String, #to_s] the caller-supplied path to confine
|
|
128
|
+
# @param label [String] the argument's own name, for the refusal message
|
|
129
|
+
# (e.g. `"domain"`, `"under"`)
|
|
130
|
+
# @return [String] `path` resolved to an absolute path under `BOOT_ROOT`
|
|
131
|
+
# @raise [Runtime::TypeMismatch] if the resolved path is outside `BOOT_ROOT`
|
|
116
132
|
def confine!(path, label)
|
|
117
133
|
resolved = File.expand_path(path.to_s, BOOT_ROOT)
|
|
118
134
|
return resolved if resolved == BOOT_ROOT || resolved.start_with?("#{BOOT_ROOT}#{File::SEPARATOR}")
|
|
@@ -124,25 +140,43 @@ module Hecks
|
|
|
124
140
|
|
|
125
141
|
# ── shared resolution helpers ────────────────────────────────────
|
|
126
142
|
|
|
127
|
-
#
|
|
143
|
+
# The one bluebook a domain directory boots. Every `bin/*` script that
|
|
128
144
|
# projects a whole-domain CLI or doc set makes this same assumption
|
|
129
145
|
# (`Facade::CliRunner#call`'s own `bluebook = runtime.registry.
|
|
130
146
|
# bluebooks.values.first`) — one `.hecksagon` names one chapter.
|
|
147
|
+
# The one bluebook this runtime booted.
|
|
148
|
+
#
|
|
149
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
150
|
+
# @return [Bluebook::Chapter] the runtime's one loaded bluebook
|
|
151
|
+
# @raise [Runtime::NotFound] if the runtime's boot loaded no bluebook
|
|
131
152
|
def bluebook_for(runtime)
|
|
132
153
|
runtime.registry.bluebooks.values.first or
|
|
133
154
|
raise Runtime::NotFound, "this boot loaded no bluebook"
|
|
134
155
|
end
|
|
135
156
|
|
|
157
|
+
# Finds one aggregate by name on a bluebook, or refuses.
|
|
158
|
+
#
|
|
159
|
+
# @param bluebook [Bluebook::Chapter] the chapter to search
|
|
160
|
+
# @param name [String, Symbol, #to_s] the aggregate's declared name
|
|
161
|
+
# @return [Bluebook::Aggregate] the matching aggregate
|
|
162
|
+
# @raise [Runtime::NotFound] if `bluebook` declares no aggregate named `name`
|
|
136
163
|
def aggregate_ir!(bluebook, name)
|
|
137
164
|
bluebook.aggregate(name) or
|
|
138
165
|
raise Runtime::NotFound, "#{bluebook.name} declares no aggregate named #{name.inspect} — " \
|
|
139
166
|
"known: #{bluebook.aggregates.map(&:hecks_name).sort.join(', ')}"
|
|
140
167
|
end
|
|
141
168
|
|
|
142
|
-
#
|
|
169
|
+
# The same alias table `CliRunner` resolves a typed word against — a
|
|
143
170
|
# short name when it's unambiguous, the qualified `Aggregate.Verb`
|
|
144
171
|
# form always. Shared here so `dispatch` and `query` (and their error
|
|
145
172
|
# messages) never drift from what a human typing `bin/run` sees.
|
|
173
|
+
#
|
|
174
|
+
# @param cli [Hash{Symbol => Object}] a `Projector.call(:cli, ...)` result
|
|
175
|
+
# @param name [String, Symbol, #to_s] the command or query name, as given by the caller
|
|
176
|
+
# @param asking [Boolean] true to resolve a query (against `cli[:questions]`),
|
|
177
|
+
# false to resolve a command (against `cli[:verbs]`)
|
|
178
|
+
# @return [Hash{Symbol => Object}] the resolved verb/question spec
|
|
179
|
+
# @raise [Runtime::NotFound] if `name` matches no known command or query
|
|
146
180
|
def resolve!(cli, name, asking:)
|
|
147
181
|
pool = asking ? cli[:questions] : cli[:verbs]
|
|
148
182
|
key = cli[:names][asking ? :question : :command][name]
|
|
@@ -153,6 +187,11 @@ module Hecks
|
|
|
153
187
|
raise Runtime::NotFound, "no such #{asking ? 'query' : 'command'}: #{name.inspect} — known: #{known}"
|
|
154
188
|
end
|
|
155
189
|
|
|
190
|
+
# Refuses a call with no summary.
|
|
191
|
+
#
|
|
192
|
+
# @param summary [String, nil] the caller-supplied one-line summary
|
|
193
|
+
# @return [void]
|
|
194
|
+
# @raise [Runtime::TypeMismatch] if `summary` is nil or blank
|
|
156
195
|
def require_summary!(summary)
|
|
157
196
|
return unless summary.nil? || summary.to_s.strip.empty?
|
|
158
197
|
|
|
@@ -160,58 +199,80 @@ module Hecks
|
|
|
160
199
|
"a one-line summary: is required on dispatch/query/state — it is what makes an audit row legible later"
|
|
161
200
|
end
|
|
162
201
|
|
|
202
|
+
# Refuses a `source:` that is not one of `SOURCE_TAGS`.
|
|
203
|
+
#
|
|
204
|
+
# @param source [String, Symbol, nil] the caller-supplied source tag
|
|
205
|
+
# @return [void]
|
|
206
|
+
# @raise [Runtime::TypeMismatch] if `source` is present and not in `SOURCE_TAGS`
|
|
163
207
|
def valid_source!(source)
|
|
164
208
|
return if source.nil? || SOURCE_TAGS.include?(source.to_s)
|
|
165
209
|
|
|
166
210
|
raise Runtime::TypeMismatch, "source: #{source.inspect} is not one of #{SOURCE_TAGS.join(', ')}"
|
|
167
211
|
end
|
|
168
212
|
|
|
169
|
-
# `actor_id`
|
|
213
|
+
# `actor_id` names who, `role` names what they hold — `Hecks.
|
|
170
214
|
# as_caller` requires the latter always, the former is additive
|
|
171
215
|
# (`Runtime::Caller::Current`'s own shape). An `actor_id` with no
|
|
172
216
|
# `role` would silently do nothing rather than bind a real caller,
|
|
173
217
|
# which is worse than refusing: a caller who thinks they've
|
|
174
218
|
# identified themselves and haven't deserves to be told.
|
|
219
|
+
#
|
|
220
|
+
# @param role [String, Symbol, nil] the caller's self-asserted role
|
|
221
|
+
# @param actor_id [String, nil] the caller's self-asserted identity
|
|
222
|
+
# @return [void]
|
|
223
|
+
# @raise [Runtime::TypeMismatch] if `actor_id` is given without `role`
|
|
175
224
|
def valid_caller!(role, actor_id)
|
|
176
225
|
return unless actor_id && role.nil?
|
|
177
226
|
|
|
178
227
|
raise Runtime::TypeMismatch, "actor_id: requires role: too — a caller names WHO through WHICH role they hold"
|
|
179
228
|
end
|
|
180
229
|
|
|
181
|
-
#
|
|
230
|
+
# Bound for the duration of one call, then gone — `Hecks.as_caller`
|
|
182
231
|
# is itself a `Thread.current`-scoped `ensure`-guarded block, so
|
|
183
232
|
# nothing here needs its own cleanup. `role: nil` yields unbound —
|
|
184
233
|
# for `query`, exactly as before: `CommandRules::Authorization#
|
|
185
|
-
# refuse_role_mismatch` is
|
|
234
|
+
# refuse_role_mismatch` is opt-in on the domain side (`return unless
|
|
186
235
|
# caller`), and query authorization runs on a wholly separate
|
|
187
236
|
# mechanism (`authorize policy, tenant: :field`, checked against an
|
|
188
237
|
# explicit `tenant:` argument — see `Runtime::TenantScope`), so
|
|
189
|
-
# binding a caller around a query has no effect on it
|
|
238
|
+
# binding a caller around a query has no effect on it today; it is
|
|
190
239
|
# still accepted here, for symmetry and for the audit log, against
|
|
191
240
|
# the day a read model does check `Caller.current`. For `dispatch`,
|
|
192
|
-
# `require_caller_for_role_gated!` (below) now refuses
|
|
241
|
+
# `require_caller_for_role_gated!` (below) now refuses before this
|
|
193
242
|
# is ever reached when the command declares a role and no caller is
|
|
194
243
|
# bound — so an unbound `dispatch` here means either the command
|
|
195
244
|
# declares no role at all, or a caller-side check let it through.
|
|
245
|
+
#
|
|
246
|
+
# @param role [String, Symbol, nil] the caller's self-asserted role; nil runs
|
|
247
|
+
# `block` unbound
|
|
248
|
+
# @param actor_id [String, nil] the caller's self-asserted identity
|
|
249
|
+
# @yield the dispatch/query to run, bound to the caller for its duration
|
|
250
|
+
# @return [Object] the block's own return value
|
|
196
251
|
def with_caller(role, actor_id, &block)
|
|
197
252
|
return block.call if role.nil?
|
|
198
253
|
|
|
199
254
|
Hecks.as_caller(role: role, actor_id: actor_id, &block)
|
|
200
255
|
end
|
|
201
256
|
|
|
202
|
-
#
|
|
203
|
-
# Governance RBAC work fixed
|
|
257
|
+
# The fail-open half `with_caller` itself cannot close — ADR 0025's
|
|
258
|
+
# Governance RBAC work fixed what a *bound* role is checked against
|
|
204
259
|
# (a live `Governance::RoleAssignment` lookup instead of a bare
|
|
205
260
|
# string match), but changed nothing about a caller who binds no
|
|
206
261
|
# role at all: `refuse_role_mismatch` `return`s immediately when
|
|
207
262
|
# `Caller.current` is nil, so a bus caller who simply omits `role:`
|
|
208
263
|
# sails past a role-gated command unchecked, not denied. That is a
|
|
209
|
-
# property of
|
|
264
|
+
# property of this bus choosing to dispatch unbound, not of the
|
|
210
265
|
# domain rule — `bin/run`, the human CLI, has no such gap because a
|
|
211
266
|
# human always dispatches through a real `Hecks.as_caller` binding
|
|
212
267
|
# upstream of it. Refusing here, before `with_caller`/`dispatch` are
|
|
213
268
|
# ever reached, makes the bus keep the same promise: a command whose
|
|
214
269
|
# bluebook declares a role is not run through this bus without one.
|
|
270
|
+
#
|
|
271
|
+
# @param spec [Hash{Symbol => Object}] a resolved command spec (from `resolve!`),
|
|
272
|
+
# read for `:role_gated`, `:role` and `:verb`
|
|
273
|
+
# @param role [String, Symbol, nil] the caller's self-asserted role
|
|
274
|
+
# @return [void]
|
|
275
|
+
# @raise [Runtime::Unauthorized] if `spec` is role-gated and `role` is nil
|
|
215
276
|
def require_caller_for_role_gated!(spec, role)
|
|
216
277
|
return unless spec[:role_gated] && role.nil?
|
|
217
278
|
|
|
@@ -220,17 +281,26 @@ module Hecks
|
|
|
220
281
|
"(role:/actor_id:) is bound; dispatching it unbound is refused, not silently unchecked"
|
|
221
282
|
end
|
|
222
283
|
|
|
223
|
-
# `dry_run?` (Runtime::Dispatcher) understands only the
|
|
284
|
+
# `dry_run?` (Runtime::Dispatcher) understands only the old flat
|
|
224
285
|
# legacy args shape — no to:/with: envelope, `route:` never passed
|
|
225
286
|
# (its own header explains why: built directly against
|
|
226
287
|
# CommandInterpreter/EntityInterpreter's pre-envelope contract,
|
|
227
288
|
# never updated because nothing else needed it to be — a real
|
|
228
289
|
# record of history, not a defect this bus should paper over
|
|
229
290
|
# silently). `Facade::CommandRequest`/`spec[:legacy_receiver]`
|
|
230
|
-
# already know how to
|
|
291
|
+
# already know how to name that same flat shape for every receiver
|
|
231
292
|
# kind (a bare id under one string key for :aggregate, a
|
|
232
293
|
# {aggregate:, entity:} pair of keys for :entity) — this is the one
|
|
233
|
-
# door back
|
|
294
|
+
# door back into it.
|
|
295
|
+
#
|
|
296
|
+
# @param envelope [Hash{Symbol => Object}] a normalized command request, with an
|
|
297
|
+
# optional `:to` route and `:with` facts
|
|
298
|
+
# @param receiver [Symbol, nil] `:aggregate`, `:entity`, or nil for anything else
|
|
299
|
+
# @param legacy_receiver [Symbol, String, Hash{Symbol => Symbol, String}, nil] where
|
|
300
|
+
# the route belongs in the flat shape: a single key for `:aggregate`, an
|
|
301
|
+
# `{aggregate:, entity:}` pair of keys for `:entity`
|
|
302
|
+
# @return [Hash{Symbol => Object}] the facts, with the route merged in under its
|
|
303
|
+
# legacy key(s) when `envelope` carries one
|
|
234
304
|
def flatten_legacy(envelope, receiver, legacy_receiver)
|
|
235
305
|
facts = envelope[:with] || {}
|
|
236
306
|
return facts unless envelope.key?(:to)
|
|
@@ -247,14 +317,34 @@ module Hecks
|
|
|
247
317
|
|
|
248
318
|
# ── the audit log `follow` reads back ───────────────────────────────
|
|
249
319
|
|
|
320
|
+
# The audit log file one domain's calls are appended to.
|
|
321
|
+
#
|
|
322
|
+
# @param domain_name [String, Symbol, #to_s] the domain name
|
|
323
|
+
# @return [String] the absolute path to that domain's JSONL log file, under
|
|
324
|
+
# `LOG_ROOT`
|
|
250
325
|
def log_path(domain_name)
|
|
251
326
|
File.join(LOG_ROOT, "#{domain_name.to_s.gsub(/[^A-Za-z0-9_-]/, '_')}.jsonl")
|
|
252
327
|
end
|
|
253
328
|
|
|
254
|
-
#
|
|
255
|
-
#
|
|
329
|
+
# Appends one call's outcome to its domain's audit log.
|
|
330
|
+
#
|
|
331
|
+
# Never fails a real call because its own audit log couldn't be
|
|
332
|
+
# written — a full disk or a permissions problem is a `follow`
|
|
256
333
|
# feature going dark, not a reason to refuse the dispatch/query/
|
|
257
334
|
# state call that was actually asked for.
|
|
335
|
+
#
|
|
336
|
+
# @param domain_name [String, Symbol, nil] the domain to log against; nil skips
|
|
337
|
+
# logging entirely (a call that never resolved a bluebook)
|
|
338
|
+
# @param tool [String] which tool logged this: `"dispatch"`, `"dry_run"`,
|
|
339
|
+
# `"query"`, or `"state"`
|
|
340
|
+
# @param summary [String, nil] the caller's one-line summary
|
|
341
|
+
# @param source [String, Symbol, nil] the caller's source tag
|
|
342
|
+
# @param outcome [Hash{Symbol => Object}] the call's own result, read for `:ok`,
|
|
343
|
+
# `:id`, `:error` and `:events`
|
|
344
|
+
# @param verb [String, nil] the resolved verb dispatched or queried, when known
|
|
345
|
+
# @param role [String, Symbol, nil] the caller's self-asserted role
|
|
346
|
+
# @param actor_id [String, nil] the caller's self-asserted identity
|
|
347
|
+
# @return [void]
|
|
258
348
|
def record!(domain_name, tool:, summary:, source:, outcome:, verb: nil, role: nil, actor_id: nil)
|
|
259
349
|
return unless domain_name
|
|
260
350
|
|
|
@@ -269,12 +359,25 @@ module Hecks
|
|
|
269
359
|
|
|
270
360
|
# ── the three that drive it ────────────────────────────────────────
|
|
271
361
|
|
|
272
|
-
# `dry_run: true`
|
|
362
|
+
# `dry_run: true` answers a different question than a real dispatch
|
|
273
363
|
# does — "would this succeed", not "here is what happened" — so a
|
|
274
|
-
# domain refusal is the legitimate, complete
|
|
364
|
+
# domain refusal is the legitimate, complete answer (`ok: true,
|
|
275
365
|
# would_succeed: false`), not a failed call. A malformed request
|
|
276
366
|
# (unknown command, a bad args shape) is still a failed call
|
|
277
367
|
# (`ok: false`) either way — it never reached the domain to be asked.
|
|
368
|
+
#
|
|
369
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
370
|
+
# @param command [String, Symbol] the command name, bare or qualified
|
|
371
|
+
# @param summary [String] a one-line human summary of the call, for the audit log
|
|
372
|
+
# @param args [Hash] the command's arguments
|
|
373
|
+
# @param source [String, Symbol, nil] a `SOURCE_TAGS` tag naming who is calling
|
|
374
|
+
# @param dry_run [Boolean] true to check whether the command would succeed,
|
|
375
|
+
# without actually dispatching it
|
|
376
|
+
# @param role [String, Symbol, nil] the caller's self-asserted role
|
|
377
|
+
# @param actor_id [String, nil] the caller's self-asserted identity
|
|
378
|
+
# @return [Hash{Symbol => Object}] `:ok` plus, on success, `:id`/`:state`/`:events`
|
|
379
|
+
# (a real dispatch) or `:would_succeed`/`:error` (a dry run); on refusal, the
|
|
380
|
+
# shape `refused` returns
|
|
278
381
|
def dispatch(runtime:, command:, summary:, args: {}, source: nil, dry_run: false, role: nil, actor_id: nil)
|
|
279
382
|
bluebook = bluebook_for(runtime)
|
|
280
383
|
tool = dry_run ? "dry_run" : "dispatch"
|
|
@@ -290,6 +393,24 @@ module Hecks
|
|
|
290
393
|
outcome
|
|
291
394
|
end
|
|
292
395
|
|
|
396
|
+
# Validates and dispatches one command, real or dry-run.
|
|
397
|
+
#
|
|
398
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
399
|
+
# @param bluebook [Bluebook::Chapter] the domain's one loaded chapter
|
|
400
|
+
# @param command [String, Symbol] the command name, bare or qualified
|
|
401
|
+
# @param summary [String, nil] a one-line human summary of the call
|
|
402
|
+
# @param args [Hash] the command's arguments, JSON-shaped
|
|
403
|
+
# @param source [String, Symbol, nil] a `SOURCE_TAGS` tag naming who is calling
|
|
404
|
+
# @param dry_run [Boolean] true to check whether the command would succeed,
|
|
405
|
+
# without actually dispatching it
|
|
406
|
+
# @param role [String, Symbol, nil] the caller's self-asserted role
|
|
407
|
+
# @param actor_id [String, nil] the caller's self-asserted identity
|
|
408
|
+
# @return [Hash{Symbol => Object}] the outcome from `real_dispatch`/`dry_run_outcome`,
|
|
409
|
+
# merged with `:verb`
|
|
410
|
+
# @raise [Runtime::TypeMismatch] if `summary`, `source`, or `actor_id` without
|
|
411
|
+
# `role` is invalid
|
|
412
|
+
# @raise [Runtime::NotFound] if `command` names no known command
|
|
413
|
+
# @raise [Runtime::Unauthorized] if `command` is role-gated and `role` is nil
|
|
293
414
|
def perform_dispatch(runtime, bluebook, command, summary, args, source, dry_run, role, actor_id)
|
|
294
415
|
require_summary!(summary)
|
|
295
416
|
valid_source!(source)
|
|
@@ -307,14 +428,30 @@ module Hecks
|
|
|
307
428
|
result.merge(verb: spec[:verb])
|
|
308
429
|
end
|
|
309
430
|
|
|
431
|
+
# Dispatches one command for real and shapes its result for the bus.
|
|
432
|
+
#
|
|
433
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
434
|
+
# @param spec [Hash{Symbol => Object}] the resolved command spec (from `resolve!`)
|
|
435
|
+
# @param envelope [Hash{Symbol => Object}] the normalized `to:`/`with:` request
|
|
436
|
+
# @param summary [String, nil] a one-line human summary of the call
|
|
437
|
+
# @return [Hash{Symbol => Object}] `:ok`, `:summary`, `:id`, `:state` (JSON-safe,
|
|
438
|
+
# nil for a port operation) and `:events` (name/payload pairs)
|
|
310
439
|
def real_dispatch(runtime, spec, envelope, summary)
|
|
311
|
-
result = runtime.
|
|
440
|
+
result = runtime.dispatch_flat(spec[:verb], envelope)
|
|
312
441
|
ok(summary: summary,
|
|
313
442
|
id: result.id,
|
|
314
443
|
state: result.state.nil? ? nil : Facade::JsonDoor.materialize(result.state),
|
|
315
444
|
events: result.events.map { |event| { name: event.name, payload: Facade::JsonDoor.materialize(event.payload) } })
|
|
316
445
|
end
|
|
317
446
|
|
|
447
|
+
# Checks whether one command would succeed, without dispatching it for real.
|
|
448
|
+
#
|
|
449
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
450
|
+
# @param spec [Hash{Symbol => Object}] the resolved command spec (from `resolve!`)
|
|
451
|
+
# @param envelope [Hash{Symbol => Object}] the normalized `to:`/`with:` request
|
|
452
|
+
# @param summary [String, nil] a one-line human summary of the call
|
|
453
|
+
# @return [Hash{Symbol => Object}] `:ok`, `:summary`, `:would_succeed`, and
|
|
454
|
+
# `:error` (the refusal message) when it would not succeed
|
|
318
455
|
def dry_run_outcome(runtime, spec, envelope, summary:)
|
|
319
456
|
flat = flatten_legacy(envelope, spec[:receiver], spec[:legacy_receiver])
|
|
320
457
|
runtime.dry_run?(spec[:verb], **flat)
|
|
@@ -323,8 +460,10 @@ module Hecks
|
|
|
323
460
|
ok(summary: summary, would_succeed: false, error: e.message)
|
|
324
461
|
end
|
|
325
462
|
|
|
326
|
-
#
|
|
327
|
-
#
|
|
463
|
+
# Dispatches a whole sequence of commands as one call.
|
|
464
|
+
#
|
|
465
|
+
# **One call, many steps** — the survey's own `bin/run <domain> script`
|
|
466
|
+
# shape, so an agent issuing a known sequence of commands (open an
|
|
328
467
|
# account, then fund it) pays one round trip instead of N. Every step
|
|
329
468
|
# goes through `dispatch` itself — same resolution, same audit log
|
|
330
469
|
# line per step, same summary/source stamped on all of them since
|
|
@@ -333,6 +472,16 @@ module Hecks
|
|
|
333
472
|
# refusing — a later step naming a record an earlier step never
|
|
334
473
|
# created will refuse honestly on its own account, which is more
|
|
335
474
|
# informative than silently dropping the rest of the batch.
|
|
475
|
+
#
|
|
476
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
477
|
+
# @param steps [Array<Hash>] each step's `command`/`args`, JSON-shaped
|
|
478
|
+
# @param summary [String] a one-line human summary of the whole batch
|
|
479
|
+
# @param source [String, Symbol, nil] a `SOURCE_TAGS` tag naming who is calling
|
|
480
|
+
# @param role [String, Symbol, nil] the caller's self-asserted role
|
|
481
|
+
# @param actor_id [String, nil] the caller's self-asserted identity
|
|
482
|
+
# @return [Hash{Symbol => Object}] `:ok` (true only if every step's own `:ok` was
|
|
483
|
+
# true), `:summary` and `:results` (each step's own `dispatch` outcome); on a
|
|
484
|
+
# refusal outside any one step, the shape `refused` returns
|
|
336
485
|
def dispatch_batch(runtime:, steps:, summary:, source: nil, role: nil, actor_id: nil)
|
|
337
486
|
require_summary!(summary)
|
|
338
487
|
results = Array(steps).map do |raw|
|
|
@@ -345,6 +494,17 @@ module Hecks
|
|
|
345
494
|
refused(e, summary: summary)
|
|
346
495
|
end
|
|
347
496
|
|
|
497
|
+
# Answers one declared query.
|
|
498
|
+
#
|
|
499
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
500
|
+
# @param question [String, Symbol] the query name, bare or qualified
|
|
501
|
+
# @param summary [String] a one-line human summary of the call, for the audit log
|
|
502
|
+
# @param args [Hash] the query's arguments
|
|
503
|
+
# @param source [String, Symbol, nil] a `SOURCE_TAGS` tag naming who is calling
|
|
504
|
+
# @param role [String, Symbol, nil] the caller's self-asserted role
|
|
505
|
+
# @param actor_id [String, nil] the caller's self-asserted identity
|
|
506
|
+
# @return [Hash{Symbol => Object}] `:ok` and `:rows` (each JSON-safe) on success,
|
|
507
|
+
# or the shape `refused` returns
|
|
348
508
|
def query(runtime:, question:, summary:, args: {}, source: nil, role: nil, actor_id: nil)
|
|
349
509
|
bluebook = bluebook_for(runtime)
|
|
350
510
|
outcome = perform_query(bluebook, runtime, question, summary, args, source, role, actor_id)
|
|
@@ -359,6 +519,20 @@ module Hecks
|
|
|
359
519
|
outcome
|
|
360
520
|
end
|
|
361
521
|
|
|
522
|
+
# Validates and answers one query.
|
|
523
|
+
#
|
|
524
|
+
# @param bluebook [Bluebook::Chapter] the domain's one loaded chapter
|
|
525
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
526
|
+
# @param question [String, Symbol] the query name, bare or qualified
|
|
527
|
+
# @param summary [String, nil] a one-line human summary of the call
|
|
528
|
+
# @param args [Hash] the query's arguments, JSON-shaped
|
|
529
|
+
# @param source [String, Symbol, nil] a `SOURCE_TAGS` tag naming who is calling
|
|
530
|
+
# @param role [String, Symbol, nil] the caller's self-asserted role
|
|
531
|
+
# @param actor_id [String, nil] the caller's self-asserted identity
|
|
532
|
+
# @return [Hash{Symbol => Object}] `:ok`, `:rows` (each JSON-safe) and `:verb`
|
|
533
|
+
# @raise [Runtime::TypeMismatch] if `summary`, `source`, or `actor_id` without
|
|
534
|
+
# `role` is invalid
|
|
535
|
+
# @raise [Runtime::NotFound] if `question` names no known query
|
|
362
536
|
def perform_query(bluebook, runtime, question, summary, args, source, role, actor_id)
|
|
363
537
|
require_summary!(summary)
|
|
364
538
|
valid_source!(source)
|
|
@@ -370,12 +544,22 @@ module Hecks
|
|
|
370
544
|
ok(summary: summary, rows: rows.map { |row| Facade::JsonDoor.materialize(row) }).merge(verb: spec[:verb])
|
|
371
545
|
end
|
|
372
546
|
|
|
373
|
-
#
|
|
547
|
+
# Reads one aggregate's stored records directly, bypassing any declared query.
|
|
548
|
+
#
|
|
549
|
+
# **What is actually stored** — no verb, no interpretation, the repository
|
|
374
550
|
# itself. `id:` given answers one record (`NotFound` when it names
|
|
375
551
|
# nothing); omitted answers every record the aggregate currently
|
|
376
552
|
# holds. This is the difference `query` can't cover: a query answers a
|
|
377
|
-
#
|
|
553
|
+
# declared question, and an aggregate that never declared "list
|
|
378
554
|
# everything" has no query this could reuse.
|
|
555
|
+
#
|
|
556
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
557
|
+
# @param aggregate [String, Symbol] the aggregate's declared name
|
|
558
|
+
# @param summary [String] a one-line human summary of the call, for the audit log
|
|
559
|
+
# @param id [String, Object, nil] one record's identity, or nil for every record
|
|
560
|
+
# @param source [String, Symbol, nil] a `SOURCE_TAGS` tag naming who is calling
|
|
561
|
+
# @return [Hash{Symbol => Object}] `:ok` and, with `id:`, `:record`; without it,
|
|
562
|
+
# `:count` and `:records` (each JSON-safe); or the shape `refused` returns
|
|
379
563
|
def state(runtime:, aggregate:, summary:, id: nil, source: nil)
|
|
380
564
|
bluebook = bluebook_for(runtime)
|
|
381
565
|
outcome = perform_state(runtime, bluebook, aggregate, summary, id)
|
|
@@ -388,6 +572,18 @@ module Hecks
|
|
|
388
572
|
outcome
|
|
389
573
|
end
|
|
390
574
|
|
|
575
|
+
# Validates and reads one aggregate's stored record(s).
|
|
576
|
+
#
|
|
577
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
578
|
+
# @param bluebook [Bluebook::Chapter] the domain's one loaded chapter
|
|
579
|
+
# @param aggregate [String, Symbol] the aggregate's declared name
|
|
580
|
+
# @param summary [String, nil] a one-line human summary of the call
|
|
581
|
+
# @param id [String, Object, nil] one record's identity, or nil for every record
|
|
582
|
+
# @return [Hash{Symbol => Object}] `:ok` and, with `id`, `:record`; without it,
|
|
583
|
+
# `:count` and `:records` (each JSON-safe)
|
|
584
|
+
# @raise [Runtime::TypeMismatch] if `summary` is invalid
|
|
585
|
+
# @raise [Runtime::NotFound] if `aggregate` names no known aggregate, or `id`
|
|
586
|
+
# names no record of it
|
|
391
587
|
def perform_state(runtime, bluebook, aggregate, summary, id)
|
|
392
588
|
require_summary!(summary)
|
|
393
589
|
ir = aggregate_ir!(bluebook, aggregate)
|
|
@@ -405,13 +601,21 @@ module Hecks
|
|
|
405
601
|
|
|
406
602
|
# ── the four zoom levels ─────────────────────────────────────────
|
|
407
603
|
|
|
408
|
-
#
|
|
604
|
+
# Lists every domain directory under a root.
|
|
605
|
+
#
|
|
606
|
+
# **Zoom level zero** — every domain directory a root actually holds,
|
|
409
607
|
# discovered rather than typed from memory. Every other tool takes
|
|
410
608
|
# `domain:` as a directory it assumes the caller already knows; this
|
|
411
609
|
# is how a caller who doesn't finds out. `Adapters::Folder#domain?`
|
|
412
610
|
# is the same predicate `domain_root`/`nearest_domain` already walk
|
|
413
611
|
# up directories checking — a bare `.hecksagon` or one under
|
|
414
612
|
# `bluebook/`, the two real shapes this corpus uses.
|
|
613
|
+
#
|
|
614
|
+
# @param under [String] the directory to search, relative to `BOOT_ROOT`
|
|
615
|
+
# @return [Hash{Symbol => Object}] `:ok`, `:under`, and `:domains` (each found
|
|
616
|
+
# domain's path, relative to `BOOT_ROOT`, sorted); `:domains` is `[]` when
|
|
617
|
+
# `under` does not exist
|
|
618
|
+
# @raise [Runtime::TypeMismatch] if `under` resolves outside `BOOT_ROOT`
|
|
415
619
|
def domains(under: "examples")
|
|
416
620
|
root = confine!(under, "under")
|
|
417
621
|
return ok(under: under, domains: []) unless Dir.exist?(root)
|
|
@@ -422,10 +626,17 @@ module Hecks
|
|
|
422
626
|
ok(under: under, domains: found.map { |name| File.join(under, name) })
|
|
423
627
|
end
|
|
424
628
|
|
|
425
|
-
#
|
|
629
|
+
# Lists a domain's aggregates and their command/query names.
|
|
630
|
+
#
|
|
631
|
+
# **Zoom level one** — every aggregate this domain declares, and every
|
|
426
632
|
# command/query name each answers to, snake_cased exactly as
|
|
427
633
|
# `dispatch`/`query` want it. Enough to pick a target; `describe` is
|
|
428
634
|
# the next level down for what one of them actually takes.
|
|
635
|
+
#
|
|
636
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
637
|
+
# @return [Hash{Symbol => Object}] `:ok`, `:domain`, and `:aggregates` — an Array
|
|
638
|
+
# of `{name:, commands:, queries:}` Hashes, commands and queries sorted,
|
|
639
|
+
# command names suffixed `!`; or the shape `refused` returns
|
|
429
640
|
def catalog(runtime:)
|
|
430
641
|
bluebook = bluebook_for(runtime)
|
|
431
642
|
|
|
@@ -439,12 +650,21 @@ module Hecks
|
|
|
439
650
|
refused(e)
|
|
440
651
|
end
|
|
441
652
|
|
|
442
|
-
#
|
|
653
|
+
# Answers one aggregate's (or the whole chapter's) full usage documentation.
|
|
654
|
+
#
|
|
655
|
+
# **Zoom level two** — the exact same usage document a human gets from
|
|
443
656
|
# `bin/docs <domain> [aggregate]` (`Projector::DocsProjector`, the
|
|
444
657
|
# identical projection `Surface::AggregateDoor#docs` calls one door
|
|
445
658
|
# over): every command's arguments, the states it may be issued
|
|
446
659
|
# from, and every way it can refuse. `aggregate:` omitted answers the
|
|
447
660
|
# whole chapter.
|
|
661
|
+
#
|
|
662
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
663
|
+
# @param aggregate [String, Symbol, nil] one aggregate's name, or nil for the
|
|
664
|
+
# whole chapter
|
|
665
|
+
# @return [Hash{Symbol => Object}] `:ok`, `:domain` and `:docs` (the rendered
|
|
666
|
+
# documentation); or the shape `refused` returns
|
|
667
|
+
# @raise [Runtime::NotFound] if `aggregate` names no known aggregate
|
|
448
668
|
def describe(runtime:, aggregate: nil)
|
|
449
669
|
bluebook = bluebook_for(runtime)
|
|
450
670
|
options = aggregate ? { aggregate: aggregate_ir!(bluebook, aggregate).hecks_name } : {}
|
|
@@ -454,19 +674,21 @@ module Hecks
|
|
|
454
674
|
refused(e)
|
|
455
675
|
end
|
|
456
676
|
|
|
457
|
-
#
|
|
677
|
+
# Boots a domain and reports whether its wiring (and, optionally, its logic) is sound.
|
|
678
|
+
#
|
|
679
|
+
# **Zoom level three** — is the wiring sound at all: every bind names a
|
|
458
680
|
# declared aggregate, every adapter satisfies the port it claims, the
|
|
459
681
|
# default adapter is usable. `Registry#verify!` (`runtime/registry/
|
|
460
682
|
# verification.rb`) is the one place this repo already answers that
|
|
461
683
|
# question, and `Runtime::Loader.boot` already calls it as the last
|
|
462
|
-
# step of every boot — so
|
|
684
|
+
# step of every boot — so this is the one method here that boots for
|
|
463
685
|
# itself rather than taking a `runtime:` already in hand, because a
|
|
464
686
|
# runtime that successfully reached this line already answered the
|
|
465
|
-
# question. Given a domain
|
|
687
|
+
# question. Given a domain path, not a booted runtime, deliberately:
|
|
466
688
|
# asking "is this valid" about a domain that failed to boot at all
|
|
467
689
|
# has to be askable without a runtime to hand it.
|
|
468
690
|
#
|
|
469
|
-
# `deep: true`
|
|
691
|
+
# `deep: true` goes past wiring into logic — `Bluebook::ModelCheck`,
|
|
470
692
|
# the lightweight-formal-methods leg (dead lifecycle transitions, a
|
|
471
693
|
# saga state no handler chain reaches, a dispatch to nowhere). Opt-in
|
|
472
694
|
# and separate from the base check on purpose: a wiring defect is
|
|
@@ -474,11 +696,17 @@ module Hecks
|
|
|
474
696
|
# part of it can never fire" — two different questions, and the
|
|
475
697
|
# first is far cheaper to ask on every boot.
|
|
476
698
|
#
|
|
477
|
-
#
|
|
699
|
+
# Any boot failure answers the question, not only `WiringError` — a
|
|
478
700
|
# domain path with no `.hecksagon`, a malformed bluebook, is just as
|
|
479
701
|
# much "not valid" as a real wiring mismatch, and this tool exists
|
|
480
702
|
# precisely so none of those ever cross a projection of this bus as
|
|
481
703
|
# a crash.
|
|
704
|
+
#
|
|
705
|
+
# @param domain [String] the domain's directory path, relative to `BOOT_ROOT`
|
|
706
|
+
# @param deep [Boolean] also run `Bluebook::ModelCheck` past wiring, into logic
|
|
707
|
+
# @return [Hash{Symbol => Object}] `{ok: true, domain:, valid: true}`, plus
|
|
708
|
+
# `:findings` (each `{kind:, severity:, subject:, message:}`) when `deep`; or
|
|
709
|
+
# `{ok: false, domain:, valid: false, error:}` if the boot itself failed
|
|
482
710
|
def validate(domain:, deep: false)
|
|
483
711
|
runtime = Hecks.boot(confine!(domain, "domain"), install_facade: false)
|
|
484
712
|
result = { ok: true, domain: domain, valid: true }
|
|
@@ -496,11 +724,20 @@ module Hecks
|
|
|
496
724
|
|
|
497
725
|
# ── beyond the zoom levels ──────────────────────────────────────────
|
|
498
726
|
|
|
499
|
-
#
|
|
500
|
-
# own logic, unchanged:
|
|
501
|
-
#
|
|
502
|
-
#
|
|
503
|
-
#
|
|
727
|
+
# The full write history, not just the current head — `bin/history`'s
|
|
728
|
+
# own logic, unchanged: every operation that ever touched the
|
|
729
|
+
# aggregate, read off its repository's own `entries`. `Registry#repository`
|
|
730
|
+
# always hands back a `Ports::Persistence::AppendOnly`-wrapped adapter,
|
|
731
|
+
# never the bare adapter, so this never needs to guard against one —
|
|
732
|
+
# what varies by adapter is only how much the wrapped `entries` itself
|
|
733
|
+
# actually holds: a fresh Memory boot honestly has none yet, and a
|
|
734
|
+
# durable adapter (Heki, Postgres, SQLite, D1) has whatever it
|
|
735
|
+
# journaled.
|
|
736
|
+
#
|
|
737
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
738
|
+
# @return [Hash{Symbol => Object}] `:ok`, `:domain` and `:history` (each
|
|
739
|
+
# aggregate's storage name mapped to its `journal_entries`); or the shape
|
|
740
|
+
# `refused` returns
|
|
504
741
|
def history(runtime:)
|
|
505
742
|
bluebook = bluebook_for(runtime)
|
|
506
743
|
entries = bluebook.aggregates.each_with_object({}) do |aggregate, all|
|
|
@@ -513,19 +750,30 @@ module Hecks
|
|
|
513
750
|
refused(e)
|
|
514
751
|
end
|
|
515
752
|
|
|
753
|
+
# One aggregate's full append-only write history, JSON-safe.
|
|
754
|
+
#
|
|
755
|
+
# @param repository [Ports::Persistence::AppendOnly] the aggregate's repository, as
|
|
756
|
+
# `Registry#repository` always returns it
|
|
757
|
+
# @return [Array<Hash>] each journal entry's `:operation`, `:id` and `:state`
|
|
758
|
+
# (materialized); `[]` when the adapter has journaled nothing yet
|
|
516
759
|
def journal_entries(repository)
|
|
517
|
-
return [] unless repository.is_a?(Ports::Persistence::AppendOnly)
|
|
518
|
-
|
|
519
760
|
repository.entries.map { |entry| { operation: entry.operation, id: entry.id, state: Facade::JsonDoor.materialize(entry.state) } }
|
|
520
761
|
end
|
|
521
762
|
|
|
522
|
-
# `.behaviors`
|
|
763
|
+
# `.behaviors` files, run and reported — hand-curated examples of how
|
|
523
764
|
# to use a domain, in domain vocabulary (`docs/guides/behaviors.md`),
|
|
524
765
|
# the survey's own "honest-refusal", generated-example-suite items.
|
|
525
766
|
# `Hecks::Behaviors` boots each test fresh through `Hecks.boot_files`
|
|
526
|
-
# itself — `target:` names a `.behaviors` file
|
|
767
|
+
# itself — `target:` names a `.behaviors` file or a directory to
|
|
527
768
|
# sweep, never a `runtime:`, the one other method here besides
|
|
528
769
|
# `validate` that takes a path instead.
|
|
770
|
+
#
|
|
771
|
+
# @param target [String] a `.behaviors` file's path, or a directory to sweep
|
|
772
|
+
# for every `.behaviors` file under it
|
|
773
|
+
# @return [Hash{Symbol => Object}] `:ok`, `:target`, `:files` (each file's own
|
|
774
|
+
# `behaviors_file` shape) and `:counts` (`Behaviors.summarize`'s tally); or
|
|
775
|
+
# the shape `refused` returns
|
|
776
|
+
# @raise [Runtime::NotFound] if `target` is nil or names no real file or directory
|
|
529
777
|
def behaviors(target:)
|
|
530
778
|
require_relative "behaviors"
|
|
531
779
|
raise Runtime::NotFound, "no such file or directory: #{target.inspect}" unless target && File.exist?(target)
|
|
@@ -541,13 +789,18 @@ module Hecks
|
|
|
541
789
|
refused(e)
|
|
542
790
|
end
|
|
543
791
|
|
|
792
|
+
# Shapes one `.behaviors` file's own result for the bus.
|
|
793
|
+
#
|
|
794
|
+
# @param result [Behaviors::FileResult] one file's run result
|
|
795
|
+
# @return [Hash{Symbol => Object}] `:path`, `:parse_error`, and `:runs` (each
|
|
796
|
+
# test's `description`/`status`/`message`)
|
|
544
797
|
def behaviors_file(result)
|
|
545
798
|
{ path: result.path,
|
|
546
799
|
parse_error: result.parse_error,
|
|
547
800
|
runs: Array(result.runs).map { |run| { description: run.description, status: run.status, message: run.message } } }
|
|
548
801
|
end
|
|
549
802
|
|
|
550
|
-
# A
|
|
803
|
+
# A live tail without a live process — `bin/hecks_mcp_door` (its
|
|
551
804
|
# transport of MCP-over-stdio) answers one request at a time, no push
|
|
552
805
|
# channel to a client that only ever asks. This is the honest version
|
|
553
806
|
# of the survey's `storehouse follow` for that shape: not a
|
|
@@ -555,6 +808,12 @@ module Hecks
|
|
|
555
808
|
# dry-run call appends to (`record!`), tailed back here. Still real,
|
|
556
809
|
# still cross-process — the log outlives any one door's own process —
|
|
557
810
|
# just pull instead of push.
|
|
811
|
+
#
|
|
812
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
813
|
+
# @param limit [Integer, #to_i] how many of the most recent log entries to return;
|
|
814
|
+
# clamped to at least 1
|
|
815
|
+
# @return [Hash{Symbol => Object}] `:ok`, `:domain` and `:entries` (each logged
|
|
816
|
+
# call's own JSON entry); or the shape `refused` returns
|
|
558
817
|
def follow(runtime:, limit: 20)
|
|
559
818
|
bluebook = bluebook_for(runtime)
|
|
560
819
|
entries = log_lines(bluebook.name).last([limit.to_i, 1].max)
|
|
@@ -564,6 +823,11 @@ module Hecks
|
|
|
564
823
|
refused(e)
|
|
565
824
|
end
|
|
566
825
|
|
|
826
|
+
# Reads back one domain's whole audit log.
|
|
827
|
+
#
|
|
828
|
+
# @param domain_name [String, Symbol, #to_s] the domain name
|
|
829
|
+
# @return [Array<Hash>] every logged entry, parsed with Symbol keys, in log
|
|
830
|
+
# order; `[]` if the domain has no log file yet
|
|
567
831
|
def log_lines(domain_name)
|
|
568
832
|
path = log_path(domain_name)
|
|
569
833
|
return [] unless File.exist?(path)
|
|
@@ -571,21 +835,32 @@ module Hecks
|
|
|
571
835
|
File.readlines(path).map { |line| JSON.parse(line, symbolize_names: true) }
|
|
572
836
|
end
|
|
573
837
|
|
|
574
|
-
#
|
|
575
|
-
# (what's stored
|
|
576
|
-
#
|
|
838
|
+
# What actually happened, with payloads — distinct from `state`
|
|
839
|
+
# (what's stored now) and `history` (append-only operation
|
|
840
|
+
# snapshots, no payload). Not a domain-wide event-sourcing replay:
|
|
577
841
|
# "one boot per call" means `runtime.events` is always empty except
|
|
578
842
|
# during the very call that populated it, discarded the moment that
|
|
579
843
|
# call returns — there is no cross-call in-memory log to read here.
|
|
580
|
-
# So this reads the
|
|
844
|
+
# So this reads the same durable audit log `follow` already tails
|
|
581
845
|
# (`record!` now stamps a successful dispatch's own announced
|
|
582
846
|
# events onto its log line), reshaped: `follow` answers "what was
|
|
583
|
-
#
|
|
584
|
-
# to one aggregate/record" — events
|
|
847
|
+
# called, in order, across every tool"; this answers "what happened
|
|
848
|
+
# to one aggregate/record" — events this bus witnessed, which is
|
|
585
849
|
# every real dispatch ever routed through it, but no more than that.
|
|
586
850
|
# `aggregate:` narrows to one aggregate; `id:` (requires
|
|
587
851
|
# `aggregate:` — an id alone is not unique across aggregates)
|
|
588
852
|
# narrows to one record's own events.
|
|
853
|
+
#
|
|
854
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted runtime
|
|
855
|
+
# @param aggregate [String, Symbol, nil] narrows to one aggregate's own events
|
|
856
|
+
# @param id [String, Object, nil] narrows to one record's own events; requires
|
|
857
|
+
# `aggregate`
|
|
858
|
+
# @param limit [Integer, #to_i, nil] how many of the most recent matching events
|
|
859
|
+
# to return; nil returns every one found
|
|
860
|
+
# @return [Hash{Symbol => Object}] `:ok`, `:domain` and `:events` (each with
|
|
861
|
+
# `:name`, `:payload`, `:time`, `:verb` and `:id`); or the shape `refused` returns
|
|
862
|
+
# @raise [Runtime::TypeMismatch] if `id` is given without `aggregate`
|
|
863
|
+
# @raise [Runtime::NotFound] if `aggregate` names no known aggregate
|
|
589
864
|
def events(runtime:, aggregate: nil, id: nil, limit: nil)
|
|
590
865
|
raise Runtime::TypeMismatch, "id: requires aggregate: too — an id alone is not unique across aggregates" if id && !aggregate
|
|
591
866
|
|
|
@@ -606,6 +881,14 @@ module Hecks
|
|
|
606
881
|
# self-contained per-entry check with nothing to share with its
|
|
607
882
|
# neighbors, extracted only to keep `events` itself to the query's
|
|
608
883
|
# own shape: build the filter, apply the limit, wrap the result.
|
|
884
|
+
#
|
|
885
|
+
# @param entry [Hash{Symbol => Object}] one logged audit entry
|
|
886
|
+
# @param fqn [String, nil] narrows to a dispatch whose verb starts with this
|
|
887
|
+
# aggregate FQN; nil matches any aggregate
|
|
888
|
+
# @param id [String, Object, nil] narrows to a dispatch whose settled record has
|
|
889
|
+
# this id; nil matches any record
|
|
890
|
+
# @return [Array<Hash>, nil] `entry`'s own events (each with `:time`, `:verb` and
|
|
891
|
+
# `:id` merged in), or nil if `entry` isn't a matching successful dispatch
|
|
609
892
|
def entry_events(entry, fqn, id)
|
|
610
893
|
return unless entry[:tool] == "dispatch" && entry[:ok] && entry[:events]
|
|
611
894
|
return if fqn && !entry[:verb].to_s.start_with?("#{fqn}.")
|
|
@@ -616,24 +899,37 @@ module Hecks
|
|
|
616
899
|
|
|
617
900
|
# ── shared shape ────────────────────────────────────────────────────
|
|
618
901
|
|
|
619
|
-
# `Runtime::WiringError`
|
|
620
|
-
# refusals — not because it
|
|
902
|
+
# `Runtime::WiringError` belongs here too, alongside the true domain
|
|
903
|
+
# refusals — not because it is one (it's a structural defect, not a
|
|
621
904
|
# rule the caller broke), but because "this domain isn't wired to
|
|
622
905
|
# answer what you're asking" (a `role:`+`actor_id:` caller reaching
|
|
623
906
|
# an authorization port nothing implements, a dry run against a
|
|
624
907
|
# port verb) is exactly the shape this bus promises never crashes
|
|
625
908
|
# through it. `dry_run_outcome` already treats it this way locally;
|
|
626
|
-
# this makes every
|
|
909
|
+
# this makes every other caller of `refusal_classes` do the same.
|
|
910
|
+
#
|
|
911
|
+
# @return [Array<Class>] every exception class this bus catches and reshapes
|
|
912
|
+
# into an honest refusal rather than letting crash through
|
|
627
913
|
def refusal_classes = [Runtime::NotFound, Runtime::TypeMismatch, Runtime::WiringError, *Runtime::DOMAIN_REFUSALS]
|
|
628
914
|
|
|
915
|
+
# Builds a successful outcome Hash.
|
|
916
|
+
#
|
|
917
|
+
# @param fields [Hash{Symbol => Object}] fields to merge in alongside `ok: true`
|
|
918
|
+
# @return [Hash{Symbol => Object}] `{ok: true}` merged with `fields`
|
|
629
919
|
def ok(**fields) = { ok: true }.merge(fields)
|
|
630
920
|
|
|
631
|
-
#
|
|
921
|
+
# Builds a refused outcome Hash from a caught exception.
|
|
922
|
+
#
|
|
923
|
+
# **An honest refusal, not a crash** — the survey's own item #9: "an
|
|
632
924
|
# explicit, structured refusal a caller can act on" rather than a
|
|
633
925
|
# stack trace an agent has to parse to find the one line that
|
|
634
926
|
# mattered. The domain's own refusal text travels verbatim
|
|
635
927
|
# (`RefusalWording` already renders every one of these to be read),
|
|
636
928
|
# this only wraps it consistently.
|
|
929
|
+
#
|
|
930
|
+
# @param error [StandardError] the caught exception, a member of `refusal_classes`
|
|
931
|
+
# @param summary [String, nil] the caller's one-line summary
|
|
932
|
+
# @return [Hash{Symbol => Object}] `{ok: false, summary:, error:}`
|
|
637
933
|
def refused(error, summary: nil) = { ok: false, summary: summary, error: error.message }
|
|
638
934
|
end
|
|
639
935
|
end
|