hecks 0.2.0 → 0.3.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 +5 -5
- data/lib/hecks/adapters/driven/claude_code.adapter +3 -0
- data/lib/hecks/adapters/driven/claude_code.rb +127 -0
- data/lib/hecks/adapters/driven/d1.adapter +12 -0
- data/lib/hecks/adapters/driven/d1.rb +403 -0
- data/lib/hecks/adapters/driven/folder.adapter +3 -0
- data/lib/hecks/adapters/driven/folder.rb +199 -0
- data/lib/hecks/adapters/driven/google_authentication.adapter +3 -0
- data/lib/hecks/adapters/driven/google_authentication.rb +100 -0
- data/lib/hecks/adapters/driven/governance_authorization.adapter +3 -0
- data/lib/hecks/adapters/driven/governance_authorization.rb +62 -0
- data/lib/hecks/adapters/driven/heki/journal.rb +51 -0
- data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
- data/lib/hecks/adapters/driven/heki/snapshot.rb +38 -0
- data/lib/hecks/adapters/driven/heki.adapter +4 -0
- data/lib/hecks/adapters/driven/heki.rb +148 -0
- data/lib/hecks/adapters/driven/identity_registry.adapter +3 -0
- data/lib/hecks/adapters/driven/identity_registry.rb +26 -0
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +51 -0
- data/lib/hecks/adapters/driven/lambda/client.rb +63 -0
- data/lib/hecks/adapters/driven/lambda.adapter +4 -0
- data/lib/hecks/adapters/driven/lambda.rb +131 -0
- data/lib/hecks/adapters/driven/memory.adapter +3 -0
- data/lib/hecks/adapters/driven/memory.rb +103 -0
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +3 -0
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +28 -0
- data/lib/hecks/adapters/driven/postgres/codec.rb +80 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +168 -0
- data/lib/hecks/adapters/driven/postgres.adapter +5 -0
- data/lib/hecks/adapters/driven/postgres.rb +391 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage/era_store.rb +171 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage/field_cache.rb +190 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage/head_compiler.rb +429 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage/mint_transaction.rb +166 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage/provisioning.rb +286 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage/resumable_backfill.rb +168 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage/tail_merge.rb +163 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage/transform_installer.rb +114 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage.rb +137 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage_manager/coverage_check.rb +89 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage_manager/era_resolver.rb +82 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage_manager/merge_coordinator.rb +43 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage_manager/minter.rb +132 -0
- data/lib/hecks/adapters/driven/postgres_era/lineage_manager.rb +74 -0
- data/lib/hecks/adapters/driven/postgres_era.adapter +6 -0
- data/lib/hecks/adapters/driven/postgres_era.rb +671 -0
- data/lib/hecks/adapters/driven/prism.adapter +3 -0
- data/lib/hecks/adapters/driven/prism.rb +80 -0
- data/lib/hecks/adapters/driven/secure_random_identity.adapter +3 -0
- data/lib/hecks/adapters/driven/secure_random_identity.rb +14 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +221 -0
- data/lib/hecks/adapters/driven/sqlite/codec.rb +63 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +105 -0
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +180 -0
- data/lib/hecks/adapters/driven/sqlite.adapter +9 -0
- data/lib/hecks/adapters/driven/sqlite.rb +285 -0
- data/lib/hecks/adapters/driven/system_clock.adapter +3 -0
- data/lib/hecks/adapters/driven/system_clock.rb +14 -0
- data/lib/hecks/adapters/driven.rb +36 -0
- data/lib/hecks/adapters.rb +6 -0
- data/lib/hecks/behaviors/dsl.rb +96 -0
- data/lib/hecks/behaviors/expectations.rb +280 -0
- data/lib/hecks/behaviors/ir.rb +31 -0
- data/lib/hecks/behaviors/rspec.rb +42 -0
- data/lib/hecks/behaviors/runner.rb +96 -0
- data/lib/hecks/behaviors.rb +25 -0
- data/lib/hecks/bluebook/aggregate.rb +108 -0
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +134 -0
- data/lib/hecks/bluebook/assembly/build.rb +48 -0
- data/lib/hecks/bluebook/assembly/contract.rb +112 -0
- data/lib/hecks/bluebook/assembly/contracts.rb +409 -0
- data/lib/hecks/bluebook/assembly/marks.rb +227 -0
- data/lib/hecks/bluebook/assembly/specializer.rb +70 -0
- data/lib/hecks/bluebook/assembly.rb +78 -0
- data/lib/hecks/bluebook/attribute.rb +96 -0
- data/lib/hecks/bluebook/behaviour/aggregate.rb +83 -0
- data/lib/hecks/bluebook/behaviour/attribute.rb +27 -0
- data/lib/hecks/bluebook/behaviour/chapter.rb +72 -0
- data/lib/hecks/bluebook/behaviour/command.rb +116 -0
- data/lib/hecks/bluebook/behaviour/domain_port.rb +25 -0
- data/lib/hecks/bluebook/behaviour/entity.rb +59 -0
- data/lib/hecks/bluebook/behaviour/hexagon.rb +50 -0
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +51 -0
- data/lib/hecks/bluebook/behaviour/policy.rb +52 -0
- data/lib/hecks/bluebook/behaviour/process_manager.rb +38 -0
- data/lib/hecks/bluebook/behaviour/query.rb +10 -0
- data/lib/hecks/bluebook/behaviour/read_model.rb +29 -0
- data/lib/hecks/bluebook/behaviour/traits.rb +81 -0
- data/lib/hecks/bluebook/behaviour/value_object.rb +33 -0
- data/lib/hecks/bluebook/chapter.rb +68 -0
- data/lib/hecks/bluebook/command.rb +124 -0
- data/lib/hecks/bluebook/domain_port.rb +102 -0
- data/lib/hecks/bluebook/dsl/adapter_builder.rb +34 -0
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +906 -0
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +327 -0
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +71 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +1018 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +626 -0
- data/lib/hecks/bluebook/dsl/const_shim.rb +81 -0
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +79 -0
- data/lib/hecks/bluebook/dsl/entity_builder.rb +305 -0
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +366 -0
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +163 -0
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +191 -0
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +44 -0
- data/lib/hecks/bluebook/dsl/malformed.rb +7 -0
- data/lib/hecks/bluebook/dsl/policy_builder.rb +120 -0
- data/lib/hecks/bluebook/dsl/port_builder.rb +30 -0
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +142 -0
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +207 -0
- data/lib/hecks/bluebook/dsl/query_builder.rb +113 -0
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +233 -0
- data/lib/hecks/bluebook/dsl/rule_reference.rb +172 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +243 -0
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +178 -0
- data/lib/hecks/bluebook/dsl/word_gate.rb +221 -0
- data/lib/hecks/bluebook/dsl/world_builder.rb +77 -0
- data/lib/hecks/bluebook/dsl.rb +45 -0
- data/lib/hecks/bluebook/entity.rb +103 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +55 -0
- data/lib/hecks/bluebook/expression/evaluator.rb +261 -0
- data/lib/hecks/bluebook/expression/projection.json +170 -0
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +233 -0
- data/lib/hecks/bluebook/expression/resolver.rb +630 -0
- data/lib/hecks/bluebook/expression.rb +13 -0
- data/lib/hecks/bluebook/hexagon.rb +60 -0
- data/lib/hecks/bluebook/lifecycle.rb +42 -0
- data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +54 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +559 -0
- data/lib/hecks/bluebook/meta_validator/plan.rb +332 -0
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +47 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +350 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +312 -0
- data/lib/hecks/bluebook/meta_validator/shapes.rb +246 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +255 -0
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +138 -0
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +78 -0
- data/lib/hecks/bluebook/meta_validator.rb +452 -0
- data/lib/hecks/bluebook/model_check.rb +318 -0
- data/lib/hecks/bluebook/pattern_subset.rb +120 -0
- data/lib/hecks/bluebook/policy.rb +41 -0
- data/lib/hecks/bluebook/process_manager.rb +86 -0
- data/lib/hecks/bluebook/project_discovery.rb +30 -0
- data/lib/hecks/bluebook/project_loader.rb +40 -0
- data/lib/hecks/bluebook/project_register.rb +82 -0
- data/lib/hecks/bluebook/query.rb +61 -0
- data/lib/hecks/bluebook/read_model.rb +109 -0
- data/lib/hecks/bluebook/reference.rb +74 -0
- data/lib/hecks/bluebook/smoke_test.rb +166 -0
- data/lib/hecks/bluebook/synthesizer.rb +95 -0
- data/lib/hecks/bluebook/translation.rb +92 -0
- data/lib/hecks/bluebook/value_object.rb +58 -0
- data/lib/hecks/bluebook.rb +74 -0
- data/lib/hecks/codemod.rb +342 -0
- data/lib/hecks/construct.rb +71 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +219 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +4 -0
- data/lib/hecks/deploy/oidc.json +18 -0
- data/lib/hecks/doc/reference.rb +389 -0
- data/lib/hecks/embryonaut_bluebook.rb +75 -0
- data/lib/hecks/facade/cli_door.rb +116 -0
- data/lib/hecks/facade/cli_runner.rb +190 -0
- data/lib/hecks/facade/command_request.rb +105 -0
- data/lib/hecks/facade/handle.rb +173 -0
- data/lib/hecks/facade/json_door.rb +154 -0
- data/lib/hecks/facade/surface/aggregate_door.rb +185 -0
- data/lib/hecks/facade/surface/chapter.rb +107 -0
- data/lib/hecks/facade/surface.rb +48 -0
- data/lib/hecks/facade.rb +44 -0
- data/lib/hecks/forms/app.rb +300 -0
- data/lib/hecks/forms/command_form_renderer.rb +113 -0
- data/lib/hecks/forms/examples/banking_console.bluebook +3 -0
- data/lib/hecks/forms/field_renderer.rb +170 -0
- data/lib/hecks/forms/field_shape.rb +232 -0
- data/lib/hecks/forms/html.rb +53 -0
- data/lib/hecks/forms/index_renderer.rb +35 -0
- data/lib/hecks/forms/page.rb +157 -0
- data/lib/hecks/forms/params.rb +132 -0
- data/lib/hecks/forms/query_form_renderer.rb +114 -0
- data/lib/hecks/forms/record_renderer.rb +115 -0
- data/lib/hecks/forms/record_table.rb +63 -0
- data/lib/hecks/forms/reference_options.rb +30 -0
- data/lib/hecks/forms/value_object_shape.rb +46 -0
- data/lib/hecks/forms.rb +54 -0
- data/lib/hecks/fqn.rb +94 -0
- data/lib/hecks/framework/bluebook/compliance.bluebook +1 -0
- data/lib/hecks/framework/bluebook/console_settings.bluebook +489 -0
- data/lib/hecks/framework/bluebook/framework.hecksagon +32 -0
- data/lib/hecks/framework/bluebook/governance.bluebook +130 -0
- data/lib/hecks/framework/bluebook/identity.bluebook +90 -0
- data/lib/hecks/framework/oidc.json +39 -0
- data/lib/hecks/framework.rb +90 -0
- data/lib/hecks/freezer.rb +67 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +86 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +92 -0
- data/lib/hecks/fuzzing/properties.rb +1146 -0
- data/lib/hecks/fuzzing/replay.rb +635 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +93 -0
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +54 -0
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +96 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +185 -0
- data/lib/hecks/fuzzing/sequence_generator.rb +130 -0
- data/lib/hecks/fuzzing/value_generator.rb +177 -0
- data/lib/hecks/fuzzing.rb +12 -0
- data/lib/hecks/grammar/evolve.rb +291 -0
- data/lib/hecks/grammar/expression.bluebook +417 -0
- data/lib/hecks/grammar/expression_operators.json +1152 -0
- data/lib/hecks/grammar/grammar.hecksagon +20 -0
- data/lib/hecks/grammar/oidc.json +69 -0
- data/lib/hecks/grammar/translation.bluebook +203 -0
- data/lib/hecks/grammar.rb +178 -0
- data/lib/hecks/ir.rb +126 -0
- data/lib/hecks/language/adapter.bluebook +116 -0
- data/lib/hecks/language/bluebook/aggregate.bluebook +581 -0
- data/lib/hecks/language/bluebook/attaches/paging.bluebook +76 -0
- data/lib/hecks/language/bluebook/bluebook.bluebook +256 -0
- data/lib/hecks/language/bluebook/bluebook.hecksagon +24 -0
- data/lib/hecks/language/bluebook/command.bluebook +437 -0
- data/lib/hecks/language/bluebook/entity.bluebook +316 -0
- data/lib/hecks/language/bluebook/policy.bluebook +172 -0
- data/lib/hecks/language/bluebook/process_manager.bluebook +264 -0
- data/lib/hecks/language/bluebook/projection.bluebook +267 -0
- data/lib/hecks/language/bluebook/query.bluebook +245 -0
- data/lib/hecks/language/bluebook/shape.bluebook +292 -0
- data/lib/hecks/language/bluebook/syntax.bluebook +447 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +364 -0
- data/lib/hecks/language/hecksagon/adapter_binding.bluebook +51 -0
- data/lib/hecks/language/hecksagon/domain_port.bluebook +76 -0
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +131 -0
- data/lib/hecks/language/hecksagon/port_operation.bluebook +102 -0
- data/lib/hecks/language/oidc.json +313 -0
- data/lib/hecks/language/port.bluebook +92 -0
- data/lib/hecks/language/translation/translation.bluebook +110 -0
- data/lib/hecks/language/translation/translation_aggregate.bluebook +267 -0
- data/lib/hecks/language/world/wiring.bluebook +62 -0
- data/lib/hecks/language/world/world.bluebook +84 -0
- data/lib/hecks/literal.rb +125 -0
- data/lib/hecks/naming.rb +121 -0
- data/lib/hecks/ports/access_control.port +4 -0
- data/lib/hecks/ports/access_control.rb +62 -0
- data/lib/hecks/ports/agent/answers.rb +104 -0
- data/lib/hecks/ports/agent.port +4 -0
- data/lib/hecks/ports/agent.rb +167 -0
- data/lib/hecks/ports/authentication.port +4 -0
- data/lib/hecks/ports/authentication.rb +50 -0
- data/lib/hecks/ports/authorization.port +4 -0
- data/lib/hecks/ports/authorization.rb +59 -0
- data/lib/hecks/ports/clock.port +4 -0
- data/lib/hecks/ports/clock.rb +62 -0
- data/lib/hecks/ports/extraction.port +4 -0
- data/lib/hecks/ports/extraction.rb +37 -0
- data/lib/hecks/ports/identity_assignment.port +4 -0
- data/lib/hecks/ports/identity_assignment.rb +45 -0
- data/lib/hecks/ports/identity_generation.port +4 -0
- data/lib/hecks/ports/identity_generation.rb +49 -0
- data/lib/hecks/ports/identity_resolution.port +4 -0
- data/lib/hecks/ports/identity_resolution.rb +40 -0
- data/lib/hecks/ports/loading.port +4 -0
- data/lib/hecks/ports/loading.rb +13 -0
- data/lib/hecks/ports/persistence/append_only.rb +102 -0
- data/lib/hecks/ports/persistence/binding_policy.rb +56 -0
- data/lib/hecks/ports/persistence/execution.rb +18 -0
- data/lib/hecks/ports/persistence/lineage.rb +277 -0
- data/lib/hecks/ports/persistence/null_saga_store.rb +25 -0
- data/lib/hecks/ports/persistence/remote_runtime.rb +42 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +30 -0
- data/lib/hecks/ports/persistence.port +4 -0
- data/lib/hecks/ports/persistence.rb +39 -0
- data/lib/hecks/ports/projection.port +4 -0
- data/lib/hecks/ports/projection.rb +76 -0
- data/lib/hecks/ports/query/in_memory.rb +60 -0
- data/lib/hecks/ports/query/ordering.rb +41 -0
- data/lib/hecks/ports/query.rb +36 -0
- data/lib/hecks/ports.rb +27 -0
- data/lib/hecks/projections/diagrams.rb +413 -0
- data/lib/hecks/projections/ir.rb +18 -0
- data/lib/hecks/projections/model/deviations.rb +98 -0
- data/lib/hecks/projections/model.rb +145 -0
- data/lib/hecks/projections/oidc.rb +110 -0
- data/lib/hecks/projections/parser_table.rb +159 -0
- data/lib/hecks/projections/reference.rb +38 -0
- data/lib/hecks/projections/shape.rb +31 -0
- data/lib/hecks/projections/statements.rb +110 -0
- data/lib/hecks/projections/vocabulary.rb +100 -0
- data/lib/hecks/projections.rb +32 -0
- data/lib/hecks/projector/cli_projector.rb +437 -0
- data/lib/hecks/projector/docs_projector.rb +321 -0
- data/lib/hecks/projector/exporter.rb +141 -0
- data/lib/hecks/projector/ir_projector.rb +18 -0
- data/lib/hecks/projector/narrate_projector.rb +243 -0
- data/lib/hecks/projector/target.rb +97 -0
- data/lib/hecks/projector.rb +186 -0
- data/lib/hecks/query_ir.rb +392 -0
- data/lib/hecks/query_specification/common/authorization_spec.rb +9 -0
- data/lib/hecks/query_specification/common/comparators.rb +25 -0
- data/lib/hecks/query_specification/common/comparison.rb +174 -0
- data/lib/hecks/query_specification/common/cursor_spec.rb +9 -0
- data/lib/hecks/query_specification/common/dsl.rb +58 -0
- data/lib/hecks/query_specification/common/inspection_spec.rb +9 -0
- data/lib/hecks/query_specification/common/limit_spec.rb +9 -0
- data/lib/hecks/query_specification/common/null_policy.rb +80 -0
- data/lib/hecks/query_specification/common/null_semantics.rb +11 -0
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -0
- data/lib/hecks/query_specification/common/options.rb +32 -0
- data/lib/hecks/query_specification/common/order_by.rb +9 -0
- data/lib/hecks/query_specification/common/specification.rb +10 -0
- data/lib/hecks/query_specification/common/where_clause.rb +9 -0
- data/lib/hecks/query_specification/field_path.rb +89 -0
- data/lib/hecks/query_specification/hop_path.rb +130 -0
- data/lib/hecks/query_specification/read_model/specification.rb +18 -0
- data/lib/hecks/query_specification.rb +14 -0
- data/lib/hecks/rendering.rb +48 -0
- data/lib/hecks/router/namespace_installer.rb +157 -0
- data/lib/hecks/router.rb +70 -0
- data/lib/hecks/runtime/caller.rb +50 -0
- data/lib/hecks/runtime/capability_graph.rb +44 -0
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +124 -0
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +248 -0
- data/lib/hecks/runtime/command_interpreter.rb +413 -0
- data/lib/hecks/runtime/command_rules/admissibility.rb +307 -0
- data/lib/hecks/runtime/command_rules/arithmetic.rb +254 -0
- data/lib/hecks/runtime/command_rules/authorization.rb +64 -0
- data/lib/hecks/runtime/command_rules/emission.rb +34 -0
- data/lib/hecks/runtime/command_rules/references.rb +181 -0
- data/lib/hecks/runtime/command_rules.rb +28 -0
- data/lib/hecks/runtime/dependency_planning.rb +231 -0
- data/lib/hecks/runtime/dispatcher.rb +286 -0
- data/lib/hecks/runtime/entity_element.rb +253 -0
- data/lib/hecks/runtime/entity_interpreter.rb +228 -0
- data/lib/hecks/runtime/era_check.rb +136 -0
- data/lib/hecks/runtime/era_guard/shape_diff.rb +128 -0
- data/lib/hecks/runtime/era_guard.rb +193 -0
- data/lib/hecks/runtime/era_tamper.rb +61 -0
- data/lib/hecks/runtime/errors.rb +100 -0
- data/lib/hecks/runtime/event.rb +51 -0
- data/lib/hecks/runtime/identity.rb +128 -0
- data/lib/hecks/runtime/instance.rb +120 -0
- data/lib/hecks/runtime/interpreting.rb +69 -0
- data/lib/hecks/runtime/loader.rb +143 -0
- data/lib/hecks/runtime/policy_interpreter.rb +366 -0
- data/lib/hecks/runtime/port_operation_interpreter.rb +210 -0
- data/lib/hecks/runtime/query_interpreter.rb +254 -0
- data/lib/hecks/runtime/reaction_invocation.rb +245 -0
- data/lib/hecks/runtime/read_model_interpreter.rb +266 -0
- data/lib/hecks/runtime/rebuild_sweep.rb +74 -0
- data/lib/hecks/runtime/reference_hop.rb +99 -0
- data/lib/hecks/runtime/refusal_wording.rb +124 -0
- data/lib/hecks/runtime/registry/saga_persistence.rb +70 -0
- data/lib/hecks/runtime/registry/verification.rb +158 -0
- data/lib/hecks/runtime/registry.rb +218 -0
- data/lib/hecks/runtime/remote_dispatcher.rb +143 -0
- data/lib/hecks/runtime/routing.rb +96 -0
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +97 -0
- data/lib/hecks/runtime/saga_interpreter.rb +301 -0
- data/lib/hecks/runtime/storage_shape.rb +120 -0
- data/lib/hecks/runtime/tenant_check.rb +84 -0
- data/lib/hecks/runtime/tenant_scope.rb +55 -0
- data/lib/hecks/runtime/value/admission.rb +112 -0
- data/lib/hecks/runtime/value/coercion.rb +500 -0
- data/lib/hecks/runtime/value/invariant_violation.rb +5 -0
- data/lib/hecks/runtime/value.rb +125 -0
- data/lib/hecks/runtime.rb +95 -0
- data/lib/hecks/translation/audit/approval_digest.rb +31 -0
- data/lib/hecks/translation/audit/layer_one.rb +37 -0
- data/lib/hecks/translation/audit/layer_two.rb +77 -0
- data/lib/hecks/translation/audit/unfed_report.rb +42 -0
- data/lib/hecks/translation/audit.rb +70 -0
- data/lib/hecks/translation/reattest.rb +72 -0
- data/lib/hecks/translation/rule_compiler.rb +120 -0
- data/lib/hecks/translation/scaffold/differ.rb +183 -0
- data/lib/hecks/translation/scaffold/renderer.rb +41 -0
- data/lib/hecks/translation/scaffold/writer.rb +28 -0
- data/lib/hecks/translation/scaffold.rb +30 -0
- data/lib/hecks/translation.rb +11 -0
- data/lib/hecks/version.rb +16 -0
- data/lib/hecks/vocabulary.rb +213 -0
- data/lib/hecks.rb +118 -6
- metadata +401 -222
- data/bin/hecks +0 -7
- data/bin/hecks-package +0 -65
- data/bin/hecks_console +0 -12
- data/bin/hecks_serverless +0 -6
- data/lib/cli/build.rb +0 -14
- data/lib/cli/command_runner.rb +0 -28
- data/lib/cli/console.rb +0 -10
- data/lib/cli/generate.rb +0 -37
- data/lib/cli/hecks-cli.rb +0 -27
- data/lib/cli/test.rb +0 -57
- data/lib/console/commands.rb +0 -8
- data/lib/console/hecks-console.rb +0 -1
- data/lib/packager/README.md +0 -0
- data/lib/packager/app_runner.rb +0 -21
- data/lib/packager/args.rb +0 -26
- data/lib/packager/compatibility/fixnum.rb +0 -6
- data/lib/packager/hecks.rb +0 -39
- data/lib/packager/query_runner.rb +0 -21
- data/lib/packager/resources/Dockerfile +0 -11
- data/lib/packager/resources/app_binary +0 -7
- data/lib/packager/resources/bundle_config +0 -3
- data/lib/packager/resources/traveling-ruby-20150715-2.2.2-linux-x86_64.tar.gz +0 -0
- data/lib/packager/resources/traveling-ruby-20150715-2.2.2-osx.tar.gz +0 -0
- data/lib/serverless/Domain +0 -32
- data/lib/serverless/cli.rb +0 -75
- data/lib/serverless/resources/command_name.js +0 -5
- data/lib/serverless/resources/environment.js +0 -7
- data/lib/serverless/resources/handler.js.tt +0 -28
- data/lib/serverless/resources/run_binary.js +0 -22
- data/lib/serverless/resources/serverless.yml +0 -20
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "../bluebook/model_check"
|
|
3
|
+
require_relative "../bluebook/meta_validator"
|
|
4
|
+
require_relative "../ports/query/in_memory"
|
|
5
|
+
require_relative "../query_specification/field_path"
|
|
6
|
+
require_relative "../runtime/value"
|
|
7
|
+
|
|
8
|
+
module Hecks
|
|
9
|
+
module Fuzzing
|
|
10
|
+
# Declared properties, checked over a REPLAYED history — the other
|
|
11
|
+
# half of property-based testing the fuzzer was missing: it already
|
|
12
|
+
# generates and (with bin/fuzz's shrinker) minimizes, but checked
|
|
13
|
+
# nothing beyond "did the interpreter crash" and "did the replay
|
|
14
|
+
# match the claim." A property here is a fact that should hold of
|
|
15
|
+
# ANY history a valid domain produces, independent of which seed
|
|
16
|
+
# produced it.
|
|
17
|
+
#
|
|
18
|
+
# Each property is `name => ->(history) { true/false, or a message
|
|
19
|
+
# string naming what broke }` — a truthy return (including `true`)
|
|
20
|
+
# is a pass; a String return is a failure, and the string IS the
|
|
21
|
+
# finding. `history` is Replay's return shape.
|
|
22
|
+
#
|
|
23
|
+
# EVERY PROPERTY DECLARES THE LANGUAGE FEATURE IT COVERS, in
|
|
24
|
+
# `FEATURE_COVERAGE` below — a "Construct#attribute" pair spelled
|
|
25
|
+
# exactly as `Bluebook::MetaValidator.grammar_registry` names it,
|
|
26
|
+
# the SAME meta-domain that judges every real bluebook (see that
|
|
27
|
+
# module's own header: "the language IS the source"). That is the
|
|
28
|
+
# link this file exists to make real: a construct the language
|
|
29
|
+
# declares is a fact `spec/meta_domain_coverage_spec.rb` can
|
|
30
|
+
# enumerate on its own, without anyone re-typing the list here —
|
|
31
|
+
# so a new attribute added to `language/bluebook/*.bluebook` shows
|
|
32
|
+
# up in that spec as UNCLAIMED the moment it lands, not whenever
|
|
33
|
+
# someone remembers to go looking. Claiming a feature here is a
|
|
34
|
+
# deliberate act (a real property, checked at least once failing
|
|
35
|
+
# AND once passing — `spec/fuzzing/properties_spec.rb`'s own
|
|
36
|
+
# discipline) or an explicit, reasoned exemption in that same
|
|
37
|
+
# spec — never silence.
|
|
38
|
+
module Properties
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# WHICH LANGUAGE FEATURE EACH PROPERTY IS ANSWERABLE FOR. Not
|
|
42
|
+
# exhaustive of everything a property's body happens to touch —
|
|
43
|
+
# `Command#attributes`, say, is exercised by nearly every property
|
|
44
|
+
# here without being what any of them was WRITTEN to guard — but
|
|
45
|
+
# exhaustive of the feature that would go UNCHECKED if this
|
|
46
|
+
# property did not exist. That is the question the coverage gate
|
|
47
|
+
# actually asks.
|
|
48
|
+
FEATURE_COVERAGE = {
|
|
49
|
+
lifecycle_values_are_declared: %w[Aggregate#state_field Aggregate#state_start Aggregate#transitions
|
|
50
|
+
Entity#state_field Entity#state_start Entity#transitions],
|
|
51
|
+
saga_advances_follow_declared_handlers: %w[Handler#from_state Handler#to_state Handler#event_type],
|
|
52
|
+
query_answers_match_reference: %w[Query#wheres Query#order_field Query#order_way Query#limit],
|
|
53
|
+
paging_offset_partitions_correctly: %w[Query#options],
|
|
54
|
+
authorize_scopes_or_refuses: %w[Query#options],
|
|
55
|
+
guard_refusals_are_declared: %w[Command#givens Command#ensures],
|
|
56
|
+
lifecycle_guard_and_given_violations_are_refused: %w[Command#from Aggregate#preconditions Entity#preconditions],
|
|
57
|
+
# Dispatch#command_name/Dispatch#with_spec are NOT claimable
|
|
58
|
+
# feature names — META_DOMAIN_ALL_FEATURES only walks ONE level
|
|
59
|
+
# of entity nesting (`agg.entities.flat_map`, meta_domain_
|
|
60
|
+
# coverage_spec.rb), and Dispatch sits TWO deep (ProcessManager
|
|
61
|
+
# -> Handler -> Dispatch), so those strings never exist there
|
|
62
|
+
# to claim — a pre-existing meta-domain coverage-generation gap,
|
|
63
|
+
# found here (their old META_DOMAIN_KNOWN_GAPS entries were
|
|
64
|
+
# themselves already-orphaned strings no completeness check ever
|
|
65
|
+
# verified, since KNOWN_GAPS has no "never lets a gap rot" check
|
|
66
|
+
# the way FEATURE_COVERAGE/GUARANTEED_BY_CONSTRUCTION both do).
|
|
67
|
+
# This property still closes the REAL behavior both would have
|
|
68
|
+
# named — a Dispatch's own command_name/with_spec are exactly
|
|
69
|
+
# what dispatch_args resolves and this property checks — the
|
|
70
|
+
# grammar just has no feature string for either one.
|
|
71
|
+
dispatch_binding_fidelity: %w[Handler#dispatches Policy#with_spec],
|
|
72
|
+
mutations_match_recompute: %w[Command#mutations],
|
|
73
|
+
sagas_rehydrate_cleanly: %w[ProcessManager#states ProcessManager#correlates_by
|
|
74
|
+
ProcessManager#starts_on ProcessManager#ends_on],
|
|
75
|
+
fanout_dispatches_once_per_matching_row: %w[Policy#for_each Policy#where],
|
|
76
|
+
aggregation_matches_recompute: %w[ReadModel#count ReadModel#median_field],
|
|
77
|
+
stored_records_satisfy_declared_invariants: %w[Aggregate#invariants Entity#invariants],
|
|
78
|
+
group_by_matches_recompute: %w[ReadModel#group_by]
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
# FEATURES A REPLAY PROPERTY COULD NEVER CATCH VIOLATED, because the
|
|
82
|
+
# RUNTIME'S OWN CONSTRUCTION makes the violation impossible to
|
|
83
|
+
# produce in the first place — not "untested," but unfalsifiable by
|
|
84
|
+
# a history, the same class of guarantee this codebase already
|
|
85
|
+
# states for identity ("NOTHING IS MINTED" — command_interpreter.rb's
|
|
86
|
+
# own header) and now generalises. Each entry names the ONE place in
|
|
87
|
+
# the runtime that makes it true, universally, for every domain and
|
|
88
|
+
# every adapter — never per-domain logic a future domain could
|
|
89
|
+
# accidentally route around.
|
|
90
|
+
#
|
|
91
|
+
# THE BLUEBOOK/HECKSAGON BOUNDARY IS WHY THIS WORKS: a bluebook
|
|
92
|
+
# declares SHAPE (attribute types, patterns, closed sets, VO
|
|
93
|
+
# invariants — see docs/decisions/0009), and shape is enforced by
|
|
94
|
+
# ONE coercion door every domain's every attribute passes through
|
|
95
|
+
# (`Runtime::Value.build`, via value/coercion.rb + value/admission.rb)
|
|
96
|
+
# regardless of which hecksagon later binds the aggregate to Memory,
|
|
97
|
+
# Postgres, or anything else. A value that violated its own declared
|
|
98
|
+
# pattern, invariant, or closed set could never be COERCED, so it
|
|
99
|
+
# could never be STORED, so it could never appear in a replay's own
|
|
100
|
+
# `:instances` to be caught violating it. Checking for it after the
|
|
101
|
+
# fact would be watching for something the construction path already
|
|
102
|
+
# made impossible.
|
|
103
|
+
#
|
|
104
|
+
# NOT a place to hide a real gap — a feature belongs here only once
|
|
105
|
+
# the SPECIFIC enforcing code path has been read and confirmed, the
|
|
106
|
+
# same discipline `spec/fuzzing/meta_domain_coverage_spec.rb` demands
|
|
107
|
+
# of `KNOWN_GAPS` in the other direction. `Entity#identified_by` was
|
|
108
|
+
# checked FOR this category once before and found NOT to qualify —
|
|
109
|
+
# `command_interpreter.rb`'s `AlreadyExists` refusal was given to
|
|
110
|
+
# every CREATING AGGREGATE command uniformly, and MutationApplier
|
|
111
|
+
# (command_interpreter/mutation_applier.rb) had no matching check on
|
|
112
|
+
# an entity's own append. It does now: #check_entity_collision runs
|
|
113
|
+
# unconditionally on both branches an entity identity can arrive by
|
|
114
|
+
# (caller-supplied, or composite — the two the auto-mint branch
|
|
115
|
+
# doesn't cover), the same way command_interpreter#hydrate's own
|
|
116
|
+
# check is unconditional for every creating aggregate command. Real,
|
|
117
|
+
# confirmed live before the fix (SafeDepositBox's Visit/KeyIssuance —
|
|
118
|
+
# see spec/runtime/safe_deposit_box_spec.rb).
|
|
119
|
+
GUARANTEED_BY_CONSTRUCTION = {
|
|
120
|
+
"Aggregate#attributes" => "every field's pattern/closed-set/type passes through Value.build's one coercion door " \
|
|
121
|
+
"(value/coercion.rb#check_patterns, value/admission.rb) before it can exist — a stored value that violated " \
|
|
122
|
+
"its own declared shape was never producible to begin with",
|
|
123
|
+
"Aggregate#value_objects" => "the shape being coerced above — same door, same guarantee",
|
|
124
|
+
# S17, ADR 0026 — the list `saga_advances_follow_declared_handlers`
|
|
125
|
+
# (below) already walks to find each handler's own event_type/
|
|
126
|
+
# from_state/to_state (the three it claims) — a property cannot
|
|
127
|
+
# check a handler's own fields without iterating the list that
|
|
128
|
+
# holds them, so the list itself is exercised by the same door.
|
|
129
|
+
"ProcessManager#handlers" => "saga_advances_follow_declared_handlers already walks this list to find event_type/from_state/to_state — same door, same guarantee",
|
|
130
|
+
"Aggregate#identified_by" => "CommandInterpreter's data-driven dispatch order refuses AlreadyExists " \
|
|
131
|
+
"(command_interpreter.rb, command.creates?) for every creating command uniformly, before a duplicate id " \
|
|
132
|
+
"can ever be stored — collision is refused at the door, not produced and later caught",
|
|
133
|
+
"Entity#identified_by" => "MutationApplier#check_entity_collision (command_interpreter/mutation_applier.rb) " \
|
|
134
|
+
"checks Array(current) against every part of the entity's own identity before an append can land, on " \
|
|
135
|
+
"both branches identity arrives by (caller-supplied, or composite) — the same AlreadyExists refusal " \
|
|
136
|
+
"Aggregate#identified_by gets above, one level down. Auto-minted entities never reach the check " \
|
|
137
|
+
"(current.size + 1 can't repeat unless something remove:s from the list between mints, which no real " \
|
|
138
|
+
"domain does today — see the comment on #entity_element itself)",
|
|
139
|
+
"Command#attributes" => "command arguments are coerced through the SAME Value.build door as any other " \
|
|
140
|
+
"attribute — an accepted dispatch's own args already passed pattern/admits/invariant checks",
|
|
141
|
+
"Command#emits" => "CommandRules::Emission#emit iterates command.emits ITSELF to construct every announced " \
|
|
142
|
+
"Event (command_rules/emission.rb) — there is no other path to emit, so a command can never announce a " \
|
|
143
|
+
"name its own declaration doesn't list",
|
|
144
|
+
"Query#attributes" => "query arguments are coerced through the same Value.build door — same guarantee as " \
|
|
145
|
+
"Command#attributes",
|
|
146
|
+
"Entity#attributes" => "same coercion door, one level in — an entity's own attributes are Value-typed exactly " \
|
|
147
|
+
"the way an aggregate's are",
|
|
148
|
+
"ValueObject#attributes" => "the shape Value.build enforces IS this declaration — the guarantee and the " \
|
|
149
|
+
"feature are the same fact seen from two sides",
|
|
150
|
+
"ValueObject#invariants" => "run inside the SAME coercion call (coercion.rb, before construction returns) " \
|
|
151
|
+
"that pattern-checks a VO's fields — a VO whose invariant did not hold could not finish being built",
|
|
152
|
+
"ValueObject#rows" => "closed-set membership is checked in value/admission.rb, the second half of the same " \
|
|
153
|
+
"one construction door",
|
|
154
|
+
# S17, ADR 0026 — Member is a genuine entity now (nested under
|
|
155
|
+
# ValueObject), so this reads "Member#pairs", not "Member#shape" —
|
|
156
|
+
# the free-text, un-parsed spelling a standalone root once needed
|
|
157
|
+
# no longer exists at all, an entity's own element is never
|
|
158
|
+
# serialized as text. "ValueObject#members" is the SAME fact
|
|
159
|
+
# "ValueObject#rows" already counts, seen from the other side — a
|
|
160
|
+
# value object cannot declare admitted rows without a members list
|
|
161
|
+
# to hold them, and vice versa.
|
|
162
|
+
"ValueObject#members" => "the members list IS what ValueObject#rows counts — same door, same guarantee",
|
|
163
|
+
"Member#pairs" => "one level into ValueObject#rows — same door"
|
|
164
|
+
}.freeze
|
|
165
|
+
|
|
166
|
+
# Every lifecycle field a replay leaves an instance holding is one
|
|
167
|
+
# of the aggregate's OWN declared states — the full set, not just
|
|
168
|
+
# `Lifecycle#states`' default+targets (see ModelCheck.full_states'
|
|
169
|
+
# own comment on that hole). The tie to M2 is direct: the model
|
|
170
|
+
# checker proves which states a domain's OWN declarations can ever
|
|
171
|
+
# produce ; this proves a REAL RUN never produced anything else —
|
|
172
|
+
# a coercion bug, a stale string surviving a rename, a default
|
|
173
|
+
# that drifted from the declared set, would all show up here as a
|
|
174
|
+
# value nothing upstream would have predicted.
|
|
175
|
+
def lifecycle_values_are_declared(history)
|
|
176
|
+
bluebook = history.fetch(:bluebook)
|
|
177
|
+
declared = {}
|
|
178
|
+
bluebook.aggregates.each do |aggregate|
|
|
179
|
+
declared[aggregate.hecks_name] = Bluebook::ModelCheck.full_states(aggregate.lifecycle) if aggregate.lifecycle
|
|
180
|
+
end
|
|
181
|
+
return true if declared.empty?
|
|
182
|
+
|
|
183
|
+
offenders = history.fetch(:instances).filter_map do |key, state|
|
|
184
|
+
aggregate_name = key.split("::").last.split("#").first
|
|
185
|
+
states = declared[aggregate_name]
|
|
186
|
+
next unless states
|
|
187
|
+
|
|
188
|
+
lifecycle = bluebook.aggregate(aggregate_name).lifecycle
|
|
189
|
+
value = state[lifecycle.field]
|
|
190
|
+
next if value.nil? || states.include?(value.to_s)
|
|
191
|
+
|
|
192
|
+
"#{key} holds #{lifecycle.field}=#{value.inspect}, which #{aggregate_name} never declares as a state"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
offenders.empty? || offenders.join("; ")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Every saga advance a replay actually logged moved along an edge
|
|
199
|
+
# the process manager DECLARED — `(from, to)` pairs that appear in
|
|
200
|
+
# `saga_log` with `advanced: true` must be a `(handler.from_state,
|
|
201
|
+
# handler.to_state)` pair some handler on that PM declares
|
|
202
|
+
# (compensation edges included ; a REFUSED-triggered advance is a
|
|
203
|
+
# handler like any other). A saga that advanced along a pair no
|
|
204
|
+
# handler names would mean the runtime moved state the language
|
|
205
|
+
# never authorized — the same trust ModelCheck's static reachability
|
|
206
|
+
# rests on, checked here against what a run actually did.
|
|
207
|
+
def saga_advances_follow_declared_handlers(history)
|
|
208
|
+
bluebook = history.fetch(:bluebook)
|
|
209
|
+
edges = Hash.new { |h, k| h[k] = [] }
|
|
210
|
+
bluebook.process_managers.each do |pm|
|
|
211
|
+
pm.handlers.each { |handler| edges[pm.name] << [handler.from_state, handler.to_state] }
|
|
212
|
+
end
|
|
213
|
+
return true if edges.empty?
|
|
214
|
+
|
|
215
|
+
offenders = history.fetch(:sagas).filter_map do |entry|
|
|
216
|
+
next unless entry[:advanced]
|
|
217
|
+
|
|
218
|
+
pair = [entry[:from], entry[:to]]
|
|
219
|
+
next if edges[entry[:process_manager]].include?(pair)
|
|
220
|
+
|
|
221
|
+
"#{entry[:process_manager]} advanced #{pair.inspect}, which no declared handler names"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
offenders.empty? || offenders.join("; ")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# THE FOUNDATIONAL ONE. `Hecks::Runtime` mints nothing — every
|
|
228
|
+
# identity is declared and derived, never invented (see
|
|
229
|
+
# command_interpreter.rb's own "NOTHING IS MINTED" — a random hex,
|
|
230
|
+
# a counter, anything not reproducible from the payload, was
|
|
231
|
+
# refused out of the runtime specifically because it broke this).
|
|
232
|
+
# So the SAME steps, replayed against a FRESH boot, must produce
|
|
233
|
+
# BYTE-IDENTICAL events, refusals, and instances — any drift here
|
|
234
|
+
# is nondeterminism the runtime promised not to have: a wall-clock
|
|
235
|
+
# read that leaked into compared state, a Hash iteration order a
|
|
236
|
+
# comparison depended on, anything. Two independent replays, not a
|
|
237
|
+
# cached one compared to itself, so a bug that corrupts the FIRST
|
|
238
|
+
# run's own bookkeeping cannot pass by agreeing with itself.
|
|
239
|
+
def replay_is_deterministic(domain_path, steps)
|
|
240
|
+
first = Replay.call(domain_path, steps)
|
|
241
|
+
second = Replay.call(domain_path, steps)
|
|
242
|
+
|
|
243
|
+
comparable = ->(history) { history.reject { |key, _| key == :bluebook || key == :bluebooks } }
|
|
244
|
+
return true if comparable.call(first) == comparable.call(second)
|
|
245
|
+
|
|
246
|
+
"two replays of the same #{steps.length} steps produced different histories"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# THE QUERY ORACLE — differential testing within the one runtime,
|
|
250
|
+
# the shape the retired cross-runtime harness should always have
|
|
251
|
+
# been. Every generated ask was answered twice at the same instant
|
|
252
|
+
# (Replay records both): once through whatever the aggregate is
|
|
253
|
+
# actually bound to (Memory's native hook is Ports::Query::InMemory;
|
|
254
|
+
# a SQL binding would compile it), once through the reference
|
|
255
|
+
# interpreter's own evaluation. The two are separate, live
|
|
256
|
+
# implementations of the same comparator vocabulary, and they have
|
|
257
|
+
# drifted before — an adapter that ACCEPTS what the reference says
|
|
258
|
+
# matches nothing, or orders what it refuses to order, shows up
|
|
259
|
+
# here as a finding no self-referential adapter spec could see.
|
|
260
|
+
def query_answers_match_reference(history)
|
|
261
|
+
offenders = history.fetch(:queries).filter_map do |asked|
|
|
262
|
+
next if asked[:error] || asked[:reference_rows].nil?
|
|
263
|
+
next if asked[:rows] == asked[:reference_rows]
|
|
264
|
+
|
|
265
|
+
"#{asked[:query]} #{asked[:args].inspect} answered #{asked[:rows].inspect} " \
|
|
266
|
+
"natively but #{asked[:reference_rows].inspect} through the reference interpreter"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
offenders.empty? || offenders.join("; ")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# THE SAME "TWO ENGINES, COMPARED" SHAPE query_answers_match_reference
|
|
273
|
+
# already uses, aimed squarely at Query#options' offset/limit pair —
|
|
274
|
+
# but recomputed from history[:instances] directly, a THIRD,
|
|
275
|
+
# independent computation, rather than comparing QueryInterpreter's
|
|
276
|
+
# own native and reference paths against each other (which could
|
|
277
|
+
# share the identical bug neither implementation happened to hit —
|
|
278
|
+
# see #4's own fix, which touched BOTH #interpret and
|
|
279
|
+
# #reference_interpret at once). `order_by` declared alongside
|
|
280
|
+
# `offset` or `limit` names a genuinely paged query. Ports::Query::
|
|
281
|
+
# Ordering.apply is the SAME engine QueryInterpreter#ordered calls,
|
|
282
|
+
# reused here rather than re-derived, so this oracle cannot drift
|
|
283
|
+
# from what "in order" means without the interpreter drifting the
|
|
284
|
+
# identical way — only the offset-then-limit .drop/.first slice
|
|
285
|
+
# (#4's own fix) is independently reproduced, in plain Ruby.
|
|
286
|
+
#
|
|
287
|
+
# Real target: ATMCard.ByFee (`limit 3; offset 1`).
|
|
288
|
+
def paging_offset_partitions_correctly(history)
|
|
289
|
+
bluebooks = history.fetch(:bluebooks)
|
|
290
|
+
|
|
291
|
+
offenders = history.fetch(:queries).filter_map do |asked|
|
|
292
|
+
next if asked[:error] || !asked[:query].is_a?(String) || !asked[:query].include?("::")
|
|
293
|
+
|
|
294
|
+
declared = query_for_verb(bluebooks, asked[:query])
|
|
295
|
+
next unless declared && declared.order_by && (declared.offset || declared.limit)
|
|
296
|
+
|
|
297
|
+
domain, aggregate_name, = Naming.split_verb(asked[:query])
|
|
298
|
+
args = asked[:args] || {}
|
|
299
|
+
rows = query_eligible_rows(asked.fetch(:instances_at), domain, aggregate_name, declared.wheres, args)
|
|
300
|
+
ordered = Ports::Query::Ordering.apply(
|
|
301
|
+
rows, declared.order_by, declared.null_semantics, identity: ->(row) { row[:id].to_s }
|
|
302
|
+
) { |row| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, declared.order_by.field)) }
|
|
303
|
+
|
|
304
|
+
skipped = declared.offset ? ordered.drop(resolve_paging_value(declared.offset.value, args).to_i) : ordered
|
|
305
|
+
expected = declared.limit ? skipped.first(resolve_paging_value(declared.limit.value, args).to_i) : skipped
|
|
306
|
+
actual = asked[:rows]
|
|
307
|
+
next if actual == expected
|
|
308
|
+
|
|
309
|
+
"#{asked[:query]} #{args.inspect} answered #{actual.inspect}, but independently recomputing " \
|
|
310
|
+
"order/offset/limit from #{rows.length} eligible row(s) gives #{expected.inspect}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
offenders.empty? || offenders.join("; ")
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# THE DECLARED Query ITSELF, resolved from a replayed verb — the
|
|
317
|
+
# same shape #command_for_verb resolves a command by, one
|
|
318
|
+
# construct over. Entity-level queries (a dotted query_path) are
|
|
319
|
+
# out of scope here — paging on an entity's own list has no real
|
|
320
|
+
# corpus site yet, and the "one many-side head, one aggregate,
|
|
321
|
+
# no FK-join" shape #query_eligible_rows assumes doesn't hold for
|
|
322
|
+
# one.
|
|
323
|
+
def query_for_verb(bluebooks, verb)
|
|
324
|
+
domain, aggregate_name, query_path = Naming.split_verb(verb)
|
|
325
|
+
return nil unless query_path && !query_path.include?(".")
|
|
326
|
+
|
|
327
|
+
bluebook = bluebooks[domain]
|
|
328
|
+
aggregate = bluebook&.aggregate(aggregate_name)
|
|
329
|
+
aggregate&.query(query_path)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# A QUERY'S OWN ROWS — unlike #eligible_rows (a ReadModel's
|
|
333
|
+
# reduced/grouped many-side head, possibly FK-joined against a
|
|
334
|
+
# root), a Query always asks about its OWN owning aggregate
|
|
335
|
+
# directly ; no join, no reference_target. `id:` merged in the
|
|
336
|
+
# same way #eligible_rows' own rows are, since a stable sort
|
|
337
|
+
# (Ordering.apply's own `identity:`) and the real answer's own
|
|
338
|
+
# `record.state.merge(id: record.id)` both need it.
|
|
339
|
+
def query_eligible_rows(instances, domain, aggregate_name, wheres, args)
|
|
340
|
+
prefix = "#{domain}::#{aggregate_name}#"
|
|
341
|
+
instances.filter_map do |key, state|
|
|
342
|
+
next unless key.start_with?(prefix)
|
|
343
|
+
|
|
344
|
+
row = state.merge(id: key.split("#").last)
|
|
345
|
+
next unless wheres.all? do |clause|
|
|
346
|
+
held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, clause.field))
|
|
347
|
+
Ports::Query::InMemory.holds?(clause, held, args)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
row
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# `QueryInterpreter#resolve_query_value`, reproduced: a declared
|
|
355
|
+
# limit/offset is either a literal or a Symbol naming an argument
|
|
356
|
+
# the caller supplied.
|
|
357
|
+
def resolve_paging_value(value, args)
|
|
358
|
+
value.is_a?(Symbol) ? args[value] : value
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# `Query#options`' OTHER HALF — TenantScope.apply's own contract
|
|
362
|
+
# (tenant_scope.rb), independently restated as a property rather
|
|
363
|
+
# than exercised only through whatever the generator happens to
|
|
364
|
+
# try. NOT closed by the generator here on purpose: SafeDepositBox.
|
|
365
|
+
# Rented — the only real corpus query declaring `authorize` at
|
|
366
|
+
# all — declares ZERO attributes of its own, so StepBuilder#args_for
|
|
367
|
+
# always hands it `{}` and TenantScope.apply refuses every
|
|
368
|
+
# generated attempt, unconditionally (confirmed: no successful ask
|
|
369
|
+
# against an authorize-bearing query reaches this property via the
|
|
370
|
+
# standard battery today). Extending the generator to invent a
|
|
371
|
+
# `tenant:` value ran into a separate, real finding along the way —
|
|
372
|
+
# SafeDepositBox is COMPOSITE-identified (`identified_by` is nil
|
|
373
|
+
# for it — Runtime::Identified#derive_identity), so the generator's
|
|
374
|
+
# existing `known_ids` pool (keyed by `aggregate.identified_by ||
|
|
375
|
+
# "id"`) tracks a stray, never-real scalar for it rather than its
|
|
376
|
+
# true `branch_code`+`box_number` pair — a second, narrower
|
|
377
|
+
# generator gap this property does not attempt to fix, since fixing
|
|
378
|
+
# it well enough to trust a generated `tenant:` value would be the
|
|
379
|
+
# heavier, "benefits every future property" path the plan itself
|
|
380
|
+
# names as the alternative. Hand-built fixtures close the real
|
|
381
|
+
# claim directly instead: faster, narrower, and correct either way,
|
|
382
|
+
# since TenantScope.apply's contract is identical regardless of
|
|
383
|
+
# where a `tenant:` arg came from.
|
|
384
|
+
#
|
|
385
|
+
# Two claims, matching TenantScope.apply's own two branches: every
|
|
386
|
+
# SUCCESSFUL answer's own tenant field agrees with the tenant arg
|
|
387
|
+
# given (the WhereClause TenantScope injects is a Symbol reference
|
|
388
|
+
# into args, resolved dynamically — this checks the OUTCOME, not
|
|
389
|
+
# re-deriving that resolution) ; every ask MISSING a required
|
|
390
|
+
# tenant: refuses with the declared wording, never succeeds. A
|
|
391
|
+
# refusal for an unrelated reason with the tenant arg present is
|
|
392
|
+
# not this property's claim either way — skipped, not graded.
|
|
393
|
+
def authorize_scopes_or_refuses(history)
|
|
394
|
+
bluebooks = history.fetch(:bluebooks)
|
|
395
|
+
|
|
396
|
+
offenders = history.fetch(:queries).filter_map do |asked|
|
|
397
|
+
next unless asked[:query].is_a?(String) && asked[:query].include?("::")
|
|
398
|
+
|
|
399
|
+
declared = query_for_verb(bluebooks, asked[:query])
|
|
400
|
+
tenant = declared&.authorization&.tenant&.to_sym
|
|
401
|
+
next unless tenant
|
|
402
|
+
|
|
403
|
+
args = asked[:args] || {}
|
|
404
|
+
tenant_given = args.key?(tenant)
|
|
405
|
+
|
|
406
|
+
if asked[:error]
|
|
407
|
+
next if tenant_given
|
|
408
|
+
next if asked[:error].to_s.include?("declares authorize with tenant: #{tenant}")
|
|
409
|
+
|
|
410
|
+
"#{asked[:query]} #{args.inspect} refused with no #{tenant}: given, but not with the declared " \
|
|
411
|
+
"tenant_required wording (#{asked[:error]})"
|
|
412
|
+
elsif !tenant_given
|
|
413
|
+
"#{asked[:query]} #{args.inspect} answered successfully with no #{tenant}: given, but #{declared.name} " \
|
|
414
|
+
"declares authorize with tenant: #{tenant}"
|
|
415
|
+
else
|
|
416
|
+
wanted = args[tenant].to_s
|
|
417
|
+
mismatched = asked[:rows].find do |row|
|
|
418
|
+
Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, tenant)).to_s != wanted
|
|
419
|
+
end
|
|
420
|
+
next unless mismatched
|
|
421
|
+
|
|
422
|
+
"#{asked[:query]} #{args.inspect} answered a row whose #{tenant} disagrees with the given " \
|
|
423
|
+
"#{wanted.inspect}: #{mismatched.inspect}"
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
offenders.empty? || offenders.join("; ")
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# EVERY GIVEN/ENSURES REFUSAL A RUN ACTUALLY RAISED NAMES A RULE
|
|
431
|
+
# THE COMMAND ACTUALLY DECLARES. `GivenNotMet`/`EnsuresNotMet` both
|
|
432
|
+
# quote their guard's own `description` verbatim
|
|
433
|
+
# (command_rules/admissibility.rb: `"#{command.hecks_name} refused
|
|
434
|
+
# — #{given.description}"`) — the SAME text `behavior.bluebook`'s
|
|
435
|
+
# own `Rule`/Command.Ensure hold as `Rule#description`, so a
|
|
436
|
+
# refusal whose quoted text is not among the refusing command's
|
|
437
|
+
# OWN `guard_descriptions` (Behaviour::Command, both givens and
|
|
438
|
+
# ensures) is either a stale message surviving a renamed rule, a
|
|
439
|
+
# rule firing against the wrong command's own guard set, or the
|
|
440
|
+
# wording drifting out from under the declaration it is supposed
|
|
441
|
+
# to quote — banking's own 128 status givens (customer/account
|
|
442
|
+
# guards, some through a cross-aggregate dereference) are exactly
|
|
443
|
+
# the surface this exists to hold to its word.
|
|
444
|
+
#
|
|
445
|
+
# `kind:` is what tells a guard refusal apart from the FOUR other
|
|
446
|
+
# `RefusalWording` templates sharing the identical "X refused — Y"
|
|
447
|
+
# shape (LifecycleRefused/transition_blocked, both TypeMismatch
|
|
448
|
+
# object-reference templates, Unauthorized/role_mismatch) — see
|
|
449
|
+
# Replay's own comment at the refusal rescue site. Pattern-matching
|
|
450
|
+
# the string alone would confuse a guard's own wording with any of
|
|
451
|
+
# those; the raised class does not.
|
|
452
|
+
GUARD_REFUSAL_KINDS = %w[Hecks::Runtime::GivenNotMet Hecks::Runtime::EnsuresNotMet].freeze
|
|
453
|
+
|
|
454
|
+
def guard_refusals_are_declared(history)
|
|
455
|
+
bluebooks = history.fetch(:bluebooks)
|
|
456
|
+
|
|
457
|
+
offenders = history.fetch(:refusals).filter_map do |refusal|
|
|
458
|
+
next unless GUARD_REFUSAL_KINDS.include?(refusal[:kind])
|
|
459
|
+
|
|
460
|
+
match = refusal[:error].to_s.match(/\A(.+) refused — (.+)\z/)
|
|
461
|
+
next "#{refusal[:verb]} raised #{refusal[:kind]} with unparseable message #{refusal[:error].inspect}" unless match
|
|
462
|
+
|
|
463
|
+
command = command_for_verb(bluebooks, refusal[:verb])
|
|
464
|
+
next "#{refusal[:verb]} raised #{refusal[:kind]}, but no declared command resolves that verb" unless command
|
|
465
|
+
next if command.guard_descriptions.include?(match[2])
|
|
466
|
+
|
|
467
|
+
"#{refusal[:verb]} refused — #{match[2].inspect} — but #{command.hecks_name} declares no given " \
|
|
468
|
+
"or ensures with that description (it declares #{command.guard_descriptions.inspect})"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
offenders.empty? || offenders.join("; ")
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# A DECLARED PROCESS MANAGER'S OWN COMMAND — `command.hecks_name`,
|
|
475
|
+
# or an entity's own if the verb's second component is itself
|
|
476
|
+
# dotted (`Aggregate.Entity.Command`, the same two shapes
|
|
477
|
+
# `Dispatcher#dispatch` itself branches on). Shared by the guard
|
|
478
|
+
# property above and available for anything else that needs to go
|
|
479
|
+
# from a replayed verb back to its declaration.
|
|
480
|
+
#
|
|
481
|
+
# RESOLVED AGAINST `bluebooks` (the FULL map, `history[:bluebooks]`
|
|
482
|
+
# — every loaded domain, keyed by name), never a single assumed
|
|
483
|
+
# bluebook: a verb names its OWN domain (`Naming.split_verb`'s
|
|
484
|
+
# first element), and that domain is not always the one Replay
|
|
485
|
+
# happens to expose as `history[:bluebook]`. A fuzz run against
|
|
486
|
+
# `lib/hecks/grammar` (Expression + Translation, in load
|
|
487
|
+
# order) found this the hard way — every `Translation::Map.Seal`
|
|
488
|
+
# refusal read as "no declared command resolves that verb" purely
|
|
489
|
+
# because `history[:bluebook]` was Expression, not Translation; the
|
|
490
|
+
# refusal was real, this property's own domain resolution was not.
|
|
491
|
+
def command_for_verb(bluebooks, verb)
|
|
492
|
+
domain, aggregate_name, command_path = Naming.split_verb(verb)
|
|
493
|
+
return nil unless command_path
|
|
494
|
+
|
|
495
|
+
bluebook = bluebooks[domain]
|
|
496
|
+
return nil unless bluebook
|
|
497
|
+
|
|
498
|
+
aggregate = bluebook.aggregate(aggregate_name)
|
|
499
|
+
return nil unless aggregate
|
|
500
|
+
|
|
501
|
+
if command_path.include?(".")
|
|
502
|
+
entity_name, sub = command_path.split(".", 2)
|
|
503
|
+
entity = aggregate.entities.find { |e| e.hecks_name == entity_name }
|
|
504
|
+
entity&.command(sub)
|
|
505
|
+
else
|
|
506
|
+
aggregate.command(command_path)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# `guard_refusals_are_declared`'s OWN OPPOSITE DIRECTION. That
|
|
511
|
+
# property is passive and one-directional — for a refusal that
|
|
512
|
+
# ALREADY HAPPENED, is the quoted text real declared text? It says
|
|
513
|
+
# nothing about a guard that should have refused and silently did
|
|
514
|
+
# not — a call site that stopped calling enforce_givens/enforce_
|
|
515
|
+
# lifecycle_guard would never appear in history[:refusals] at all,
|
|
516
|
+
# invisible to that property by construction.
|
|
517
|
+
#
|
|
518
|
+
# This one calls Admissibility#enforce_givens (which itself folds
|
|
519
|
+
# in #enforce_lifecycle_guard whenever `declaring:` is passed)
|
|
520
|
+
# DIRECTLY, against Replay's own pre-dispatch snapshot
|
|
521
|
+
# (history[:guard_checks], one bounded, additive extension — see
|
|
522
|
+
# that file's own comment at the capture site) — an independent
|
|
523
|
+
# recomputation, not grading production against itself, the same
|
|
524
|
+
# "two engines, compared" shape query_answers_match_reference and
|
|
525
|
+
# the fan-out oracle already establish. `recomputed_refused`
|
|
526
|
+
# (Replay's own call, made live, before this step's real dispatch
|
|
527
|
+
# could mutate anything a cross-aggregate given dereferences) is
|
|
528
|
+
# compared against `actual_refused` (GivenNotMet/LifecycleRefused
|
|
529
|
+
# specifically — Replay's own comment on GUARD_REFUSAL_CLASSES
|
|
530
|
+
# explains why ANY other refusal class, or an outright success,
|
|
531
|
+
# both count as "the guard did not fire," since enforce_givens
|
|
532
|
+
# runs FIRST in DISPATCH_ORDER).
|
|
533
|
+
#
|
|
534
|
+
# Aggregate#preconditions closes for free alongside this — a
|
|
535
|
+
# no-block `given` reference (CommandBuilder#given) pushes the
|
|
536
|
+
# SAME Given struct object `enforce_givens` already iterates
|
|
537
|
+
# command.givens for, so there is no separate runtime path a
|
|
538
|
+
# property could exercise beyond what this already reaches.
|
|
539
|
+
# Entity#preconditions closes the identical way, one level down
|
|
540
|
+
# (ADR 0028) — a piece's own bare `given` reference pushes the
|
|
541
|
+
# SAME Given struct onto ITS OWN referencing command's givens,
|
|
542
|
+
# so LedgerEntry's own Amend/Reverse (banking) already exercise
|
|
543
|
+
# this through the exact mechanism above, no separate path.
|
|
544
|
+
#
|
|
545
|
+
# Real targets: Account.Debit/CloseAccount (`from:` guards),
|
|
546
|
+
# Credit/Debit (the named-once `given("customer is active")`
|
|
547
|
+
# precondition) — FreezeAccount deliberately references the
|
|
548
|
+
# DIFFERENT named precondition `"customer is not closed"` instead
|
|
549
|
+
# (a suspended customer must still be freezable), so it is not a
|
|
550
|
+
# `"customer is active"` example, just the same MECHANISM.
|
|
551
|
+
def lifecycle_guard_and_given_violations_are_refused(history)
|
|
552
|
+
offenders = history.fetch(:guard_checks).filter_map do |check|
|
|
553
|
+
next if check[:recomputed_refused] == check[:actual_refused]
|
|
554
|
+
|
|
555
|
+
"#{check[:verb]} — independently recomputing enforce_givens/enforce_lifecycle_guard against the " \
|
|
556
|
+
"pre-dispatch state says #{check[:recomputed_refused] ? "refused (#{check[:recomputed_kind]})" : 'admitted'}, " \
|
|
557
|
+
"but the real dispatch #{check[:actual_refused] ? "refused (#{check[:actual_kind]})" : 'admitted it'}"
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
offenders.empty? || offenders.join("; ")
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Closes Handler#dispatches and — the same shape, one construct
|
|
564
|
+
# over — Policy#with_spec (Dispatch#command_name/Dispatch#with_spec
|
|
565
|
+
# are not claimable feature strings at all — see FEATURE_COVERAGE's
|
|
566
|
+
# own comment on this entry). history[:saga_dispatches]/[:policy_dispatches]
|
|
567
|
+
# (Registry#saga_dispatch_log/#policy_dispatch_log — additive,
|
|
568
|
+
# Ruby-only, NEVER touching saga_log/reaction_log, the byte-for-
|
|
569
|
+
# byte shape spec/rust_conformance_spec.rb holds Rust to) each
|
|
570
|
+
# carry the RAW inputs a dispatch's own args were resolved from,
|
|
571
|
+
# captured live at the moment the resolution actually ran — a
|
|
572
|
+
# saga's own memory keeps changing across a run, so re-deriving
|
|
573
|
+
# from history[:saga_instances]'s FINAL memory (the only other
|
|
574
|
+
# place it would be visible) would grade the wrong moment,
|
|
575
|
+
# lifecycle_guard_and_given_violations_are_refused's own false
|
|
576
|
+
# positive one item earlier, in a different shape.
|
|
577
|
+
#
|
|
578
|
+
# #resolve_dispatch_binding/#resolve_trigger_binding are SEPARATE,
|
|
579
|
+
# independently-written re-derivations of SagaInterpreter#
|
|
580
|
+
# dispatch_args/PolicyInterpreter#trigger_args's own resolution —
|
|
581
|
+
# never calling either method again, which would only ever agree
|
|
582
|
+
# with itself. Exactly the class of bug this closes: "a wrong
|
|
583
|
+
# argument binding on a fan-out dispatch that produces a perfectly
|
|
584
|
+
# normal-looking log entry (`delivered: true`) and would only ever
|
|
585
|
+
# surface as a downstream assertion failure, if it surfaces at
|
|
586
|
+
# all" — PR #325's own defect class, one level over.
|
|
587
|
+
#
|
|
588
|
+
# Real targets: Settlement (mixed literal/correlation-head/event-
|
|
589
|
+
# payload/memory-fallback bindings across three legs, plus a
|
|
590
|
+
# compensation leg that deliberately omits `reference:`, a field
|
|
591
|
+
# the forward Credit leg carries), ExternalSettlement, Onboarding
|
|
592
|
+
# (no compensation leg, by design — nothing to check there beyond
|
|
593
|
+
# the forward leg's own event-payload binding).
|
|
594
|
+
def dispatch_binding_fidelity(history)
|
|
595
|
+
saga_offenders = history.fetch(:saga_dispatches, []).filter_map do |entry|
|
|
596
|
+
expected = resolve_dispatch_binding(entry)
|
|
597
|
+
next if expected == entry[:args]
|
|
598
|
+
|
|
599
|
+
"#{entry[:process_manager]}##{entry[:instance]} dispatching #{entry[:dispatch]} on #{entry[:on]} — " \
|
|
600
|
+
"bound #{entry[:args].inspect}, but independently re-deriving with_spec's own resolution gives " \
|
|
601
|
+
"#{expected.inspect}"
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
policy_offenders = history.fetch(:policy_dispatches, []).filter_map do |entry|
|
|
605
|
+
expected = resolve_trigger_binding(entry)
|
|
606
|
+
next if expected == entry[:args]
|
|
607
|
+
|
|
608
|
+
"#{entry[:policy]} on #{entry[:on]} — bound #{entry[:args].inspect}, but independently re-deriving " \
|
|
609
|
+
"with_spec's own resolution gives #{expected.inspect}"
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
offenders = saga_offenders + policy_offenders
|
|
613
|
+
offenders.empty? || offenders.join("; ")
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# SagaInterpreter#dispatch_args's own 4-branch resolution,
|
|
617
|
+
# reproduced independently: a literal, the correlation key itself,
|
|
618
|
+
# the CURRENT triggering event's own payload, or — the fallback —
|
|
619
|
+
# the saga's own carried memory (seeded from the STARTING event's
|
|
620
|
+
# payload, at begin_saga).
|
|
621
|
+
def resolve_dispatch_binding(entry)
|
|
622
|
+
entry[:with_spec].to_h do |key, value|
|
|
623
|
+
resolved = if !value.is_a?(Symbol) then value
|
|
624
|
+
elsif value == entry[:correlation_head] then entry[:instance]
|
|
625
|
+
elsif entry[:event_payload].key?(value) then entry[:event_payload][value]
|
|
626
|
+
else entry[:memory][value]
|
|
627
|
+
end
|
|
628
|
+
[key.to_sym, Runtime::Value.materialize(resolved)]
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# PolicyInterpreter#trigger_args's own 2-branch resolution — a
|
|
633
|
+
# policy holds no correlation and no memory, so `payload` (the
|
|
634
|
+
# triggering event's own payload, already merged with a fan-out
|
|
635
|
+
# row's id when there is one) is the WHOLE source.
|
|
636
|
+
def resolve_trigger_binding(entry)
|
|
637
|
+
entry[:with_spec].to_h do |key, value|
|
|
638
|
+
resolved = value.is_a?(Symbol) ? entry[:payload][value] : value
|
|
639
|
+
[key.to_sym, Runtime::Value.materialize(resolved)]
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Closes Command#mutations — the last of the five, and the
|
|
644
|
+
# largest: no real corpus entity anywhere uses `append`/`remove`/
|
|
645
|
+
# `multiply`/`clamp` (every real aggregate-owned mutation is
|
|
646
|
+
# aggregate-scoped — Account.Credit's :ledger, LogVisit's
|
|
647
|
+
# :visits); the only entity-owned use of these four ops in the
|
|
648
|
+
# whole repository is spec/fixtures/entity_list_mutations, now a
|
|
649
|
+
# real, bootable, Memory-default domain (no .hecksagon needed at
|
|
650
|
+
# all — a domain with none boots every aggregate against Memory
|
|
651
|
+
# by construction, confirmed live) rather than the raw-Kernel.
|
|
652
|
+
# load-only fixture it was.
|
|
653
|
+
#
|
|
654
|
+
# `history[:mutation_traces]` (Replay's own bounded, additive
|
|
655
|
+
# extension — see #build_mutation_trace's own comment) carries a
|
|
656
|
+
# per-step before/after snapshot of the ENTITY ELEMENT an
|
|
657
|
+
# entity-dispatched command's own mutations acted on, materialized
|
|
658
|
+
# to plain data, plus the step's own raw args — the delta
|
|
659
|
+
# `aggregation_matches_recompute` never had to ask for, because
|
|
660
|
+
# count/median are pure functions of FINAL state and a mutation
|
|
661
|
+
# is not (the same "captured live, not re-derived from final
|
|
662
|
+
# state" lesson item 8's own saga_dispatch_log already learned).
|
|
663
|
+
#
|
|
664
|
+
# #recompute_append/#recompute_remove/#recompute_multiply/
|
|
665
|
+
# #recompute_clamp are SEPARATE, independently-written
|
|
666
|
+
# reproductions of MutationApplier#appended/#removed and
|
|
667
|
+
# CommandRules::Arithmetic#multiply/#clamp — never calling either
|
|
668
|
+
# again, which would only ever agree with itself. `:set`/
|
|
669
|
+
# `:increment`/`:decrement` are out of scope on purpose (the four
|
|
670
|
+
# "vendored, not yet upstream" ops this item exists for); a
|
|
671
|
+
# command mixing them with a recomputable op still gets the
|
|
672
|
+
# recomputable one checked.
|
|
673
|
+
#
|
|
674
|
+
# `:unrecomputable` (never compared, never a finding) covers the
|
|
675
|
+
# generator's own deliberate arg-malforming (`StepBuilder#malform`)
|
|
676
|
+
# landing a non-Numeric amount/non-2-element bounds where
|
|
677
|
+
# multiply/clamp need one — the SAME shape `guard_check`'s own
|
|
678
|
+
# AbsentArgument false positive taught: a step whose raw material
|
|
679
|
+
# doesn't fit the op's own contract is inconclusive, not a claimed
|
|
680
|
+
# mismatch.
|
|
681
|
+
RECOMPUTABLE_MUTATION_OPS = %i[append remove multiply clamp].freeze
|
|
682
|
+
|
|
683
|
+
def mutations_match_recompute(history)
|
|
684
|
+
bluebooks = history.fetch(:bluebooks)
|
|
685
|
+
|
|
686
|
+
offenders = history.fetch(:mutation_traces, []).flat_map do |entry|
|
|
687
|
+
next [] unless entry[:after]
|
|
688
|
+
|
|
689
|
+
command = command_for_verb(bluebooks, entry[:verb])
|
|
690
|
+
next [] unless command
|
|
691
|
+
|
|
692
|
+
command.mutations.select { |m| RECOMPUTABLE_MUTATION_OPS.include?(m.op) }.filter_map do |mutation|
|
|
693
|
+
expected = recompute_mutation(mutation, entry[:before][mutation.target], entry[:args], entry[:before])
|
|
694
|
+
next if expected == :unrecomputable
|
|
695
|
+
|
|
696
|
+
actual = entry[:after][mutation.target]
|
|
697
|
+
next if symbolize_deep(expected) == symbolize_deep(actual)
|
|
698
|
+
|
|
699
|
+
"#{entry[:verb]} — #{mutation.op} on #{mutation.target} — recomputing independently gives " \
|
|
700
|
+
"#{expected.inspect}, but the real dispatch left #{actual.inspect}"
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
offenders.empty? || offenders.join("; ")
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def recompute_mutation(mutation, current, args, before_scope)
|
|
708
|
+
case mutation.op
|
|
709
|
+
when :append then recompute_append(current, mutation.source, before_scope, args)
|
|
710
|
+
when :remove then recompute_remove(current, mutation.source, args)
|
|
711
|
+
when :multiply then recompute_multiply(current, resolve_mutation_source(mutation.source, args))
|
|
712
|
+
when :clamp then recompute_clamp(current, mutation.source)
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# `MutationApplier#appended`'s own value-object branch (never the
|
|
717
|
+
# entity_element branch — see #build_mutation_trace's own comment
|
|
718
|
+
# on why an entity-dispatched command's own mutations never reach
|
|
719
|
+
# it), reproduced: the field map resolved the SAME two-tier way
|
|
720
|
+
# (`MutationApplier#resolve_append_source` — a caller-supplied
|
|
721
|
+
# arg, or the entity's own current field), then appended.
|
|
722
|
+
def recompute_append(current, source_map, before_scope, args)
|
|
723
|
+
fields = source_map.transform_values { |source| resolve_mutation_append_field(source, before_scope, args) }
|
|
724
|
+
Array(current) + [symbolize_deep(fields)]
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def resolve_mutation_append_field(source, before_scope, args)
|
|
728
|
+
return source unless source.is_a?(Symbol)
|
|
729
|
+
return args[source] if args.key?(source)
|
|
730
|
+
|
|
731
|
+
before_scope[source]
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# `MutationApplier#removed`'s own value-equality match, reproduced.
|
|
735
|
+
def recompute_remove(current, source, args)
|
|
736
|
+
target = symbolize_deep(resolve_mutation_source(source, args))
|
|
737
|
+
Array(current).reject { |element| symbolize_deep(element) == target }
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# `CommandRules::Arithmetic#multiply`'s own two branches,
|
|
741
|
+
# reproduced on plain materialized data instead of a real Value:
|
|
742
|
+
# a single-numeric-field Hash (the VO-typed case — ListCount, one
|
|
743
|
+
# Integer field) scales that field ; a bare Numeric scales itself.
|
|
744
|
+
# `current ||= 0` — the SAME phantom-field fallback #multiply
|
|
745
|
+
# itself already gives (unaffected by this session's #clamp fix,
|
|
746
|
+
# since #multiply never needed one).
|
|
747
|
+
def recompute_multiply(current, amount)
|
|
748
|
+
return :unrecomputable unless amount.is_a?(Numeric)
|
|
749
|
+
|
|
750
|
+
current ||= 0
|
|
751
|
+
if current.is_a?(Hash)
|
|
752
|
+
field = current.keys.find { |f| current[f].is_a?(Numeric) }
|
|
753
|
+
return :unrecomputable unless field
|
|
754
|
+
|
|
755
|
+
current.merge(field => current[field] * amount)
|
|
756
|
+
elsif current.is_a?(Numeric)
|
|
757
|
+
current * amount
|
|
758
|
+
else
|
|
759
|
+
:unrecomputable
|
|
760
|
+
end
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# `CommandRules::Arithmetic#clamp`'s own two branches, reproduced
|
|
764
|
+
# the same way #recompute_multiply is — including THIS SESSION'S
|
|
765
|
+
# OWN `current ||= 0` fix (command_rules/arithmetic.rb), the one
|
|
766
|
+
# arithmetic op that didn't have it until now. `mutation.source`
|
|
767
|
+
# is always a literal `[min, max]`, never an argument reference
|
|
768
|
+
# (MutationApplier's own comment on why `resolve_source` is
|
|
769
|
+
# skipped for clamp) — so nothing here reads `args` for it at all.
|
|
770
|
+
def recompute_clamp(current, bounds)
|
|
771
|
+
return :unrecomputable unless bounds.is_a?(Array) && bounds.size == 2
|
|
772
|
+
|
|
773
|
+
min, max = bounds
|
|
774
|
+
current ||= 0
|
|
775
|
+
if current.is_a?(Hash)
|
|
776
|
+
field = current.keys.find { |f| current[f].is_a?(Numeric) }
|
|
777
|
+
return :unrecomputable unless field
|
|
778
|
+
|
|
779
|
+
current.merge(field => current[field].clamp(min, max))
|
|
780
|
+
elsif current.is_a?(Numeric)
|
|
781
|
+
current.clamp(min, max)
|
|
782
|
+
else
|
|
783
|
+
:unrecomputable
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
# `CommandRules::Arithmetic#resolve_source`, reproduced: a
|
|
788
|
+
# mutation's source is either the NAME OF AN ARGUMENT or a
|
|
789
|
+
# LITERAL, told apart by type.
|
|
790
|
+
def resolve_mutation_source(source, args)
|
|
791
|
+
source.is_a?(Symbol) ? args[source] : source
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# A generated step's own `args` arrive with STRING keys on every
|
|
795
|
+
# nested Hash (the wire/JSON shape `spec/corpus/*.json` already
|
|
796
|
+
# uses) while `history[:mutation_traces]`' own materialized
|
|
797
|
+
# before/after state carries SYMBOL keys throughout (Runtime::
|
|
798
|
+
# Value.materialize's own convention) — two hashes holding the
|
|
799
|
+
# identical fact compare UNEQUAL by Ruby's own `Hash#==` unless
|
|
800
|
+
# both sides are normalized the same way first. Recursive, since
|
|
801
|
+
# an appended/removed element can itself nest a value object
|
|
802
|
+
# (RemoveTag's own `Tag` argument, `{"key"=>..., "value"=>...}`).
|
|
803
|
+
def symbolize_deep(value)
|
|
804
|
+
case value
|
|
805
|
+
when Hash then value.to_h { |key, val| [key.to_sym, symbolize_deep(val)] }
|
|
806
|
+
when Array then value.map { |val| symbolize_deep(val) }
|
|
807
|
+
else value
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# EVERY STORED RECORD STILL SATISFIES ITS OWN AGGREGATE'S DECLARED
|
|
812
|
+
# INVARIANTS — Admissibility#enforce_invariants (command_rules/
|
|
813
|
+
# admissibility.rb) checks these AFTER every command's mutations,
|
|
814
|
+
# BEFORE save, the same point `ensures` is checked. Nothing until
|
|
815
|
+
# now re-checked a record AFTER a whole replay finished, independent
|
|
816
|
+
# of whichever call site was supposed to have refused a violation
|
|
817
|
+
# in the first place — a record failing its own declared invariant
|
|
818
|
+
# here is proof a violating write landed anyway: the call site
|
|
819
|
+
# stopped calling enforce_invariants, or some other path (a
|
|
820
|
+
# translation, a backfill) wrote around it entirely.
|
|
821
|
+
#
|
|
822
|
+
# `history[:instances]` entries are already plain, symbol-keyed
|
|
823
|
+
# state Hashes (Replay.call's own `record.state`) — called against
|
|
824
|
+
# Evaluator.call the SAME way ValueObject::Builder#build already
|
|
825
|
+
# does for a VO's own invariants (value/coercion.rb), no GuardState
|
|
826
|
+
# wrapper needed the way enforce_invariants' own LIVE call uses one
|
|
827
|
+
# (GuardState exists for `parent.`/projected-field dereferencing
|
|
828
|
+
# mid-dispatch; a stored record's own scalar fields need none of
|
|
829
|
+
# that to re-check a same-aggregate invariant against itself).
|
|
830
|
+
#
|
|
831
|
+
# Real target: Account's own `invariant("the balance never goes
|
|
832
|
+
# negative") { balance.cents >= 0 }`.
|
|
833
|
+
#
|
|
834
|
+
# Entity#invariants (round 7) closes here too, not for free —
|
|
835
|
+
# `stored_records_satisfy_declared_invariants` only ever checked
|
|
836
|
+
# the AGGREGATE's own flat state; a piece's own invariant is
|
|
837
|
+
# checked against every ELEMENT of a `list_of` field, a genuinely
|
|
838
|
+
# different walk `check_piece_invariants` below makes,
|
|
839
|
+
# independently of `Admissibility#check_entity_invariants` (the
|
|
840
|
+
# live enforcement path this property exists to catch drifting
|
|
841
|
+
# from) — same reasoning `stored_records_satisfy_declared_
|
|
842
|
+
# invariants`' own top-level check already applies one level up.
|
|
843
|
+
#
|
|
844
|
+
# Real target: SafeDepositBox's own Visit — `invariant("a written
|
|
845
|
+
# note is not blank") { !note || !note.text.to_s.empty? }`.
|
|
846
|
+
def stored_records_satisfy_declared_invariants(history)
|
|
847
|
+
bluebooks = history.fetch(:bluebooks)
|
|
848
|
+
|
|
849
|
+
offenders = history.fetch(:instances).filter_map do |key, state|
|
|
850
|
+
domain_name = key.split("::").first
|
|
851
|
+
aggregate_name = key.split("::").last.split("#").first
|
|
852
|
+
bluebook = bluebooks[domain_name]
|
|
853
|
+
aggregate = bluebook&.aggregate(aggregate_name)
|
|
854
|
+
next unless aggregate
|
|
855
|
+
|
|
856
|
+
violated = aggregate.invariants.find do |invariant|
|
|
857
|
+
!Bluebook::Expression::Evaluator.call(invariant.canonical, state)
|
|
858
|
+
end
|
|
859
|
+
next "#{key} violates #{aggregate_name}'s own declared invariant #{violated.description.inspect}" if violated
|
|
860
|
+
|
|
861
|
+
check_piece_invariants(aggregate, state, key)
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
offenders.empty? || offenders.join("; ")
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# A PIECE'S OWN INVARIANT, checked against every element a
|
|
868
|
+
# `list_of` field holds — the SAME lookup `Admissibility#
|
|
869
|
+
# check_entity_invariants` makes (`owner.attributes.find { |a|
|
|
870
|
+
# a.list? && a.type.to_s == entity.hecks_name }`), independently
|
|
871
|
+
# reapplied here against a STORED record's own plain Hash state
|
|
872
|
+
# rather than a live `Instance`.
|
|
873
|
+
def check_piece_invariants(owner_construct, owner_state, key)
|
|
874
|
+
owner_construct.entities.each do |entity|
|
|
875
|
+
next if entity.invariants.empty?
|
|
876
|
+
|
|
877
|
+
list_attr = owner_construct.attributes.find { |a| a.list? && a.type.to_s == entity.hecks_name }
|
|
878
|
+
next unless list_attr
|
|
879
|
+
|
|
880
|
+
Array(owner_state[list_attr.name]).each do |element|
|
|
881
|
+
violated = entity.invariants.find do |invariant|
|
|
882
|
+
!Bluebook::Expression::Evaluator.call(invariant.canonical, element)
|
|
883
|
+
end
|
|
884
|
+
return "#{key}'s own #{entity.hecks_name} violates its declared invariant #{violated.description.inspect}" if violated
|
|
885
|
+
|
|
886
|
+
nested = check_piece_invariants(entity, element, key)
|
|
887
|
+
return nested if nested
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
nil
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
# A SAGA INSTANCE'S OWN CHECKPOINT SURVIVES BEING WRITTEN AND READ
|
|
894
|
+
# BACK — the durability contract `SagaInterpreter#checkpoint` makes
|
|
895
|
+
# (`state:` plus a `deep_copy`d `memory:`, handed to whatever
|
|
896
|
+
# adapter answers `save_saga`) and `Registry#rehydrate_sagas!`
|
|
897
|
+
# promises to restore on the next boot (`each_saga` yielding
|
|
898
|
+
# `[pm, correlation, state, memory]` back into `saga_instances`).
|
|
899
|
+
# `Replay` captures the LIVE store already materialised the same
|
|
900
|
+
# way `checkpoint` itself does (`Value.materialize`, not raw
|
|
901
|
+
# `Runtime::Value`s — see its own comment); this property pushes
|
|
902
|
+
# that captured memory through the SAME `JSON.generate` then
|
|
903
|
+
# `JSON.parse(symbolize_names: true)` round-trip `checkpoint`'s own
|
|
904
|
+
# `deep_copy` performs (mirrored here rather than called — a
|
|
905
|
+
# private instance method with no registry to hand it) and checks
|
|
906
|
+
# it comes back byte-identical. A memory holding anything that
|
|
907
|
+
# round-trip cannot carry faithfully — a bare Symbol leaf, a
|
|
908
|
+
# non-JSON type a future field introduces — is corruption the
|
|
909
|
+
# durable path would introduce on a REAL restart, caught here
|
|
910
|
+
# without needing one.
|
|
911
|
+
#
|
|
912
|
+
# `declares_state?` (Behaviour::ProcessManager) is the other half:
|
|
913
|
+
# a live or rehydrated instance sitting in a state the procedure
|
|
914
|
+
# never declares is the saga-durability twin of
|
|
915
|
+
# `lifecycle_values_are_declared` above.
|
|
916
|
+
def sagas_rehydrate_cleanly(history)
|
|
917
|
+
bluebook = history.fetch(:bluebook)
|
|
918
|
+
process_managers = bluebook.process_managers.to_h { |pm| [pm.name, pm] }
|
|
919
|
+
|
|
920
|
+
offenders = history.fetch(:saga_instances).flat_map do |pm_name, conversations|
|
|
921
|
+
pm = process_managers[pm_name]
|
|
922
|
+
|
|
923
|
+
conversations.filter_map do |correlation, instance|
|
|
924
|
+
problems = []
|
|
925
|
+
|
|
926
|
+
problems << "holds state #{instance[:state].inspect}, which #{pm_name} never declares" if pm && !pm.declares_state?(instance[:state])
|
|
927
|
+
|
|
928
|
+
rehydrated = JSON.parse(JSON.generate(instance[:memory]), symbolize_names: true)
|
|
929
|
+
if rehydrated != instance[:memory]
|
|
930
|
+
problems << "memory does not survive its own checkpoint round-trip " \
|
|
931
|
+
"(checkpointed #{instance[:memory].inspect}, rehydrated #{rehydrated.inspect})"
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
next if problems.empty?
|
|
935
|
+
|
|
936
|
+
"#{pm_name}##{correlation.inspect}: #{problems.join(' and ')}"
|
|
937
|
+
end
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
offenders.empty? || offenders.join("; ")
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# A `for_each` POLICY DISPATCHES EXACTLY ONCE PER ROW ITS DECLARED
|
|
944
|
+
# QUERY ANSWERS — never once for the triggering event regardless of
|
|
945
|
+
# row count, never skipping a matched row, never firing on a row a
|
|
946
|
+
# concurrent mutation only made match AFTER the fact. `Replay`
|
|
947
|
+
# computes the expected row-id set INDEPENDENTLY, at the same
|
|
948
|
+
# instant the real dispatch runs (`Replay.expected_fan_out_rows`,
|
|
949
|
+
# the query oracle's own shape aimed at fan-out: two engines
|
|
950
|
+
# compared, never one graded against itself), and records it
|
|
951
|
+
# beside what the reaction log actually shows. `expected_row_ids`
|
|
952
|
+
# is `nil`, not `[]`, when `policy.where` did not hold — no
|
|
953
|
+
# dispatch is the claim then, not "dispatched to zero rows," and a
|
|
954
|
+
# policy that dispatched anyway despite a failing guard is as real
|
|
955
|
+
# a finding as a row it skipped.
|
|
956
|
+
def fanout_dispatches_once_per_matching_row(history)
|
|
957
|
+
offenders = history.fetch(:fan_outs).filter_map do |finding|
|
|
958
|
+
expected = finding[:expected_row_ids]
|
|
959
|
+
actual = finding[:actual_row_ids].sort
|
|
960
|
+
|
|
961
|
+
if expected.nil?
|
|
962
|
+
next if actual.empty?
|
|
963
|
+
|
|
964
|
+
next "#{finding[:policy]} on #{finding[:on]}: where did not hold, but dispatched to #{actual.inspect}"
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
next if actual == expected
|
|
968
|
+
|
|
969
|
+
"#{finding[:policy]} on #{finding[:on]}: for_each answered #{expected.inspect}, " \
|
|
970
|
+
"but the reaction log shows dispatches to #{actual.inspect}"
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
offenders.empty? || offenders.join("; ")
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
# A `count`/`median` REPORT'S REDUCED SCALAR MATCHES THE SAME
|
|
977
|
+
# REDUCTION DONE INDEPENDENTLY, over the SAME eligible rows —
|
|
978
|
+
# `ReadModelInterpreter#project`'s own FK-join (root first, then
|
|
979
|
+
# each many-side head matched against it) and `#median` (odd →
|
|
980
|
+
# the true middle, even → the average of the two middles as a
|
|
981
|
+
# Float, empty → `nil`; `count` is the filtered length, empty →
|
|
982
|
+
# `0`), reproduced here in plain Ruby against `history[:instances]`
|
|
983
|
+
# rather than a live registry — `FieldPath.dig` +
|
|
984
|
+
# `Ports::Query::InMemory.comparable`/`.holds?` are the SAME two
|
|
985
|
+
# calls the interpreter itself makes to read a field and judge a
|
|
986
|
+
# `where`, called here rather than re-derived, so this oracle
|
|
987
|
+
# cannot drift from what "read a field" or "a clause holds" mean
|
|
988
|
+
# without the interpreter drifting the identical way.
|
|
989
|
+
#
|
|
990
|
+
# Only a report whose `:query` is answered by the SAME bluebook
|
|
991
|
+
# `history[:bluebook]` carries (the bare `Domain.report_name`
|
|
992
|
+
# form, `domain == bluebook.name`) is checked — the same "only
|
|
993
|
+
# what we have the grammar for" scope `lifecycle_values_are_declared`
|
|
994
|
+
# already takes for a multi-domain replay.
|
|
995
|
+
def aggregation_matches_recompute(history)
|
|
996
|
+
bluebook = history.fetch(:bluebook)
|
|
997
|
+
|
|
998
|
+
offenders = history.fetch(:queries).filter_map do |asked|
|
|
999
|
+
next if asked[:error]
|
|
1000
|
+
|
|
1001
|
+
domain, name = asked[:query].to_s.split(".", 2)
|
|
1002
|
+
next unless name && domain == bluebook.name
|
|
1003
|
+
|
|
1004
|
+
model = bluebook.read_model(name)
|
|
1005
|
+
next unless model && (model.count? || model.median_field)
|
|
1006
|
+
|
|
1007
|
+
reduced_head = model.aggregate_heads.find { |head| head[:many] }
|
|
1008
|
+
next unless reduced_head
|
|
1009
|
+
|
|
1010
|
+
rows = eligible_rows(bluebook, asked.fetch(:instances_at), domain, model, reduced_head, asked[:args] || {})
|
|
1011
|
+
expected = model.count? ? rows.length : recompute_median(rows, model.median_field)
|
|
1012
|
+
actual = asked[:rows]&.first&.dig(reduced_head[:as])
|
|
1013
|
+
next if actual == expected
|
|
1014
|
+
|
|
1015
|
+
"#{asked[:query]} #{asked[:args].inspect} answered #{actual.inspect} for #{reduced_head[:as]}, " \
|
|
1016
|
+
"but recomputing independently from #{rows.length} eligible row(s) gives #{expected.inspect}"
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
offenders.empty? || offenders.join("; ")
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
# `aggregation_matches_recompute`'s own shape, extended from
|
|
1023
|
+
# reducing a many-side head to a scalar (count/median) to NESTING
|
|
1024
|
+
# it — `ReadModelInterpreter#group_by_target`/`#nest`, reproduced
|
|
1025
|
+
# here in plain Ruby against `history[:instances]` the same way
|
|
1026
|
+
# `eligible_rows` already reproduces the FK-join and `where`
|
|
1027
|
+
# narrowing count/median share. `Value.materialize_unwrapped` is
|
|
1028
|
+
# the SAME call `#project` makes before nesting (a single-field
|
|
1029
|
+
# value object recurses to its bare scalar — a real grouping key
|
|
1030
|
+
# has to BE one) — called here rather than re-derived, so this
|
|
1031
|
+
# oracle cannot drift from what "the group key" means without the
|
|
1032
|
+
# interpreter drifting the identical way.
|
|
1033
|
+
#
|
|
1034
|
+
# Real target: AccountsByKind (`group_by :kind, :number`,
|
|
1035
|
+
# rootless — always generator-eligible with `{}` args).
|
|
1036
|
+
def group_by_matches_recompute(history)
|
|
1037
|
+
bluebook = history.fetch(:bluebook)
|
|
1038
|
+
|
|
1039
|
+
offenders = history.fetch(:queries).filter_map do |asked|
|
|
1040
|
+
next if asked[:error]
|
|
1041
|
+
|
|
1042
|
+
domain, name = asked[:query].to_s.split(".", 2)
|
|
1043
|
+
next unless name && domain == bluebook.name
|
|
1044
|
+
|
|
1045
|
+
model = bluebook.read_model(name)
|
|
1046
|
+
next unless model && model.group_by.any?
|
|
1047
|
+
|
|
1048
|
+
grouped_head = model.aggregate_heads.find { |head| head[:many] }
|
|
1049
|
+
next unless grouped_head
|
|
1050
|
+
|
|
1051
|
+
rows = eligible_rows(bluebook, asked.fetch(:instances_at), domain, model, grouped_head, asked[:args] || {})
|
|
1052
|
+
materialized = rows.map { |state| Runtime::Value.materialize_unwrapped(state) }
|
|
1053
|
+
expected = nest_rows(materialized, model.group_by_fields)
|
|
1054
|
+
actual = asked[:rows]&.first&.dig(grouped_head[:as])
|
|
1055
|
+
next if actual == expected
|
|
1056
|
+
|
|
1057
|
+
"#{asked[:query]} #{asked[:args].inspect} answered a #{grouped_head[:as]} grouping that disagrees " \
|
|
1058
|
+
"with independently nesting group_by #{model.group_by_fields.inspect} over #{rows.length} " \
|
|
1059
|
+
"eligible row(s)"
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
offenders.empty? || offenders.join("; ")
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
# `ReadModelInterpreter#nest`, byte for byte: one level of nesting
|
|
1066
|
+
# per `group_by` field in declared order, leaf is the row with
|
|
1067
|
+
# every grouped field stripped (already spent, as the keys that
|
|
1068
|
+
# reached it).
|
|
1069
|
+
def nest_rows(rows, fields)
|
|
1070
|
+
field, *rest = fields
|
|
1071
|
+
rows.group_by { |row| row[field] }.transform_values do |group|
|
|
1072
|
+
stripped = group.map { |row| row.reject { |key, _| key == field } }
|
|
1073
|
+
rest.empty? ? stripped.first : nest_rows(stripped, rest)
|
|
1074
|
+
end
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
# THE ELIGIBLE ROWS a `count`/`median` head reduces — every
|
|
1078
|
+
# instance of the reduced head's own aggregate, FK-matched against
|
|
1079
|
+
# the report's root reference (if it has one; a rootless report has
|
|
1080
|
+
# none to match) exactly the way `ReadModelInterpreter#reference_fields`
|
|
1081
|
+
# finds the matching attribute, then narrowed by the report's own
|
|
1082
|
+
# `where` clauses via the SAME `InMemory.holds?` the interpreter's
|
|
1083
|
+
# `execute` calls.
|
|
1084
|
+
def eligible_rows(bluebook, instances, domain, model, reduced_head, args)
|
|
1085
|
+
aggregate = bluebook.aggregate(reduced_head[:aggregate])
|
|
1086
|
+
prefix = "#{domain}::#{reduced_head[:aggregate]}#"
|
|
1087
|
+
# `id:` MERGED IN, the same `record.to_h` (`@state.merge(id:
|
|
1088
|
+
# @id)`) every live head row carries — count/median never read
|
|
1089
|
+
# it, but group_by_matches_recompute's own independent nesting
|
|
1090
|
+
# does, the same way ReadModelInterpreter#row(record) = record.
|
|
1091
|
+
# to_h does for the live path it's checking against.
|
|
1092
|
+
rows = instances.filter_map { |key, state| state.merge(id: key.split("#").last) if key.start_with?(prefix) }
|
|
1093
|
+
|
|
1094
|
+
if model.reference_target
|
|
1095
|
+
reference_id = args[model.reference_name].to_s
|
|
1096
|
+
fk_fields = aggregate.attributes.select do |attribute|
|
|
1097
|
+
attribute.reference? && attribute.type.target_name == model.reference_target.to_s
|
|
1098
|
+
end.map(&:name)
|
|
1099
|
+
|
|
1100
|
+
rows = rows.select { |state| fk_fields.any? { |field| state[field].to_s == reference_id } }
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
rows.select do |state|
|
|
1104
|
+
model.wheres.all? do |clause|
|
|
1105
|
+
held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, clause.field))
|
|
1106
|
+
Ports::Query::InMemory.holds?(clause, held, args)
|
|
1107
|
+
end
|
|
1108
|
+
end
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
# `ReadModelInterpreter#median`'s own definition, reproduced byte
|
|
1112
|
+
# for byte: odd count → the true middle value, sorted; even count
|
|
1113
|
+
# → the average of the two middle values, as a Float; empty → nil,
|
|
1114
|
+
# never zero, so a caller cannot mistake "nothing to average" for
|
|
1115
|
+
# "averaged to zero."
|
|
1116
|
+
def recompute_median(rows, field)
|
|
1117
|
+
values = rows.map { |state| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, field)) }
|
|
1118
|
+
.compact.sort
|
|
1119
|
+
return nil if values.empty?
|
|
1120
|
+
|
|
1121
|
+
middle = values.length / 2
|
|
1122
|
+
values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
# THE STANDARD BATTERY, run over one replayed history — everything
|
|
1126
|
+
# except determinism, which needs to replay TWICE itself and so
|
|
1127
|
+
# takes the steps directly rather than a single history.
|
|
1128
|
+
def check(history)
|
|
1129
|
+
{ lifecycle_values_are_declared: lifecycle_values_are_declared(history),
|
|
1130
|
+
saga_advances_follow_declared_handlers: saga_advances_follow_declared_handlers(history),
|
|
1131
|
+
query_answers_match_reference: query_answers_match_reference(history),
|
|
1132
|
+
guard_refusals_are_declared: guard_refusals_are_declared(history),
|
|
1133
|
+
sagas_rehydrate_cleanly: sagas_rehydrate_cleanly(history),
|
|
1134
|
+
fanout_dispatches_once_per_matching_row: fanout_dispatches_once_per_matching_row(history),
|
|
1135
|
+
aggregation_matches_recompute: aggregation_matches_recompute(history),
|
|
1136
|
+
stored_records_satisfy_declared_invariants: stored_records_satisfy_declared_invariants(history),
|
|
1137
|
+
group_by_matches_recompute: group_by_matches_recompute(history),
|
|
1138
|
+
paging_offset_partitions_correctly: paging_offset_partitions_correctly(history),
|
|
1139
|
+
lifecycle_guard_and_given_violations_are_refused: lifecycle_guard_and_given_violations_are_refused(history),
|
|
1140
|
+
authorize_scopes_or_refuses: authorize_scopes_or_refuses(history),
|
|
1141
|
+
dispatch_binding_fidelity: dispatch_binding_fidelity(history),
|
|
1142
|
+
mutations_match_recompute: mutations_match_recompute(history) }
|
|
1143
|
+
end
|
|
1144
|
+
end
|
|
1145
|
+
end
|
|
1146
|
+
end
|